feat(security): expose install policy warning approval

This commit is contained in:
jesse-merhi
2026-08-13 14:43:54 +10:00
parent 2624f97ce9
commit 1712e7fd61
64 changed files with 3101 additions and 413 deletions
@@ -1 +1 @@
{"contentHash":"a35378b670434316ba33a3ba4e595925be685daee9fb0f3c5989983525b18c50","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
{"contentHash":"68fc42bf703b4d4330d5b81c33cda5efcc08513ed2695577c4e721f1a650d9bd","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"ae00c815c607cea46536c879daf88c76490920cc9d5beefa1822f384b15610e1","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"0f06f4f4822eadd8f372a44210452712cdbb6f374d9e71d742b81f3358356929","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"d8b7c867b29b4651375455e938ae634e7213e3e8c17444dc2b0ef091e46fda6c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"3d7b5d15182a191670d0f1c0b1ee8e07337b9cf7055653d3e85a6c9cf2e72732","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -1 +1 @@
{"contentHash":"883bbab77caeff122bf8b7505dc9299484fba601e285a42d2c457697b7590b8e","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
{"contentHash":"e8771f0e1539fbdc6902392043b641ea192f566e884895f261ba87fb5038db6d","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
@@ -1 +1 @@
{"contentHash":"2f85610859f047db1d76efbe52d9e63d8126ce8e944d53d6fdafb4e7f503b5dc","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"2ec34a7ea4128251e0b5c694703d9c57b1f6e9bacb21474e164ead33e40f1331","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"6b1f575e77dc71e72c4b2575f366e7e9990d391e85364844e0ecec2146e7f902","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"006b5a333d131e09438f838c5200c3da1c2ccb30c96a269b085626b9f18e1eb7","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -1 +1 @@
{"contentHash":"72d713de6cb87ad8e6d9c49456477367a2d30c8157695ddb66ac57919558272b","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
{"contentHash":"25317f5f757a366c0e8de79ce4e70c6ceae8cf4c55fc1b0cbcd7e573f1de1fa7","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"2ccfe8eb85378f3be3f78f79f2638b00faa943e351ff69464bfed44ed63e09c1","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"a27a7ab5f3a2a166675222eda5755a6991b99c982106548b848616c14e977e18","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"d23cd5df763235dbe35ede1c23d26735f4c183b03dc216ac212aefe75c0c1f96","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"af1acb53d110c0ebec021ad878018e57f028c201348bf813f652e13d0db2c674","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -1 +1 @@
{"contentHash":"b691fcfb34a5f228938d06c50f9f8a26bb63ce06644e9df25f44876601e4cebb","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
{"contentHash":"5669646ab04c2aaf510924b853e925a6e5e8798fa5c3e3b500678d228131e49b","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
@@ -1 +1 @@
{"contentHash":"f57a244e027b326090ed91cdc105c0c8c83b24318d99be0ec205fffd33504050","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
{"contentHash":"03291a3f9de779b5c69bd06803dc7d759c96e9a19ffa10f56c865c22f63d692f","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
@@ -1 +1 @@
{"contentHash":"5008615c80d86383e18527d71442f1501e4f02bdb3faa1494a48edf40fd66f84","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"5d8f9b45e4338ab4623a15d77fc95c850a6dec6d30e6f38e4c355730229c678d","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"6b73741c889f68cc19a5d1f8703df2eca650fe57803795045cff122d746cbb6a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"cc9aeab1ce02f61610eb10aeee25023f729c8b81a4b7d2759235953e016cdf4f","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"15704fc50b81cebceb677a5a69a7bc8c97afd962081c647de58a027bd816f19d","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"76570c04f575db917766195be05f7ae0daa457cb3f82915e4478c5890f95694b","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -1 +1 @@
{"contentHash":"df8043d0630d95bc4c316da28bf675a713e7f1f0c304959d1b4b9c8b30fcbd0d","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
{"contentHash":"b65a8a25a13043e2aaf3a71e6a33a04b293d7d7ff762f91fb8583e424191a84b","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"8b0c71c8abe13163eee28ca65e6afb490d0ae3c16288292143ad021975b96ca6","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"054fa8033deea8ae745cd47a80b8e9bffe963464674f73a45160452da1f8fc1a","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"eb0096fd25551a7f4a58dcf6c6b53120f3f1ddc1248b2b962cca60c75f6daa12","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"047e294fde7ac6b68feb7b1b490a4ed708b2cabb5f9db51ffc1f7fc1ae39645f","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
+1 -1
View File
@@ -560,7 +560,7 @@ methods. Treat this as feature discovery, not a full enumeration of
<Accordion title="Plugin management">
- `plugins.list` (`operator.read`) returns the installed plugin inventory plus locally curated official picks, diagnostics, and whether the current install mode allows mutations.
- `plugins.search` (`operator.read`) searches installable ClawHub code-plugin and bundle-plugin families. Pass non-empty `query` and optional `limit` from 1 to 100.
- `plugins.install` (`operator.admin`) installs either an official catalog entry with `{ source: "official", pluginId }` or a ClawHub package with `{ source: "clawhub", packageName, version?, acknowledgeClawHubRisk? }`. ClawHub installs preserve Gateway trust, integrity, and install-policy checks. Successful installs require a Gateway restart.
- `plugins.install` (`operator.admin`) installs either an official catalog entry with `{ source: "official", pluginId, installPolicyWarningAcknowledgement? }` or a ClawHub package with `{ source: "clawhub", packageName, version?, acknowledgeClawHubRisk?, installPolicyWarningAcknowledgement? }`. ClawHub installs preserve Gateway trust, integrity, and install-policy checks. When install policy warns and the Gateway can bind the request to an immutable resolved artifact, the request fails before commit with structured `error.details` containing `installPolicyCode: "install_policy_warning_acknowledgement_required"`, target metadata, `reason`, optional `findings`, and a server-issued `acknowledgementToken`. After showing those details, a client may retry with that token as `installPolicyWarningAcknowledgement`. Every presentation consumes the token, including an expired or wrong-request attempt; only its first presentation can authorize the same install request and resolved artifact. Policy then re-evaluates the staged source and continues when that fresh evaluation allows the install or repeats the unchanged warning the operator approved. Treat `acknowledgementToken` as a short-lived bearer secret. It is not bound to the Gateway connection, device, or operator that received it: any authenticated `operator.admin` client that has the token and submits the same install request may consume it once for the matching resolved artifact. Keep it only in memory for the immediate reviewed retry; do not log, persist, or forward it. Tokens expire after five minutes and are invalidated by a Gateway restart; after either event, retry without the token to receive the current warning and a fresh token. A changed or later warning fails that request with its own structured details and, when the artifact remains immutably resolved, a fresh token for another reviewed retry. A block or a warning without immutable resolution metadata is terminal and does not include an acknowledgement token. Successful installs require a Gateway restart.
- `plugins.setEnabled` (`operator.admin`) changes one installed plugin's enabled policy with `{ pluginId, enabled }`. The response includes the updated catalog entry, restart metadata, and any slot-selection warnings.
- `plugins.uninstall` (`operator.admin`) removes one externally installed plugin with `{ pluginId }`: config references, the install record, and managed files. Bundled plugins cannot be uninstalled, only disabled. The response lists the removal actions and always requires a Gateway restart.
+27 -9
View File
@@ -153,17 +153,35 @@ copy as suspicious ClawHub releases; policy is then re-evaluated. Reviewed
non-interactive direct CLI commands can use `--acknowledge-install-policy-warning`.
That flag is consumed by the first warning in one command; a later warning
fails closed and requires interactive review.
Gateway-backed and automatic installs remain blocked on warnings because they
have no operator-confirmation flow. When an equivalent direct plugin or skill
command exists, use that command to review and approve the warning. Otherwise,
change `security.installPolicy` to return `allow` for the reviewed request, then
retry the managed flow. Neither `--force` nor the deprecated plugin
install/update flag `--dangerously-force-unsafe-install` approves a policy
warning. Plugin
Gateway `plugins.install` clients receive structured warning details when the
Gateway can bind the request to an immutable resolved artifact, and may make
one explicit retry with the returned `acknowledgementToken` as
`installPolicyWarningAcknowledgement`. Every presentation consumes the token,
including an expired or wrong-request attempt; only its first presentation can
authorize the same install request and resolved artifact. OpenClaw re-evaluates
the staged source and continues when that fresh evaluation allows
the install or repeats the unchanged warning the operator approved.
Treat `acknowledgementToken` as a short-lived bearer secret. It is not bound to
the Gateway connection, device, or operator that received it: any authenticated
`operator.admin` client that has the token and submits the same install request
may consume it once for the matching resolved artifact. Keep it only in memory
for the immediate reviewed retry; do not log, persist, or forward it. Tokens
expire after five minutes and are invalidated by a Gateway restart.
A changed or later warning stops the request before commit and receives a fresh
token when the artifact remains immutably resolved. A block or a warning without
immutable resolution metadata is terminal and has no acknowledgement token.
Other Gateway-backed and automatic installs remain blocked on warnings because
they have no operator-confirmation flow. When an equivalent direct plugin or
skill command exists, use that command to review and approve the warning.
Otherwise, change `security.installPolicy` to return `allow` for the reviewed
request, then retry the managed flow. Neither `--force` nor the deprecated
plugin install/update flag `--dangerously-force-unsafe-install` approves a
policy warning. Plugin
`before_install` hooks run later, and only in OpenClaw processes where plugin
hooks are loaded, so use `security.installPolicy` for operator-owned install
decisions instead. The flag does not override a block or policy failure.
It also does not bypass `before_install` hook blocks.
decisions instead. `--acknowledge-install-policy-warning` does not override a
block or policy failure. Neither acknowledgement nor the deprecated flag
bypasses `before_install` hook blocks.
See [Skills config](/tools/skills-config#operator-install-policy-security-installpolicy)
for the shared `security.installPolicy` exec schema used by both skills and
+17
View File
@@ -201,6 +201,23 @@ approval after review;
every approved warning is re-evaluated before continuing.
The flag is consumed by the first warning in one command; a later warning
fails closed and requires interactive review.
Gateway `plugins.install` clients receive structured warning details when the
Gateway can bind the request to an immutable resolved artifact, and may make
one explicit retry with the returned `acknowledgementToken` as
`installPolicyWarningAcknowledgement`. Every presentation consumes the token,
including an expired or wrong-request attempt; only its first presentation can
authorize the same install request and resolved artifact. OpenClaw re-evaluates
the staged source and continues when that fresh evaluation allows
the install or repeats the unchanged warning the operator approved.
Treat `acknowledgementToken` as a short-lived bearer secret. It is not bound to
the Gateway connection, device, or operator that received it: any authenticated
`operator.admin` client that has the token and submits the same install request
may consume it once for the matching resolved artifact. Keep it only in memory
for the immediate reviewed retry; do not log, persist, or forward it. Tokens
expire after five minutes and are invalidated by a Gateway restart.
A changed or later warning stops the request before commit and receives a fresh
token when the artifact remains immutably resolved. A block or a warning without
immutable resolution metadata is terminal and has no acknowledgement token. Other
Gateway-backed and automatic installs remain blocked on warnings because they
have no operator-confirmation flow. Use an equivalent direct plugin or skill
command to review and approve the warning when one exists. Otherwise, change
+1
View File
@@ -1,4 +1,5 @@
export * from "./clawhub-trust-error-details.js";
export * from "./install-policy-warning-error-details.js";
export * from "./system-agent-error-details.js";
export {
isMcpAppViewExpiredError,
@@ -0,0 +1,109 @@
import {
asProtocolRecord,
normalizeOptionalProtocolString,
} from "./protocol-value-normalization.js";
/** Structured install-policy warning details carried in Gateway error payloads. */
export const INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED =
"install_policy_warning_acknowledgement_required" as const;
export type InstallPolicyWarningErrorFinding = {
ruleId: string;
severity: "info" | "warn" | "critical";
message: string;
file?: string;
line?: number;
evidence?: string;
};
export type InstallPolicyWarningErrorDetails = {
installPolicyCode: typeof INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED;
targetName: string;
targetType: "skill" | "plugin";
requestMode: "install" | "update";
reason: string;
acknowledgementToken: string;
findings?: InstallPolicyWarningErrorFinding[];
};
function readFinding(value: unknown): InstallPolicyWarningErrorFinding | undefined {
const record = asProtocolRecord(value);
if (!record) {
return undefined;
}
const ruleId = normalizeOptionalProtocolString(record.ruleId);
const message = normalizeOptionalProtocolString(record.message);
const severity = record.severity;
if (
!ruleId ||
!message ||
(severity !== "info" && severity !== "warn" && severity !== "critical")
) {
return undefined;
}
const file = normalizeOptionalProtocolString(record.file);
const evidence = normalizeOptionalProtocolString(record.evidence);
const line = record.line;
if (
(record.file !== undefined && !file) ||
(record.evidence !== undefined && !evidence) ||
(line !== undefined && (typeof line !== "number" || !Number.isSafeInteger(line) || line <= 0))
) {
return undefined;
}
return {
ruleId,
severity,
message,
...(file ? { file } : {}),
...(line !== undefined ? { line } : {}),
...(evidence ? { evidence } : {}),
};
}
export function readInstallPolicyWarningErrorDetails(
value: unknown,
): InstallPolicyWarningErrorDetails | undefined {
const record = asProtocolRecord(value);
if (!record) {
return undefined;
}
const targetName = normalizeOptionalProtocolString(record.targetName);
const reason = normalizeOptionalProtocolString(record.reason);
const acknowledgementToken = normalizeOptionalProtocolString(record.acknowledgementToken);
const targetType = record.targetType;
const requestMode = record.requestMode;
if (
record.installPolicyCode !== INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED ||
!targetName ||
!reason ||
!acknowledgementToken ||
(targetType !== "skill" && targetType !== "plugin") ||
(requestMode !== "install" && requestMode !== "update")
) {
return undefined;
}
let findings: InstallPolicyWarningErrorFinding[] | undefined;
if (record.findings !== undefined) {
if (!Array.isArray(record.findings)) {
return undefined;
}
findings = [];
for (const findingValue of record.findings) {
const finding = readFinding(findingValue);
if (!finding) {
return undefined;
}
findings.push(finding);
}
}
return {
installPolicyCode: INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED,
targetName,
targetType,
requestMode,
reason,
acknowledgementToken,
...(findings ? { findings } : {}),
};
}
@@ -31,9 +31,16 @@ describe("plugin lifecycle protocol validators", () => {
packageName: "memory-plus",
version: "2.1.0",
acknowledgeClawHubRisk: true,
installPolicyWarningAcknowledgement: "approval-token",
}),
).toBe(true);
expect(
validatePluginsInstallParams({
source: "official",
pluginId: "workboard",
installPolicyWarningAcknowledgement: "approval-token",
}),
).toBe(true);
expect(validatePluginsInstallParams({ source: "official", pluginId: "workboard" })).toBe(true);
expect(
validatePluginsInstallParams({
source: "official",
@@ -41,6 +48,13 @@ describe("plugin lifecycle protocol validators", () => {
packageName: "memory-plus",
}),
).toBe(false);
expect(
validatePluginsInstallParams({
source: "official",
pluginId: "workboard",
installPolicyWarningAcknowledgement: true,
}),
).toBe(false);
});
it("validates uninstall requests", () => {
@@ -174,10 +174,12 @@ export const PluginsInstallParamsSchema = Type.Union([
packageName: NonEmptyString,
version: Type.Optional(NonEmptyString),
acknowledgeClawHubRisk: Type.Optional(Type.Boolean()),
installPolicyWarningAcknowledgement: Type.Optional(NonEmptyString),
}),
closedObject({
source: Type.Literal("official"),
pluginId: NonEmptyString,
installPolicyWarningAcknowledgement: Type.Optional(NonEmptyString),
}),
]);
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { InstallPolicyWarningAcknowledgementRequest } from "../plugins/install-security-scan.types.js";
import { resolveInstallPolicyWarningAcknowledgementCliOptions } from "./install-policy-warning-acknowledgement.ts";
const promptTextMock = vi.hoisted(() => vi.fn());
@@ -47,6 +48,18 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => {
targetName: "demo\npkg",
targetType: "plugin",
requestMode: fixture.requestMode,
approvalFingerprint: "review-required",
scan: {
requestKind: "plugin-npm",
originType: "plugin-npm",
pluginContentType: "package",
},
warning: {
targetName: "demo\npkg",
targetType: "plugin",
requestMode: fixture.requestMode,
reason: "Review required",
},
}),
).resolves.toEqual({ status: "approved" });
@@ -66,6 +79,18 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => {
targetName: "demo",
targetType: "skill",
requestMode: "install",
approvalFingerprint: "review-required",
scan: {
requestKind: "skill-install",
originType: "skill-managed-install",
skillInstallId: "demo",
},
warning: {
targetName: "demo",
targetType: "skill",
requestMode: "install",
reason: "Review required",
},
}),
).resolves.toEqual({ status: "declined" });
});
@@ -98,21 +123,31 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => {
const options = resolveInstallPolicyWarningAcknowledgementCliOptions({
acknowledgeInstallPolicyWarning: true,
});
await expect(
options.onInstallPolicyWarning?.({
const warningRequest: InstallPolicyWarningAcknowledgementRequest = {
targetName: "demo",
targetType: "plugin",
requestMode: "install",
approvalFingerprint: "review-required",
scan: {
requestKind: "plugin-npm",
originType: "plugin-npm",
pluginContentType: "package",
},
warning: {
targetName: "demo",
targetType: "plugin",
requestMode: "install",
}),
).resolves.toEqual({ status: "approved" });
await expect(
options.onInstallPolicyWarning?.({
targetName: "demo-dependency",
targetType: "plugin",
requestMode: "install",
}),
).resolves.toEqual({ status: "unavailable", reason: "approval-exhausted" });
reason: "Review required",
},
};
await expect(options.onInstallPolicyWarning?.(warningRequest)).resolves.toEqual({
status: "approved",
});
await expect(options.onInstallPolicyWarning?.(warningRequest)).resolves.toEqual({
status: "unavailable",
reason: "approval-exhausted",
});
expect(promptTextMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import {
INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED,
type InstallPolicyWarningErrorDetails,
} from "../../packages/gateway-protocol/src/install-policy-warning-error-details.js";
import { readInstallPolicyWarningErrorDetails } from "./install-policy-warning-error-details.js";
describe("install policy warning error details", () => {
const completeWarning: Omit<InstallPolicyWarningErrorDetails, "installPolicyCode"> = {
targetName: "demo-plugin",
targetType: "plugin",
requestMode: "install",
reason: "Scanner found behavior that needs review",
acknowledgementToken: "approval-token",
findings: [
{
ruleId: "dynamic-eval",
severity: "warn",
message: "Dynamic code execution",
file: "index.js",
line: 12,
},
],
};
const expectedWarning = {
installPolicyCode: INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED,
...completeWarning,
};
it("parses a complete warning payload", () => {
expect(
readInstallPolicyWarningErrorDetails({
...expectedWarning,
}),
).toEqual(expectedWarning);
});
it("accepts the largest wire-safe finding line", () => {
expect(
readInstallPolicyWarningErrorDetails({
...expectedWarning,
findings: [{ ...completeWarning.findings?.[0], line: Number.MAX_SAFE_INTEGER }],
}),
).toMatchObject({
findings: [{ line: Number.MAX_SAFE_INTEGER }],
});
});
it("rejects finding lines outside the wire-safe range", () => {
expect(
readInstallPolicyWarningErrorDetails({
...expectedWarning,
findings: [{ ...completeWarning.findings?.[0], line: Number.MAX_SAFE_INTEGER + 1 }],
}),
).toBeUndefined();
});
it.each([
null,
{},
{
installPolicyCode: INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED,
targetName: "demo-plugin",
targetType: "plugin",
requestMode: "install",
reason: "",
},
{
...expectedWarning,
findings: [{ ...completeWarning.findings?.[0], line: 0 }],
},
{
...expectedWarning,
findings: [{ ...completeWarning.findings?.[0], severity: "error" }],
},
])("rejects malformed warning details", (value) => {
expect(readInstallPolicyWarningErrorDetails(value)).toBeUndefined();
});
it("normalizes protocol strings without changing the published dependency surface", () => {
expect(
readInstallPolicyWarningErrorDetails({
...expectedWarning,
targetName: " demo-plugin ",
reason: " Review required ",
acknowledgementToken: " token ",
}),
).toMatchObject({
targetName: "demo-plugin",
reason: "Review required",
acknowledgementToken: "token",
});
});
});
@@ -0,0 +1 @@
export { readInstallPolicyWarningErrorDetails } from "../../packages/gateway-protocol/src/install-policy-warning-error-details.js";
@@ -0,0 +1,37 @@
import type { InstallPolicyWarningOccurrence } from "../plugins/install-security-scan.types.js";
import type { ManagedPluginSourceInstallRequest } from "../plugins/management-service.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
type InstallPolicyAcknowledgement = {
expiresAt: number;
generation: number;
requestKey: string;
resolvedRequest: ManagedPluginSourceInstallRequest;
warnings: InstallPolicyWarningOccurrence[];
};
type InstallPolicyAcknowledgementState = {
generation: number;
records: Map<string, InstallPolicyAcknowledgement>;
};
function revokeState(state: InstallPolicyAcknowledgementState): void {
state.generation += 1;
state.records.clear();
}
const state = resolveGlobalSingleton(
Symbol.for("openclaw.installPolicyAcknowledgements"),
(): InstallPolicyAcknowledgementState => ({ generation: 0, records: new Map() }),
revokeState,
"close-and-restart",
);
export function getInstallPolicyAcknowledgementState(): InstallPolicyAcknowledgementState {
return state;
}
/** Retires every approval before Gateway teardown can publish old-generation work. */
export function revokeInstallPolicyAcknowledgements(): void {
revokeState(state);
}
+10 -8
View File
@@ -237,7 +237,7 @@ describe("gateway startup import boundaries", () => {
expect(workerStartup.match(/loadWorkerEnvironmentRuntimeModule\(\)/gu)).toHaveLength(3);
});
it("fences config reload before gateway teardown and gateway_stop hooks", () => {
it("fences install approvals and config reload before gateway teardown", () => {
const serverImpl = readServerImplementation();
const closeStart = /close:\s*async\s*\([^)]*\)\s*=>/u.exec(serverImpl)?.index ?? -1;
const hookStart = serverImpl.indexOf("runGlobalGatewayStopSafely", closeStart);
@@ -245,6 +245,7 @@ describe("gateway startup import boundaries", () => {
const terminalStopStart = serverImpl.indexOf("terminalSessions.disposeAll();", closeStart);
const markHelperStart = serverImpl.indexOf("const markClosePreludeStarted = () => {");
const markHelperEnd = serverImpl.indexOf("};", markHelperStart);
const markHelperBlock = serverImpl.slice(markHelperStart, markHelperEnd);
const beginHelperStart = serverImpl.indexOf("const beginClosePrelude = async () => {");
const beginHelperEnd = serverImpl.indexOf("};", beginHelperStart);
const postReadyStart = serverImpl.indexOf("scheduleGatewayPostReadyMaintenance({");
@@ -256,14 +257,15 @@ describe("gateway startup import boundaries", () => {
expect(reloadStopStart).toBeLessThan(terminalStopStart);
expect(reloadStopStart).toBeLessThan(hookStart);
expect(markHelperStart).toBeGreaterThan(-1);
expect(serverImpl.slice(markHelperStart, markHelperEnd)).toContain(
"clearPostReadyMaintenanceTimer();",
expect(markHelperBlock).toContain("clearPostReadyMaintenanceTimer();");
expect(markHelperBlock).toContain("revokeInstallPolicyAcknowledgements();");
expect(markHelperBlock).toContain("cronReconciliation.invalidate();");
expect(markHelperBlock).toContain("void stopOutboundDeliveryRecoveryForClose();");
expect(markHelperBlock.indexOf("revokeInstallPolicyAcknowledgements();")).toBeLessThan(
markHelperBlock.indexOf("void stopOutboundDeliveryRecoveryForClose();"),
);
expect(serverImpl.slice(markHelperStart, markHelperEnd)).toContain(
"cronReconciliation.invalidate();",
);
expect(serverImpl.slice(markHelperStart, markHelperEnd)).toContain(
"void stopOutboundDeliveryRecoveryForClose();",
expect(markHelperBlock.indexOf("revokeInstallPolicyAcknowledgements();")).toBeLessThan(
markHelperBlock.indexOf("void stopMediaCleanupForClose();"),
);
expect(beginHelperStart).toBeGreaterThan(-1);
expect(serverImpl.slice(beginHelperStart, beginHelperEnd)).toContain(
+6
View File
@@ -13,6 +13,7 @@ import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/c
import { createOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import { getFreePort } from "../test-utils/ports.js";
import { CLI_DEFAULT_OPERATOR_SCOPES } from "./method-scopes.js";
import { getInstallPolicyAcknowledgementState } from "./plugin-install-policy-acknowledgement-state.js";
import { dispatchGatewayRequestInProcess } from "./server-in-process-dispatch.js";
import { createGatewayKernel } from "./server-kernel.js";
import { createSyntheticPluginRuntimeClient } from "./server-plugin-runtime-client.js";
@@ -183,6 +184,11 @@ describe("createGatewayKernel", () => {
"gateway.handlers",
"gateway.request-context",
]);
const installPolicyGeneration = getInstallPolicyAcknowledgementState().generation;
const closePrelude = kernel.beginClosePrelude();
expect(getInstallPolicyAcknowledgementState().generation).toBe(installPolicyGeneration + 1);
await closePrelude;
} finally {
try {
await kernel?.closeOnStartupFailure();
+4
View File
@@ -24,6 +24,7 @@ import { createControlUiSessionPullRequestSubscriptions } from "./control-ui-ses
import { STARTUP_UNAVAILABLE_GATEWAY_METHODS } from "./methods/core-descriptors.js";
import { disposeNodeConnectionNotifications } from "./node-connection-notifications.js";
import { clearNodeWakeState } from "./node-wake-state.js";
import { revokeInstallPolicyAcknowledgements } from "./plugin-install-policy-acknowledgement-state.js";
import { createLazyGatewayCronState } from "./server-cron-lazy.js";
import { createGatewayCronReconciliation } from "./server-cron-reconciled.js";
import { applyGatewayLaneConcurrency, resolveGatewayLaneConcurrency } from "./server-lanes.js";
@@ -445,6 +446,9 @@ export async function prepareGatewayLifecycle(params: {
};
const markClosePreludeStarted = () => {
lifecycle.closePreludeStarted = true;
// Approval-bearing installs can survive close waits. Revoke before the first
// await so old-generation work cannot publish during teardown.
revokeInstallPolicyAcknowledgements();
// Fence background owners before any awaited close step can tear down the
// plugin/channel or shared-state runtime they still need.
void stopOutboundDeliveryRecoveryForClose();
+719 -38
View File
@@ -2,54 +2,60 @@
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
InstallPolicyWarningDetails,
InstallPolicyWarningOccurrence,
} from "../../plugins/install-security-scan.types.js";
import { parseInstallPolicyResponse } from "../../security/install-policy-response.js";
import { drainGlobalSingletonLifecycleState } from "../../shared/global-singleton.js";
import { revokeInstallPolicyAcknowledgements } from "../plugin-install-policy-acknowledgement-state.js";
type InstallPolicyWarningScanIdentity = InstallPolicyWarningOccurrence["scan"];
type ManagementServiceModule = typeof import("../../plugins/management-service.js");
const managementMocks = vi.hoisted(() => {
class ManagedPluginLifecycleError extends Error {
readonly kind: "invalid-request" | "unavailable";
readonly code?: string;
readonly version?: string;
readonly warning?: string;
constructor(
message: string,
details?: {
kind?: "invalid-request" | "unavailable";
code?: string;
version?: string;
warning?: string;
},
) {
super(message);
this.kind = details?.kind ?? "invalid-request";
this.code = details?.code;
this.version = details?.version;
this.warning = details?.warning;
}
}
return {
ManagedPluginLifecycleError,
install: vi.fn(),
list: vi.fn(),
setEnabled: vi.fn(),
uninstall: vi.fn(),
install: vi.fn<ManagementServiceModule["installManagedPlugin"]>(),
list: vi.fn<ManagementServiceModule["listManagedPlugins"]>(),
setEnabled: vi.fn<ManagementServiceModule["setManagedPluginEnabled"]>(),
uninstall: vi.fn<ManagementServiceModule["uninstallManagedPlugin"]>(),
};
});
const searchMock = vi.hoisted(() => vi.fn());
vi.mock("../../plugins/management-service.js", () => ({
ManagedPluginLifecycleError: managementMocks.ManagedPluginLifecycleError,
installManagedPlugin: (...args: unknown[]) => managementMocks.install(...args),
listManagedPlugins: (...args: unknown[]) => managementMocks.list(...args),
setManagedPluginEnabled: (...args: unknown[]) => managementMocks.setEnabled(...args),
uninstallManagedPlugin: (...args: unknown[]) => managementMocks.uninstall(...args),
}));
vi.mock("../../plugins/management-service.js", async () => {
const actual = await vi.importActual<typeof import("../../plugins/management-service.js")>(
"../../plugins/management-service.js",
);
return {
...actual,
installManagedPlugin: managementMocks.install,
listManagedPlugins: managementMocks.list,
setManagedPluginEnabled: managementMocks.setEnabled,
uninstallManagedPlugin: managementMocks.uninstall,
};
});
vi.mock("../../plugins/catalog-search.js", () => ({
searchInstallablePluginPackages: (...args: unknown[]) => searchMock(...args),
}));
const { ManagedPluginLifecycleError } = await import("../../plugins/management-service.js");
const { pluginsHandlers } = await import("./plugins.js");
const packageScan: InstallPolicyWarningScanIdentity = {
requestKind: "plugin-archive",
originType: "plugin-package",
pluginContentType: "package",
};
function warningOccurrence(
warning: InstallPolicyWarningDetails,
scan: InstallPolicyWarningScanIdentity = packageScan,
): InstallPolicyWarningOccurrence {
return { scan, warning, approvalFingerprint: `fingerprint:${warning.reason}` };
}
async function callHandler(
method: string,
params: Record<string, unknown>,
@@ -124,6 +130,59 @@ describe("plugin management Gateway handlers", () => {
});
});
it("waits for lifecycle mutations before returning reconnect catalog state", async () => {
let finishInstall!: (value: { plugin: typeof workboard }) => void;
const pendingInstall = new Promise<{ plugin: typeof workboard }>((resolve) => {
finishInstall = resolve;
});
managementMocks.install.mockReturnValue(pendingInstall);
managementMocks.list.mockResolvedValue({
plugins: [workboard],
diagnostics: [],
mutationAllowed: true,
});
const install = callHandler("plugins.install", { source: "official", pluginId: "workboard" });
await vi.waitFor(() => expect(managementMocks.install).toHaveBeenCalledOnce());
await drainGlobalSingletonLifecycleState("restart");
const list = callHandler("plugins.list", {});
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
expect(managementMocks.list).not.toHaveBeenCalled();
finishInstall({ plugin: workboard });
await install;
expect((await list).response).toMatchObject({ plugins: [workboard] });
expect(managementMocks.list).toHaveBeenCalledOnce();
});
it("releases reconnect catalog reads after a lifecycle mutation fails", async () => {
let failInstall!: (error: Error) => void;
const pendingInstall = new Promise<never>((_resolve, reject) => {
failInstall = reject;
});
managementMocks.install.mockReturnValue(pendingInstall);
managementMocks.list.mockResolvedValue({
plugins: [workboard],
diagnostics: [],
mutationAllowed: true,
});
const install = callHandler("plugins.install", { source: "official", pluginId: "workboard" });
await vi.waitFor(() => expect(managementMocks.install).toHaveBeenCalledOnce());
const list = callHandler("plugins.list", {});
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
expect(managementMocks.list).not.toHaveBeenCalled();
failInstall(new Error("install failed"));
expect((await install).error).toMatchObject({ code: "UNAVAILABLE" });
expect((await list).response).toMatchObject({ plugins: [workboard] });
expect(managementMocks.list).toHaveBeenCalledOnce();
});
it("maps plugin-only ClawHub search results to the public DTO", async () => {
searchMock.mockResolvedValue([
{
@@ -248,7 +307,7 @@ describe("plugin management Gateway handlers", () => {
it("classifies known enablement policy failures as invalid requests", async () => {
managementMocks.setEnabled.mockRejectedValue(
new managementMocks.ManagedPluginLifecycleError("Plugin is blocked"),
new ManagedPluginLifecycleError("Plugin is blocked"),
);
const result = await callHandler("plugins.setEnabled", {
@@ -298,9 +357,23 @@ describe("plugin management Gateway handlers", () => {
});
});
it("rejects an install-policy acknowledgement that the Gateway did not issue", async () => {
const result = await callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
installPolicyWarningAcknowledgement: "not-issued",
});
expect(result.error).toMatchObject({
code: "INVALID_REQUEST",
message: expect.stringContaining("does not match this plugin"),
});
expect(managementMocks.install).not.toHaveBeenCalled();
});
it("returns structured ClawHub acknowledgement details", async () => {
managementMocks.install.mockRejectedValue(
new managementMocks.ManagedPluginLifecycleError("Review required", {
new ManagedPluginLifecycleError("Review required", {
kind: "invalid-request",
code: "clawhub_risk_acknowledgement_required",
version: "1.2.3",
@@ -325,9 +398,617 @@ describe("plugin management Gateway handlers", () => {
});
});
it("returns structured install-policy warning details", async () => {
managementMocks.install.mockRejectedValue(
new ManagedPluginLifecycleError("Install requires approval", {
installPolicyResolvedRequest: {
source: "clawhub",
spec: "clawhub:community/plugin@1.0.0",
},
installPolicyWarning: warningOccurrence({
targetName: "demo-plugin",
targetType: "plugin",
requestMode: "install",
reason: "Scanner found behavior that needs review",
findings: [
{
ruleId: "dynamic-eval",
severity: "warn",
message: "Dynamic code execution",
file: "index.js",
line: 12,
},
],
}),
}),
);
const result = await callHandler("plugins.install", {
source: "clawhub",
packageName: "community/plugin",
});
expect(result.error).toMatchObject({
code: "INVALID_REQUEST",
message: "Install requires approval",
details: {
installPolicyCode: "install_policy_warning_acknowledgement_required",
targetName: "demo-plugin",
targetType: "plugin",
requestMode: "install",
reason: "Scanner found behavior that needs review",
findings: [
{
ruleId: "dynamic-eval",
severity: "warn",
message: "Dynamic code execution",
file: "index.js",
line: 12,
},
],
},
});
const error = result.error as { details?: { acknowledgementToken?: unknown } };
const acknowledgementToken = expectDefined(
error.details?.acknowledgementToken,
"expected install-policy acknowledgement token",
);
expect(acknowledgementToken).toEqual(expect.any(String));
managementMocks.install.mockRejectedValueOnce(
new ManagedPluginLifecycleError("Warning changed", {
installPolicyResolvedRequest: {
source: "clawhub",
spec: "clawhub:community/plugin@1.0.0",
},
installPolicyWarning: warningOccurrence({
targetName: "demo-plugin",
targetType: "plugin",
requestMode: "install",
reason: "Scanner found a different issue",
}),
}),
);
const changed = await callHandler("plugins.install", {
source: "clawhub",
packageName: "community/plugin",
installPolicyWarningAcknowledgement: acknowledgementToken,
});
expect(changed.error).toMatchObject({
details: { reason: "Scanner found a different issue" },
});
expect(managementMocks.install).toHaveBeenLastCalledWith({
request: {
source: "clawhub",
packageName: "community/plugin",
installPolicyWarningAcknowledgement: {
publicationAuthority: {
assertCurrent: expect.any(Function),
commit: expect.any(Function),
},
resolvedRequest: {
source: "clawhub",
spec: "clawhub:community/plugin@1.0.0",
},
warnings: [
{
scan: packageScan,
approvalFingerprint: "fingerprint:Scanner found behavior that needs review",
warning: {
targetName: "demo-plugin",
targetType: "plugin",
requestMode: "install",
reason: "Scanner found behavior that needs review",
findings: [
{
ruleId: "dynamic-eval",
severity: "warn",
message: "Dynamic code execution",
file: "index.js",
line: 12,
},
],
},
},
],
},
},
});
const changedError = changed.error as { details?: { acknowledgementToken?: unknown } };
const changedAcknowledgementToken = expectDefined(
changedError.details?.acknowledgementToken,
"expected changed-warning acknowledgement token",
);
expect(changedAcknowledgementToken).not.toBe(acknowledgementToken);
managementMocks.install.mockResolvedValue({
plugin: { ...workboard, id: "diffs", name: "Diffs", enabled: true, state: "enabled" },
});
const approved = await callHandler("plugins.install", {
source: "clawhub",
packageName: "community/plugin",
installPolicyWarningAcknowledgement: changedAcknowledgementToken,
});
expect(approved.ok).toBe(true);
expect(managementMocks.install).toHaveBeenLastCalledWith({
request: {
source: "clawhub",
packageName: "community/plugin",
installPolicyWarningAcknowledgement: {
publicationAuthority: {
assertCurrent: expect.any(Function),
commit: expect.any(Function),
},
resolvedRequest: {
source: "clawhub",
spec: "clawhub:community/plugin@1.0.0",
},
warnings: [
{
scan: packageScan,
approvalFingerprint: "fingerprint:Scanner found a different issue",
warning: {
targetName: "demo-plugin",
targetType: "plugin",
requestMode: "install",
reason: "Scanner found a different issue",
},
},
],
},
},
});
const replay = await callHandler("plugins.install", {
source: "clawhub",
packageName: "community/plugin",
installPolicyWarningAcknowledgement: changedAcknowledgementToken,
});
expect(replay.error).toMatchObject({ code: "INVALID_REQUEST" });
expect(managementMocks.install).toHaveBeenCalledTimes(3);
});
it("binds an install-policy acknowledgement to the request that received it", async () => {
managementMocks.install.mockRejectedValue(
new ManagedPluginLifecycleError("Install requires approval", {
installPolicyResolvedRequest: {
source: "official",
spec: "@openclaw/diffs@1.0.0",
pluginId: "diffs",
mode: "install",
},
installPolicyWarning: warningOccurrence({
targetName: "diffs",
targetType: "plugin",
requestMode: "install",
reason: "Review required",
}),
}),
);
const warning = await callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
});
const error = warning.error as { details?: { acknowledgementToken?: unknown } };
const acknowledgementToken = expectDefined(
error.details?.acknowledgementToken,
"expected install-policy acknowledgement token",
);
const mismatch = await callHandler("plugins.install", {
source: "official",
pluginId: "workboard",
installPolicyWarningAcknowledgement: acknowledgementToken,
});
expect(mismatch.error).toMatchObject({
code: "INVALID_REQUEST",
message: expect.stringContaining("does not match this plugin"),
});
const retryAfterMismatch = await callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
installPolicyWarningAcknowledgement: acknowledgementToken,
});
expect(retryAfterMismatch.error).toMatchObject({
code: "INVALID_REQUEST",
message: expect.stringContaining("expired or does not match this plugin"),
});
expect(managementMocks.install).toHaveBeenCalledOnce();
});
it("revokes install-policy acknowledgements when the Gateway restarts", async () => {
managementMocks.install.mockRejectedValueOnce(
new ManagedPluginLifecycleError("Install requires approval", {
installPolicyResolvedRequest: {
source: "official",
spec: "@openclaw/diffs@1.0.0",
pluginId: "diffs",
mode: "install",
},
installPolicyWarning: warningOccurrence({
targetName: "diffs",
targetType: "plugin",
requestMode: "install",
reason: "Review required",
}),
}),
);
const warning = await callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
});
const acknowledgementToken = expectDefined(
(warning.error as { details?: { acknowledgementToken?: unknown } }).details
?.acknowledgementToken,
"expected install-policy acknowledgement token",
);
await drainGlobalSingletonLifecycleState("restart");
const retry = await callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
installPolicyWarningAcknowledgement: acknowledgementToken,
});
expect(retry.error).toMatchObject({
code: "INVALID_REQUEST",
message: expect.stringContaining("expired or does not match this plugin"),
});
expect(managementMocks.install).toHaveBeenCalledOnce();
});
it("does not issue an install-policy acknowledgement after the Gateway restarts", async () => {
const warningError = () =>
new ManagedPluginLifecycleError("Install requires approval", {
installPolicyResolvedRequest: {
source: "official",
spec: "@openclaw/diffs@1.0.0",
pluginId: "diffs",
mode: "install",
},
installPolicyWarning: warningOccurrence({
targetName: "diffs",
targetType: "plugin",
requestMode: "install",
reason: "Review required",
}),
});
let rejectInstall!: (error: Error) => void;
const pendingInstall = new Promise<never>((_resolve, reject) => {
rejectInstall = reject;
});
managementMocks.install
.mockReturnValueOnce(pendingInstall)
.mockRejectedValueOnce(warningError());
const pending = callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
});
await vi.waitFor(() => expect(managementMocks.install).toHaveBeenCalledOnce());
await drainGlobalSingletonLifecycleState("restart");
rejectInstall(warningError());
const stale = await pending;
expect(stale.error).toMatchObject({
code: "UNAVAILABLE",
message: expect.stringContaining("Gateway restarted"),
});
expect(stale.error).not.toHaveProperty("details.acknowledgementToken");
const fresh = await callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
});
expect(
(fresh.error as { details?: { acknowledgementToken?: unknown } }).details
?.acknowledgementToken,
).toEqual(expect.any(String));
});
it("rejects a queued install-policy retry when its Gateway generation restarts", async () => {
managementMocks.install.mockRejectedValueOnce(
new ManagedPluginLifecycleError("Install requires approval", {
installPolicyResolvedRequest: {
source: "official",
spec: "@openclaw/diffs@1.0.0",
pluginId: "diffs",
mode: "install",
},
installPolicyWarning: warningOccurrence({
targetName: "diffs",
targetType: "plugin",
requestMode: "install",
reason: "Review required",
}),
}),
);
const warning = await callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
});
const acknowledgementToken = expectDefined(
(warning.error as { details?: { acknowledgementToken?: unknown } }).details
?.acknowledgementToken,
"expected install-policy acknowledgement token",
);
const retry = callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
installPolicyWarningAcknowledgement: acknowledgementToken,
});
await drainGlobalSingletonLifecycleState("restart");
expect((await retry).error).toMatchObject({
code: "INVALID_REQUEST",
message: expect.stringContaining("expired or does not match this plugin"),
});
expect(managementMocks.install).toHaveBeenCalledOnce();
});
it("rejects publication when an acknowledged install outlives a Gateway restart", async () => {
managementMocks.install.mockRejectedValueOnce(
new ManagedPluginLifecycleError("Install requires approval", {
installPolicyResolvedRequest: {
source: "official",
spec: "@openclaw/diffs@1.0.0",
pluginId: "diffs",
mode: "install",
},
installPolicyWarning: warningOccurrence({
targetName: "diffs",
targetType: "plugin",
requestMode: "install",
reason: "Review required",
}),
}),
);
const warning = await callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
});
const acknowledgementToken = expectDefined(
(warning.error as { details?: { acknowledgementToken?: unknown } }).details
?.acknowledgementToken,
"expected install-policy acknowledgement token",
);
let resumeInstall!: () => void;
const installCanPublish = new Promise<void>((resolve) => {
resumeInstall = resolve;
});
managementMocks.install.mockImplementationOnce(async ({ request }) => {
await installCanPublish;
request.installPolicyWarningAcknowledgement?.publicationAuthority.commit();
return {
plugin: { ...workboard, id: "diffs", name: "Diffs", enabled: true, state: "enabled" },
};
});
const retry = callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
installPolicyWarningAcknowledgement: acknowledgementToken,
});
await vi.waitFor(() => expect(managementMocks.install).toHaveBeenCalledTimes(2));
revokeInstallPolicyAcknowledgements();
resumeInstall();
expect((await retry).error).toMatchObject({
code: "UNAVAILABLE",
message: expect.stringContaining("Gateway restarted"),
});
expect(pluginMetadataChanged).not.toHaveBeenCalled();
});
it("lets an authorized publication finish after its first commit boundary", async () => {
managementMocks.install.mockRejectedValueOnce(
new ManagedPluginLifecycleError("Install requires approval", {
installPolicyResolvedRequest: {
source: "official",
spec: "@openclaw/diffs@1.0.0",
pluginId: "diffs",
mode: "install",
},
installPolicyWarning: warningOccurrence({
targetName: "diffs",
targetType: "plugin",
requestMode: "install",
reason: "Review required",
}),
}),
);
const warning = await callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
});
const acknowledgementToken = expectDefined(
(warning.error as { details?: { acknowledgementToken?: unknown } }).details
?.acknowledgementToken,
"expected install-policy acknowledgement token",
);
managementMocks.install.mockImplementationOnce(async ({ request }) => {
const commitPublication = expectDefined(
request.installPolicyWarningAcknowledgement?.publicationAuthority.commit,
"expected publication authority",
);
commitPublication();
await drainGlobalSingletonLifecycleState("restart");
commitPublication();
return {
plugin: { ...workboard, id: "diffs", name: "Diffs", enabled: true, state: "enabled" },
};
});
const retry = await callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
installPolicyWarningAcknowledgement: acknowledgementToken,
});
expect(retry.ok).toBe(true);
expect(retry.response).toMatchObject({ ok: true, restartRequired: true });
});
it("carries earlier approvals into a token for a later scan-stage warning", async () => {
const warning: InstallPolicyWarningDetails = {
targetName: "demo-plugin",
targetType: "plugin",
requestMode: "install",
reason: "Review this behavior",
};
const firstWarning = warningOccurrence(warning);
const secondWarning = warningOccurrence(warning, {
requestKind: "plugin-archive",
originType: "plugin-dependency-tree",
pluginContentType: "dependency-tree",
});
const publicWarningDetails = {
installPolicyCode: "install_policy_warning_acknowledgement_required",
targetName: warning.targetName,
targetType: warning.targetType,
requestMode: warning.requestMode,
reason: warning.reason,
};
const resolvedRequest = {
source: "clawhub" as const,
spec: "clawhub:community/plugin@1.0.0",
};
managementMocks.install.mockRejectedValueOnce(
new ManagedPluginLifecycleError("First warning", {
installPolicyResolvedRequest: resolvedRequest,
installPolicyWarning: firstWarning,
}),
);
const first = await callHandler("plugins.install", {
source: "clawhub",
packageName: "community/plugin",
});
const firstToken = expectDefined(
(first.error as { details?: { acknowledgementToken?: string } }).details
?.acknowledgementToken,
"first acknowledgement token",
);
expect(first.error).toMatchObject({ details: publicWarningDetails });
expect((first.error as { details?: Record<string, unknown> }).details).not.toHaveProperty(
"scan",
);
expect((first.error as { details?: Record<string, unknown> }).details).not.toHaveProperty(
"approvalFingerprint",
);
managementMocks.install.mockRejectedValueOnce(
new ManagedPluginLifecycleError("Second warning", {
installPolicyResolvedRequest: resolvedRequest,
installPolicyWarning: secondWarning,
installPolicyAcknowledgedWarnings: [firstWarning],
}),
);
const second = await callHandler("plugins.install", {
source: "clawhub",
packageName: "community/plugin",
installPolicyWarningAcknowledgement: firstToken,
});
const secondToken = expectDefined(
(second.error as { details?: { acknowledgementToken?: string } }).details
?.acknowledgementToken,
"second acknowledgement token",
);
expect(second.error).toMatchObject({ details: publicWarningDetails });
expect((second.error as { details?: Record<string, unknown> }).details).not.toHaveProperty(
"scan",
);
expect((second.error as { details?: Record<string, unknown> }).details).not.toHaveProperty(
"approvalFingerprint",
);
managementMocks.install.mockResolvedValueOnce({
plugin: { ...workboard, id: "diffs", name: "Diffs", enabled: true, state: "enabled" },
});
await callHandler("plugins.install", {
source: "clawhub",
packageName: "community/plugin",
installPolicyWarningAcknowledgement: secondToken,
});
expect(managementMocks.install).toHaveBeenLastCalledWith({
request: {
source: "clawhub",
packageName: "community/plugin",
installPolicyWarningAcknowledgement: {
publicationAuthority: {
assertCurrent: expect.any(Function),
commit: expect.any(Function),
},
resolvedRequest,
warnings: [firstWarning, secondWarning],
},
},
});
});
it("preserves normalized finding lines in structured warning details", async () => {
const policyResult = parseInstallPolicyResponse(
JSON.stringify({
protocolVersion: 1,
decision: "warn",
reason: "Review line normalization",
findings: [
{
ruleId: "large-line",
severity: "warn",
message: "Review line",
line: 1e100,
},
],
}),
{ sourcePath: "/tmp/staged-plugin" },
);
const warning = expectDefined(policyResult.warning, "expected parsed install-policy warning");
managementMocks.install.mockRejectedValue(
new ManagedPluginLifecycleError("Install requires approval", {
installPolicyResolvedRequest: {
source: "clawhub",
spec: "clawhub:community/plugin@1.0.0",
},
installPolicyWarning: {
scan: packageScan,
warning: {
targetName: "demo-plugin",
targetType: "plugin",
requestMode: "install",
reason: warning.reason,
findings: policyResult.findings,
},
approvalFingerprint: warning.approvalFingerprint,
},
}),
);
const result = await callHandler("plugins.install", {
source: "clawhub",
packageName: "community/plugin",
});
expect(result.error).toMatchObject({
code: "INVALID_REQUEST",
details: {
findings: [{ line: Number.MAX_SAFE_INTEGER }],
acknowledgementToken: expect.any(String),
},
});
});
it("classifies ClawHub security outages as unavailable", async () => {
managementMocks.install.mockRejectedValue(
new managementMocks.ManagedPluginLifecycleError("Security service unavailable", {
new ManagedPluginLifecycleError("Security service unavailable", {
kind: "unavailable",
code: "clawhub_security_unavailable",
}),
@@ -383,7 +1064,7 @@ describe("plugin management Gateway handlers", () => {
it("classifies bundled uninstall refusals as invalid requests", async () => {
managementMocks.uninstall.mockRejectedValue(
new managementMocks.ManagedPluginLifecycleError(
new ManagedPluginLifecycleError(
"bundled plugin cannot be uninstalled: workboard; disable it instead",
),
);
+214 -8
View File
@@ -1,6 +1,8 @@
// Gateway control-plane handlers for cold plugin catalog and lifecycle operations.
import { randomUUID } from "node:crypto";
import {
buildClawHubTrustErrorDetails,
INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED,
ErrorCodes,
errorShape,
isClawHubTrustErrorCode,
@@ -10,22 +12,183 @@ import {
validatePluginsSearchParams,
validatePluginsSetEnabledParams,
validatePluginsUninstallParams,
type PluginsInstallParams,
} from "../../../packages/gateway-protocol/src/index.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { searchInstallablePluginPackages } from "../../plugins/catalog-search.js";
import type { InstallPolicyWarningOccurrence } from "../../plugins/install-security-scan.types.js";
import {
installManagedPlugin,
listManagedPlugins,
ManagedPluginLifecycleError,
setManagedPluginEnabled,
uninstallManagedPlugin,
type ManagedPluginInstallRequest,
type ManagedPluginInstallPolicyAcknowledgement,
type ManagedPluginSourceInstallRequest,
} from "../../plugins/management-service.js";
import { resolveGlobalSet } from "../../shared/global-singleton.js";
import { buildGatewayReloadPlan } from "../config-reload-plan.js";
import { resolveGatewayReloadSettings } from "../config-reload-settings.js";
import { readInstallPolicyWarningErrorDetails } from "../install-policy-warning-error-details.js";
import { getInstallPolicyAcknowledgementState } from "../plugin-install-policy-acknowledgement-state.js";
import type { GatewayRequestHandlers } from "./types.js";
import { assertValidParams } from "./validation.js";
const INSTALL_POLICY_ACKNOWLEDGEMENT_TTL_MS = 5 * 60_000;
const MAX_INSTALL_POLICY_ACKNOWLEDGEMENTS = 256;
const installPolicyAcknowledgementState = getInstallPolicyAcknowledgementState();
const installPolicyAcknowledgements = installPolicyAcknowledgementState.records;
const pendingPluginLifecycleOperations = resolveGlobalSet<Promise<unknown>>(
Symbol.for("openclaw.pendingPluginLifecycleOperations"),
"close-only",
);
async function runTrackedPluginLifecycleOperation<T>(run: () => Promise<T>): Promise<T> {
const operation = Promise.resolve().then(run);
pendingPluginLifecycleOperations.add(operation);
try {
return await operation;
} finally {
pendingPluginLifecycleOperations.delete(operation);
}
}
async function waitForPendingPluginLifecycleOperations(): Promise<void> {
await Promise.allSettled(pendingPluginLifecycleOperations);
}
function installPolicyRequestKey(request: PluginsInstallParams): string {
return request.source === "clawhub"
? JSON.stringify({
source: request.source,
packageName: request.packageName,
version: request.version ?? null,
acknowledgeClawHubRisk: request.acknowledgeClawHubRisk ?? false,
})
: JSON.stringify({ source: request.source, pluginId: request.pluginId });
}
function pruneInstallPolicyAcknowledgements(now: number): void {
for (const [token, acknowledgement] of installPolicyAcknowledgements) {
if (acknowledgement.expiresAt <= now) {
installPolicyAcknowledgements.delete(token);
}
}
while (installPolicyAcknowledgements.size >= MAX_INSTALL_POLICY_ACKNOWLEDGEMENTS) {
const oldestToken = installPolicyAcknowledgements.keys().next().value;
if (typeof oldestToken !== "string") {
break;
}
installPolicyAcknowledgements.delete(oldestToken);
}
}
function issueInstallPolicyAcknowledgement(params: {
generation: number;
request: PluginsInstallParams;
resolvedRequest: ManagedPluginSourceInstallRequest;
warning: InstallPolicyWarningOccurrence;
acknowledgedWarnings?: InstallPolicyWarningOccurrence[];
}): string {
const now = Date.now();
pruneInstallPolicyAcknowledgements(now);
const token = randomUUID();
installPolicyAcknowledgements.set(token, {
expiresAt: now + INSTALL_POLICY_ACKNOWLEDGEMENT_TTL_MS,
generation: params.generation,
requestKey: installPolicyRequestKey(params.request),
resolvedRequest: params.resolvedRequest,
warnings: [...(params.acknowledgedWarnings ?? []), params.warning],
});
return token;
}
function assertInstallPolicyGenerationCurrent(generation: number): void {
if (generation === installPolicyAcknowledgementState.generation) {
return;
}
throw new ManagedPluginLifecycleError(
"Gateway restarted before the approved install could be published. Retry the install to review the current warning.",
{ kind: "unavailable" },
);
}
function createInstallPolicyPublicationAuthority(
generation: number,
): ManagedPluginInstallPolicyAcknowledgement["publicationAuthority"] {
let committed = false;
const assertCurrent = () => {
if (!committed) {
assertInstallPolicyGenerationCurrent(generation);
}
};
return {
assertCurrent,
commit: () => {
if (committed) {
return;
}
assertCurrent();
committed = true;
},
};
}
function consumeInstallPolicyAcknowledgement(
request: PluginsInstallParams,
generation: number,
): ManagedPluginInstallPolicyAcknowledgement | undefined {
const token = request.installPolicyWarningAcknowledgement;
if (!token) {
return undefined;
}
const acknowledgement = installPolicyAcknowledgements.get(token);
installPolicyAcknowledgements.delete(token);
if (
!acknowledgement ||
generation !== installPolicyAcknowledgementState.generation ||
acknowledgement.generation !== generation ||
acknowledgement.expiresAt <= Date.now() ||
acknowledgement.requestKey !== installPolicyRequestKey(request)
) {
throw new ManagedPluginLifecycleError(
"Install policy approval expired or does not match this plugin. Review the current warning and try again.",
);
}
return {
resolvedRequest: acknowledgement.resolvedRequest,
warnings: acknowledgement.warnings,
publicationAuthority: createInstallPolicyPublicationAuthority(generation),
};
}
function managedInstallRequest(
params: PluginsInstallParams,
generation: number,
): ManagedPluginInstallRequest {
const installPolicyWarningAcknowledgement = consumeInstallPolicyAcknowledgement(
params,
generation,
);
if (params.source === "clawhub") {
return {
source: params.source,
packageName: params.packageName,
...(params.version ? { version: params.version } : {}),
...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
...(installPolicyWarningAcknowledgement ? { installPolicyWarningAcknowledgement } : {}),
};
}
return {
source: params.source,
pluginId: params.pluginId,
...(installPolicyWarningAcknowledgement ? { installPolicyWarningAcknowledgement } : {}),
};
}
function pluginPolicyRestartRequired(params: {
config: OpenClawConfig;
changedPaths: readonly string[];
@@ -49,7 +212,11 @@ export const pluginsHandlers: GatewayRequestHandlers = {
return;
}
try {
respond(true, await listManagedPlugins({ config: context.getRuntimeConfig() }), undefined);
// Handler operations can outlive a disconnected client, including while queued for the
// lifecycle lease. Reconnect reads wait for every earlier handler to reach a terminal state.
await waitForPendingPluginLifecycleOperations();
const result = await listManagedPlugins({ config: context.getRuntimeConfig() });
respond(true, result, undefined);
} catch (error) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
}
@@ -109,8 +276,13 @@ export const pluginsHandlers: GatewayRequestHandlers = {
if (!assertValidParams(params, validatePluginsInstallParams, "plugins.install", respond)) {
return;
}
const acknowledgementGeneration = installPolicyAcknowledgementState.generation;
try {
const result = await installManagedPlugin({ request: params });
const result = await runTrackedPluginLifecycleOperation(() =>
installManagedPlugin({
request: managedInstallRequest(params, acknowledgementGeneration),
}),
);
respond(
true,
{
@@ -123,17 +295,47 @@ export const pluginsHandlers: GatewayRequestHandlers = {
);
} catch (error) {
const lifecycleError = error instanceof ManagedPluginLifecycleError ? error : undefined;
if (
lifecycleError?.installPolicyWarning &&
lifecycleError.installPolicyResolvedRequest &&
acknowledgementGeneration !== installPolicyAcknowledgementState.generation
) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
"Gateway restarted before the install warning could be returned. Retry the install to review the current warning.",
),
);
return;
}
const trustCode =
lifecycleError?.code && isClawHubTrustErrorCode(lifecycleError.code)
? lifecycleError.code
: undefined;
const details = lifecycleError
const trustDetails = lifecycleError
? buildClawHubTrustErrorDetails({
...(trustCode ? { code: trustCode } : {}),
...(lifecycleError.version ? { version: lifecycleError.version } : {}),
...(lifecycleError.warning ? { warning: lifecycleError.warning } : {}),
})
: undefined;
const installPolicyDetails =
lifecycleError?.installPolicyWarning && lifecycleError.installPolicyResolvedRequest
? readInstallPolicyWarningErrorDetails({
installPolicyCode: INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED,
...lifecycleError.installPolicyWarning.warning,
acknowledgementToken: issueInstallPolicyAcknowledgement({
generation: acknowledgementGeneration,
request: params,
resolvedRequest: lifecycleError.installPolicyResolvedRequest,
warning: lifecycleError.installPolicyWarning,
acknowledgedWarnings: lifecycleError.installPolicyAcknowledgedWarnings,
}),
})
: undefined;
const details = installPolicyDetails ?? trustDetails;
respond(
false,
undefined,
@@ -152,7 +354,9 @@ export const pluginsHandlers: GatewayRequestHandlers = {
return;
}
try {
const result = await uninstallManagedPlugin({ pluginId: params.pluginId });
const result = await runTrackedPluginLifecycleOperation(() =>
uninstallManagedPlugin({ pluginId: params.pluginId }),
);
respond(
true,
{
@@ -185,10 +389,12 @@ export const pluginsHandlers: GatewayRequestHandlers = {
return;
}
try {
const result = await setManagedPluginEnabled({
pluginId: params.pluginId,
enabled: params.enabled,
});
const result = await runTrackedPluginLifecycleOperation(() =>
setManagedPluginEnabled({
pluginId: params.pluginId,
enabled: params.enabled,
}),
);
respond(
true,
{
+127
View File
@@ -281,6 +281,133 @@ describe("installPackageDir", () => {
).resolves.toHaveLength(0);
});
it("preserves caller-owned failure details from staged post-install validation", async () => {
await fixtureRootTracker.setup();
const fixtureRoot = await fixtureRootTracker.make("case");
const sourceDir = path.join(fixtureRoot, "source");
const installBaseDir = path.join(fixtureRoot, "plugins");
const targetDir = path.join(installBaseDir, "demo");
await fs.mkdir(sourceDir, { recursive: true });
await fs.writeFile(path.join(sourceDir, "marker.txt"), "new");
const installPolicyWarning = {
targetName: "demo",
targetType: "plugin" as const,
requestMode: "install" as const,
reason: "Review the installed dependency tree",
};
const result = await installPackageDir({
sourceDir,
targetDir,
mode: "install",
timeoutMs: 1_000,
copyErrorPrefix: "failed to copy plugin",
hasDeps: false,
depsLogMessage: "Installing deps…",
afterInstall: async () => ({
ok: false as const,
error: installPolicyWarning.reason,
code: "security_scan_blocked",
installPolicyWarning,
}),
});
expect(result).toEqual({
ok: false,
error: installPolicyWarning.reason,
code: "security_scan_blocked",
installPolicyWarning,
});
await expectMissingPath(targetDir);
await expect(
listMatchingDirs(installBaseDir, ".openclaw-install-stage-"),
).resolves.toHaveLength(0);
});
it("does not publish a staged install after its approval authority expires", async () => {
await fixtureRootTracker.setup();
const fixtureRoot = await fixtureRootTracker.make("case");
const sourceDir = path.join(fixtureRoot, "source");
const installBaseDir = path.join(fixtureRoot, "plugins");
const targetDir = path.join(installBaseDir, "demo");
await fs.mkdir(sourceDir, { recursive: true });
await fs.writeFile(path.join(sourceDir, "marker.txt"), "new");
const afterInstall = vi.fn(async () => ({ ok: true as const }));
const result = await installPackageDir({
sourceDir,
targetDir,
mode: "install",
timeoutMs: 1_000,
copyErrorPrefix: "failed to copy plugin",
hasDeps: false,
depsLogMessage: "Installing deps…",
afterInstall,
publicationAuthority: {
assertCurrent: () => {},
commit: () => {
throw new Error("approval expired");
},
},
});
expect(afterInstall).toHaveBeenCalledOnce();
expect(result).toEqual({
ok: false,
error: "failed to copy plugin: Error: approval expired",
});
await expectMissingPath(targetDir);
await expect(
listMatchingDirs(installBaseDir, ".openclaw-install-stage-"),
).resolves.toHaveLength(0);
});
it("restores an existing install when approval expires during publication", async () => {
await fixtureRootTracker.setup();
const fixtureRoot = await fixtureRootTracker.make("case");
const { installBaseDir, sourceDir, targetDir } =
await createExistingInstallFixture(fixtureRoot);
let approvalCurrent = true;
const assertCommitAllowed = vi.fn(() => {
if (!approvalCurrent) {
throw new Error("approval expired");
}
approvalCurrent = false;
});
const commitPublication = vi.fn(() => {
if (!approvalCurrent) {
throw new Error("approval expired");
}
});
const result = await installPackageDir({
sourceDir,
targetDir,
mode: "update",
timeoutMs: 1_000,
copyErrorPrefix: "failed to copy plugin",
hasDeps: false,
depsLogMessage: "Installing deps…",
publicationAuthority: {
assertCurrent: assertCommitAllowed,
commit: commitPublication,
},
});
expect(assertCommitAllowed).toHaveBeenCalledOnce();
expect(commitPublication).toHaveBeenCalledOnce();
expect(result).toEqual({
ok: false,
error: "failed to copy plugin: Error: approval expired",
});
await expect(fs.readFile(path.join(targetDir, "marker.txt"), "utf8")).resolves.toBe("old");
await expect(
listMatchingDirs(installBaseDir, ".openclaw-install-stage-"),
).resolves.toHaveLength(0);
await expect(
fs.readdir(path.join(installBaseDir, ".openclaw-install-backups")),
).resolves.toHaveLength(0);
});
it("restores the original install if publish rename fails", async () => {
await fixtureRootTracker.setup();
const fixtureRoot = await fixtureRootTracker.make("case");
+24 -16
View File
@@ -26,6 +26,9 @@ type HiddenProjectConfigFile = {
hiddenPath: string;
} | null;
type InstallPackageDirFailure = { ok: false; error: string };
type InstallPackageDirSuccess = { ok: true };
async function sanitizeManifestForNpmInstall(targetDir: string): Promise<void> {
const manifestPath = path.join(targetDir, "package.json");
const parsed = await tryReadJson<unknown>(manifestPath);
@@ -215,7 +218,9 @@ export function resolvePackageDirInstallTransaction(
* Update mode backs up the existing target, runs optional validation hooks,
* and rolls back when copy, dependency install, or validation fails.
*/
export async function installPackageDir(params: {
export async function installPackageDir<
TAfterInstallFailure extends InstallPackageDirFailure = InstallPackageDirFailure,
>(params: {
sourceDir: string;
targetDir: string;
mode: "install" | "update";
@@ -226,10 +231,12 @@ export async function installPackageDir(params: {
sourceHardlinks?: InstallSourceHardlinks;
depsLogMessage: string;
afterCopy?: (installedDir: string) => void | Promise<void>;
afterInstall?: (
installedDir: string,
) => Promise<{ ok: true } | { ok: false; error: string; code?: string }>;
}): Promise<{ ok: true } | { ok: false; error: string; code?: string }> {
afterInstall?: (installedDir: string) => Promise<InstallPackageDirSuccess | TAfterInstallFailure>;
publicationAuthority?: {
assertCurrent: () => void;
commit: () => void;
};
}): Promise<InstallPackageDirSuccess | InstallPackageDirFailure | TAfterInstallFailure> {
const deferCommit = isPackageDirInstallCommitDeferred(params);
params.logger?.info?.(`Installing to ${params.targetDir}`);
const installBaseDir = path.dirname(params.targetDir);
@@ -279,10 +286,6 @@ export async function installPackageDir(params: {
}
return { ok: false as const, error };
};
const failWithCode = async (paramsLocal: { error: string; code?: string }, cause?: unknown) => {
const failed = await fail(paramsLocal.error, cause);
return paramsLocal.code ? { ...failed, code: paramsLocal.code } : failed;
};
const restoreBackup = async () => {
if (!backupDir) {
return;
@@ -354,7 +357,10 @@ export async function installPackageDir(params: {
try {
const postInstallResult = await params.afterInstall(stageDir);
if (!postInstallResult.ok) {
return await failWithCode(postInstallResult);
const failed = await fail(postInstallResult.error);
// The caller owns post-install failure metadata. Preserve the exact
// result so domain details survive cleanup and rollback.
return { ...postInstallResult, error: failed.error };
}
} catch (err) {
return await fail(`post-install validation failed: ${String(err)}`, err);
@@ -374,6 +380,7 @@ export async function installPackageDir(params: {
installBaseDir,
expectedRealPath: installBaseRealPath,
});
params.publicationAuthority?.assertCurrent();
await movePathWithCopyFallback({
from: canonicalTargetDir,
sourceHardlinks,
@@ -389,6 +396,7 @@ export async function installPackageDir(params: {
installBaseDir,
expectedRealPath: installBaseRealPath,
});
params.publicationAuthority?.commit();
await movePathWithCopyFallback({
from: stageDir,
sourceHardlinks,
@@ -458,7 +466,9 @@ export async function installPackageDir(params: {
* Installs a manifest-backed package directory while deriving whether npm
* dependencies must be installed and which hardlink policy is safe to use.
*/
export async function installPackageDirWithManifestDeps(params: {
export async function installPackageDirWithManifestDeps<
TAfterInstallFailure extends InstallPackageDirFailure = InstallPackageDirFailure,
>(params: {
sourceDir: string;
targetDir: string;
mode: "install" | "update";
@@ -468,12 +478,10 @@ export async function installPackageDirWithManifestDeps(params: {
depsLogMessage: string;
manifestDependencies?: Record<string, unknown>;
afterCopy?: (installedDir: string) => void | Promise<void>;
afterInstall?: (
installedDir: string,
) => Promise<{ ok: true } | { ok: false; error: string; code?: string }>;
}): Promise<{ ok: true } | { ok: false; error: string; code?: string }> {
afterInstall?: (installedDir: string) => Promise<InstallPackageDirSuccess | TAfterInstallFailure>;
}): Promise<InstallPackageDirSuccess | InstallPackageDirFailure | TAfterInstallFailure> {
const hasDeps = Object.keys(params.manifestDependencies ?? {}).length > 0;
return installPackageDir({
return installPackageDir<TAfterInstallFailure>({
...params,
hasDeps,
sourceHardlinks: hasDeps ? "package-manager" : "reject",
+24
View File
@@ -57,4 +57,28 @@ describe("resolveNpmIntegrityDrift", () => {
"Integrity drift detected for @openclaw/test@1.0.0: expected sha512-old, got sha512-new",
);
});
it("fails closed when a pinned resolution omits fresh integrity metadata", async () => {
const warn = vi.fn();
const onIntegrityDrift = vi.fn(async () => true);
const result = await resolveNpmIntegrityDriftWithDefaultMessage({
spec: "@openclaw/test@1.0.0",
expectedIntegrity: "sha512-reviewed",
resolution: {
resolvedSpec: "@openclaw/test@1.0.0",
resolvedAt: "2026-01-01T00:00:00.000Z",
},
onIntegrityDrift,
warn,
});
expect(result).toEqual({
error: "aborted: npm package integrity metadata missing for @openclaw/test@1.0.0",
});
expect(onIntegrityDrift).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
"Integrity metadata missing for @openclaw/test@1.0.0: expected sha512-reviewed",
);
});
});
+8
View File
@@ -86,6 +86,14 @@ type ResolveNpmIntegrityDriftWithDefaultMessageParams = {
export async function resolveNpmIntegrityDriftWithDefaultMessage(
params: ResolveNpmIntegrityDriftWithDefaultMessageParams,
): Promise<{ integrityDrift?: NpmIntegrityDrift; error?: string }> {
const expectedIntegrity = normalizeIntegrity(params.expectedIntegrity);
const resolvedLabel = params.resolution.resolvedSpec ?? params.spec;
if (expectedIntegrity && !normalizeIntegrity(params.resolution.integrity)) {
params.warn?.(`Integrity metadata missing for ${resolvedLabel}: expected ${expectedIntegrity}`);
return {
error: `aborted: npm package integrity metadata missing for ${resolvedLabel}`,
};
}
const driftResult = await resolveNpmIntegrityDrift<NpmIntegrityDriftPayload>({
spec: params.spec,
expectedIntegrity: params.expectedIntegrity,
+30 -1
View File
@@ -279,6 +279,7 @@ type ArchiveInstallCall = {
dangerouslyForceUnsafeInstall?: boolean;
expectedPluginId?: string;
onInstallPolicyWarning?: unknown;
publicationAuthority?: unknown;
installPolicyRequest?: {
kind?: string;
requestedSpecifier?: string;
@@ -299,6 +300,7 @@ type InstallSuccess = {
type InstallFailure = {
code?: string;
error: string;
integrity?: string;
ok: false;
version?: string;
warning?: string;
@@ -1783,6 +1785,29 @@ describe("installPluginFromClawHub", () => {
expect(archiveInstallCall().onInstallPolicyWarning).toBe(onInstallPolicyWarning);
});
it("passes install publication authority through to archive installs", async () => {
const publicationAuthority = {
assertCurrent: vi.fn(),
commit: vi.fn(),
};
await installPluginFromClawHub({
spec: "clawhub:demo",
publicationAuthority,
});
expect(archiveInstallCall().publicationAuthority).toBe(publicationAuthority);
});
it("preserves the reviewed policy spec when retrying a pinned ClawHub version", async () => {
await installPluginFromClawHub({
spec: "clawhub:demo@2026.3.22",
installPolicyRequestedSpecifier: "clawhub:demo",
});
expect(archiveInstallCall().installPolicyRequest?.requestedSpecifier).toBe("clawhub:demo");
});
it("cleans up the downloaded archive even when archive install fails", async () => {
installPluginFromArchiveMock.mockResolvedValueOnce({
ok: false,
@@ -1794,7 +1819,11 @@ describe("installPluginFromClawHub", () => {
baseUrl: "https://clawhub.ai",
});
expect(expectInstallFailure(result).error).toBe("bad archive");
expect(expectInstallFailure(result)).toMatchObject({
error: "bad archive",
integrity: DEMO_ARCHIVE_INTEGRITY,
version: "2026.3.22",
});
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
});
+26 -17
View File
@@ -53,6 +53,7 @@ import { CLAWHUB_INSTALL_ERROR_CODE, type ClawHubInstallErrorCode } from "./claw
import type { ClawHubPluginInstallRecordFields } from "./clawhub-install-records.js";
import type { InstallSafetyOverrides } from "./install-security-scan.js";
import { copyPluginInstallTransactionRequest } from "./install-transaction.js";
import type { InstallPublicationOptions } from "./install-types.js";
import {
installPluginFromArchive,
PLUGIN_INSTALL_ERROR_CODE,
@@ -74,6 +75,7 @@ type ClawHubInstallFailure = {
ok: false;
error: string;
code?: ClawHubInstallErrorCode;
integrity?: string;
warning?: string;
version?: string;
};
@@ -1207,21 +1209,23 @@ function logClawHubPackageSummary(params: {
}
export async function installPluginFromClawHub(
params: InstallSafetyOverrides & {
spec: string;
baseUrl?: string;
token?: string;
logger?: PluginInstallLogger;
mode?: "install" | "update";
extensionsDir?: string;
timeoutMs?: number;
dryRun?: boolean;
expectedPluginId?: string;
expectedIntegrity?: string;
env?: RuntimeVersionEnv;
acknowledgeClawHubRisk?: boolean;
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise<boolean>;
},
params: InstallSafetyOverrides &
InstallPublicationOptions & {
spec: string;
baseUrl?: string;
token?: string;
logger?: PluginInstallLogger;
mode?: "install" | "update";
extensionsDir?: string;
timeoutMs?: number;
dryRun?: boolean;
expectedPluginId?: string;
expectedIntegrity?: string;
installPolicyRequestedSpecifier?: string;
env?: RuntimeVersionEnv;
acknowledgeClawHubRisk?: boolean;
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise<boolean>;
},
): Promise<
| ({
ok: true;
@@ -1445,6 +1449,7 @@ export async function installPluginFromClawHub(
const installResult = await installPluginFromArchive(
copyPluginInstallTransactionRequest(params, {
archivePath: archive.archivePath,
publicationAuthority: params.publicationAuthority,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
onInstallPolicyWarning: params.onInstallPolicyWarning,
trustedSourceLinkedOfficialInstall:
@@ -1458,7 +1463,7 @@ export async function installPluginFromClawHub(
expectedPluginId: runtimeIdResolution.expectedPluginId,
installPolicyRequest: {
kind: "plugin-archive",
requestedSpecifier: params.spec,
requestedSpecifier: params.installPolicyRequestedSpecifier ?? params.spec,
source: {
kind: "clawhub",
authority: officialClawHubPackage ? "official" : clawhubAuthority,
@@ -1469,7 +1474,11 @@ export async function installPluginFromClawHub(
}),
);
if (!installResult.ok) {
return installResult;
return {
...installResult,
integrity: archive.integrity,
version: versionState.version,
};
}
const pkg = detail.package!;
+13 -10
View File
@@ -32,6 +32,7 @@ import {
isPluginInstallCommitDeferred,
type PluginInstallTransaction,
} from "./install-transaction.js";
import type { InstallPublicationOptions } from "./install-types.js";
import {
installPluginFromInstalledPackageDir,
PLUGIN_INSTALL_ERROR_CODE,
@@ -377,16 +378,17 @@ async function runGitCommand(params: {
}
export async function installPluginFromGitSpec(
params: InstallSafetyOverrides & {
spec: string;
extensionsDir?: string;
gitDir?: string;
timeoutMs?: number;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
},
params: InstallSafetyOverrides &
InstallPublicationOptions & {
spec: string;
extensionsDir?: string;
gitDir?: string;
timeoutMs?: number;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
},
): Promise<GitPluginInstallResult> {
const parsed = parseGitPluginSpec(params.spec);
if (!parsed) {
@@ -525,6 +527,7 @@ export async function installPluginFromGitSpec(
}
let transaction: PluginInstallTransaction | undefined;
if (!params.dryRun) {
params.publicationAuthority?.commit();
const replaceResult = await replaceManagedGitRepo({
stagedRepoDir: repoDir,
persistentRepoDir,
+23 -21
View File
@@ -64,6 +64,7 @@ import {
isPluginInstallCommitDeferred,
} from "./install-transaction.js";
import type {
InstallPublicationOptions,
InstallPluginResult,
PluginInstallLogger,
PluginInstallPolicyRequest,
@@ -75,27 +76,28 @@ import {
} from "./plugin-peer-link.js";
export async function installPluginFromManagedNpmRoot(
params: InstallSafetyOverrides & {
packageName: string;
dependencySpec?: string;
prepareDependencySpec?: ManagedNpmRootDependencySpecPreparation;
displaySpec: string;
installPolicyRequest: PluginInstallPolicyRequest;
npmResolution: NpmSpecResolution;
policyPreflightSourcePath?: string;
policyPreflightSourcePathKind?: "file" | "directory";
skipPolicyPreflight?: boolean;
extensionsDir?: string;
npmDir?: string;
timeoutMs?: number;
signal?: AbortSignal;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
expectedReplacementPluginId?: string;
integrityDrift?: NpmIntegrityDrift;
},
params: InstallSafetyOverrides &
InstallPublicationOptions & {
packageName: string;
dependencySpec?: string;
prepareDependencySpec?: ManagedNpmRootDependencySpecPreparation;
displaySpec: string;
installPolicyRequest: PluginInstallPolicyRequest;
npmResolution: NpmSpecResolution;
policyPreflightSourcePath?: string;
policyPreflightSourcePathKind?: "file" | "directory";
skipPolicyPreflight?: boolean;
extensionsDir?: string;
npmDir?: string;
timeoutMs?: number;
signal?: AbortSignal;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
expectedReplacementPluginId?: string;
integrityDrift?: NpmIntegrityDrift;
},
): Promise<InstallPluginResult> {
const runtime = await loadPluginInstallRuntime();
const { logger, timeoutMs, mode, dryRun } = runtime.resolveTimedInstallModeOptions(
+16 -13
View File
@@ -27,6 +27,7 @@ import {
} from "./install-shared.js";
import {
PLUGIN_INSTALL_ERROR_CODE,
type InstallPublicationOptions,
type InstallPluginResult,
type PluginInstallErrorCode,
type PluginInstallLogger,
@@ -156,19 +157,20 @@ async function stageNpmPackArchiveInManagedRoot(params: {
}
export async function installPluginFromNpmPackArchive(
params: InstallSafetyOverrides & {
archivePath: string;
extensionsDir?: string;
npmDir?: string;
timeoutMs?: number;
signal?: AbortSignal;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
expectedIntegrity?: string;
onIntegrityDrift?: (params: PluginNpmIntegrityDriftParams) => boolean | Promise<boolean>;
},
params: InstallSafetyOverrides &
InstallPublicationOptions & {
archivePath: string;
extensionsDir?: string;
npmDir?: string;
timeoutMs?: number;
signal?: AbortSignal;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
expectedIntegrity?: string;
onIntegrityDrift?: (params: PluginNpmIntegrityDriftParams) => boolean | Promise<boolean>;
},
): Promise<InstallPluginResult & { npmTarballName?: string }> {
const runtime = await loadPluginInstallRuntime();
const { logger, timeoutMs, mode, dryRun } = runtime.resolveTimedInstallModeOptions(
@@ -233,6 +235,7 @@ export async function installPluginFromNpmPackArchive(
: targetMode;
const result = await installPluginFromManagedNpmRoot({
publicationAuthority: params.publicationAuthority,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
onInstallPolicyWarning: params.onInstallPolicyWarning,
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
@@ -0,0 +1,85 @@
import fs from "node:fs/promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
type ResolveNpmSpecMetadata =
typeof import("../infra/install-source-utils.js").resolveNpmSpecMetadata;
type PreflightPluginNpmInstallPolicy =
typeof import("./install-security-scan.js").preflightPluginNpmInstallPolicy;
const resolveNpmSpecMetadataMock = vi.fn<ResolveNpmSpecMetadata>();
const preflightPluginNpmInstallPolicyMock = vi.fn<PreflightPluginNpmInstallPolicy>();
vi.mock("../infra/install-source-utils.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../infra/install-source-utils.js")>();
return { ...actual, resolveNpmSpecMetadata: resolveNpmSpecMetadataMock };
});
vi.mock("./install-security-scan.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./install-security-scan.js")>();
return {
...actual,
preflightPluginNpmInstallPolicy: preflightPluginNpmInstallPolicyMock,
};
});
const { installPluginFromNpmSpec } = await import("./install-npm.js");
describe("npm install policy metadata", () => {
beforeEach(() => {
resolveNpmSpecMetadataMock.mockReset();
preflightPluginNpmInstallPolicyMock.mockReset();
resolveNpmSpecMetadataMock.mockResolvedValue({
ok: true,
metadata: {
name: "@openclaw/demo",
version: "1.2.3",
resolvedSpec: "@openclaw/demo@1.2.3",
integrity: "sha512-reviewed",
shasum: "reviewed",
packageOpenClaw: { extensions: ["./index.js"] },
},
});
});
it("keeps policy metadata stable when a retry pins the resolved npm spec", async () => {
const policyInputs: Array<{
requestedSpecifier: string;
metadata: Record<string, unknown>;
}> = [];
preflightPluginNpmInstallPolicyMock.mockImplementation(async (params) => {
policyInputs.push({
requestedSpecifier: params.requestedSpecifier ?? "",
metadata: JSON.parse(await fs.readFile(params.sourcePath, "utf8")) as Record<
string,
unknown
>,
});
return { blocked: { code: "security_scan_blocked", reason: "test stop" } };
});
const requestedSpecifier = "@openclaw/demo@latest";
const first = await installPluginFromNpmSpec({ spec: requestedSpecifier });
const retry = await installPluginFromNpmSpec({
spec: "@openclaw/demo@1.2.3",
installPolicyRequestedSpecifier: requestedSpecifier,
expectedIntegrity: "sha512-reviewed",
});
expect(first.ok).toBe(false);
expect(retry.ok).toBe(false);
expect(policyInputs).toHaveLength(2);
expect(policyInputs[0]).toMatchObject({
requestedSpecifier,
metadata: {
packageName: "@openclaw/demo",
requestedSpecifier,
resolution: {
resolvedSpec: "@openclaw/demo@1.2.3",
integrity: "sha512-reviewed",
},
},
});
expect(policyInputs[1]).toEqual(policyInputs[0]);
expect(policyInputs[0]).not.toHaveProperty("metadata.resolution.resolvedAt");
});
});
+27 -20
View File
@@ -38,6 +38,7 @@ import {
import { copyPluginInstallTransactionRequest } from "./install-transaction.js";
import {
PLUGIN_INSTALL_ERROR_CODE,
type InstallPublicationOptions,
type InstallPluginResult,
type PluginInstallLogger,
type PluginNpmIntegrityDriftParams,
@@ -45,20 +46,22 @@ import {
import { hasRetainedManagedNpmInstallMarker } from "./managed-npm-retention.js";
export async function installPluginFromNpmSpec(
params: InstallSafetyOverrides & {
spec: string;
extensionsDir?: string;
npmDir?: string;
timeoutMs?: number;
signal?: AbortSignal;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
expectedReplacementPluginId?: string;
expectedIntegrity?: string;
onIntegrityDrift?: (params: PluginNpmIntegrityDriftParams) => boolean | Promise<boolean>;
},
params: InstallSafetyOverrides &
InstallPublicationOptions & {
spec: string;
extensionsDir?: string;
npmDir?: string;
timeoutMs?: number;
signal?: AbortSignal;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
expectedReplacementPluginId?: string;
expectedIntegrity?: string;
installPolicyRequestedSpecifier?: string;
onIntegrityDrift?: (params: PluginNpmIntegrityDriftParams) => boolean | Promise<boolean>;
},
): Promise<InstallPluginResult> {
const runtime = await loadPluginInstallRuntime();
const { logger, timeoutMs, mode, dryRun } = runtime.resolveTimedInstallModeOptions(
@@ -67,6 +70,7 @@ export async function installPluginFromNpmSpec(
);
const expectedPluginId = params.expectedPluginId;
const spec = params.spec.trim();
const installPolicyRequestedSpecifier = params.installPolicyRequestedSpecifier?.trim() || spec;
const specError = runtime.validateRegistryNpmSpec(spec);
if (specError) {
return {
@@ -212,14 +216,16 @@ export async function installPluginFromNpmSpec(
const policyTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-npm-policy-"));
try {
const policyNpmResolution = { ...npmResolution };
delete policyNpmResolution.resolvedAt;
const policyMetadataPath = path.join(policyTempDir, "npm-package-metadata.json");
await fs.writeFile(
policyMetadataPath,
`${JSON.stringify(
{
packageName: parsedSpec.name,
requestedSpecifier: spec,
resolution: npmResolution,
requestedSpecifier: installPolicyRequestedSpecifier,
resolution: policyNpmResolution,
},
null,
2,
@@ -240,14 +246,14 @@ export async function installPluginFromNpmSpec(
mode: policyMode,
packageName: parsedSpec.name,
...(expectedPluginId ? { pluginId: expectedPluginId } : {}),
requestedSpecifier: spec,
requestedSpecifier: installPolicyRequestedSpecifier,
source: npmInstallPolicySource,
sourcePath: policyMetadataPath,
sourcePathKind: "file",
}),
});
if (preflightPolicyResult) {
return preflightPolicyResult;
return { ...preflightPolicyResult, npmResolution };
}
} finally {
await fs.rm(policyTempDir, { recursive: true, force: true });
@@ -255,6 +261,7 @@ export async function installPluginFromNpmSpec(
const result = await installPluginFromManagedNpmRoot(
copyPluginInstallTransactionRequest(params, {
publicationAuthority: params.publicationAuthority,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
onInstallPolicyWarning: params.onInstallPolicyWarning,
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
@@ -267,7 +274,7 @@ export async function installPluginFromNpmSpec(
displaySpec: spec,
installPolicyRequest: {
kind: "plugin-npm",
requestedSpecifier: spec,
requestedSpecifier: installPolicyRequestedSpecifier,
source: npmInstallPolicySource,
},
extensionsDir: params.extensionsDir,
@@ -290,5 +297,5 @@ export async function installPluginFromNpmSpec(
sourceFamily: "npm",
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
});
return result;
return result.ok ? result : { ...result, npmResolution };
}
+4
View File
@@ -49,6 +49,7 @@ function pickPackageInstallCommonParams(
config: params.config,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
onInstallPolicyWarning: params.onInstallPolicyWarning,
publicationAuthority: params.publicationAuthority,
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
extensionsDir: params.extensionsDir,
npmDir: params.npmDir,
@@ -195,6 +196,7 @@ async function installBundleFromSourceDir(
copyErrorPrefix: "failed to copy plugin bundle",
hasDeps: false,
depsLogMessage: "",
publicationAuthority: params.publicationAuthority,
}),
);
return installed.ok
@@ -334,6 +336,7 @@ async function installPluginFromPackageDir(
hasDeps: shouldInstallRuntimeDeps,
sourceHardlinks: shouldInstallRuntimeDeps ? "package-manager" : "reject",
depsLogMessage: "Installing plugin dependencies…",
publicationAuthority: params.publicationAuthority,
nameEncoder: encodePluginInstallDirName,
afterInstall: async (installedDir) => {
return await scanAndLinkInstalledPackage({
@@ -389,6 +392,7 @@ export async function installPluginFromArchive(
sourceDir,
...pickPackageInstallCommonParams(
copyPluginInstallTransactionRequest(params, {
publicationAuthority: params.publicationAuthority,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
onInstallPolicyWarning: params.onInstallPolicyWarning,
extensionsDir: params.extensionsDir,
+5 -1
View File
@@ -23,7 +23,7 @@ import {
import { discoverOpenClawPlugins } from "./discovery.js";
import { enablePluginInConfig } from "./enable.js";
import { commitPluginInstallRecordsWithConfig } from "./install-record-commit.js";
import type { PluginInstallLogger } from "./install-types.js";
import type { InstallPublicationAuthority, PluginInstallLogger } from "./install-types.js";
import {
loadInstalledPluginIndexInstallRecords,
recordPluginInstallInRecords,
@@ -484,6 +484,7 @@ export async function persistPluginInstall(params: {
warningMessage?: string;
runtime?: RuntimeEnv;
persistenceLogger?: PluginInstallLogger;
publicationAuthority?: InstallPublicationAuthority;
}): Promise<OpenClawConfig> {
const runtime = params.runtime ?? defaultRuntime;
// Terminal diagnostics may contain paths/errors; management receives only producer-authored summaries.
@@ -628,6 +629,9 @@ export async function persistPluginInstall(params: {
nextInstallRecords,
nextConfig: next,
baseHash: params.snapshot.baseHash,
...(params.publicationAuthority
? { commitPublication: params.publicationAuthority.commit }
: {}),
writeOptions: {
...params.snapshot.writeOptions,
afterWrite: { mode: "restart", reason: "plugin source changed" },
@@ -0,0 +1,44 @@
import { expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
replaceConfigFile: vi.fn(),
writeInstallRecords: vi.fn(),
}));
vi.mock("../config/config.js", () => ({
replaceConfigFile: mocks.replaceConfigFile,
resolveConfigWriteAfterWrite: (value?: unknown) => value ?? { mode: "auto" },
}));
vi.mock("./installed-plugin-index-records.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./installed-plugin-index-records.js")>()),
writePersistedInstalledPluginIndexInstallRecordsWithLease: mocks.writeInstallRecords,
}));
vi.mock("./plugin-lifecycle-lease.js", () => ({
withPluginLifecycleLease: async (
_options: unknown,
run: (lease: { databasePath: string }) => Promise<unknown>,
) => await run({ databasePath: "/tmp/openclaw-plugin-index.sqlite" }),
}));
const { commitPluginInstallRecordsWithConfig } = await import("./install-record-commit.js");
it("checks publication authority immediately before the tentative index write", async () => {
const commitPublication = vi.fn(() => {
throw new Error("approval expired");
});
await expect(
commitPluginInstallRecordsWithConfig({
previousInstallRecords: {},
nextInstallRecords: { demo: { source: "npm", spec: "demo@1.0.0" } },
nextConfig: {},
commitPublication,
}),
).rejects.toThrow("approval expired");
expect(commitPublication).toHaveBeenCalledOnce();
expect(mocks.writeInstallRecords).not.toHaveBeenCalled();
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
});
+4
View File
@@ -395,6 +395,7 @@ async function commitPluginInstallRecordsWithWriter(params: {
nextConfig: OpenClawConfig;
writeOptions?: ConfigWriteOptions;
commit: ConfigCommit;
commitPublication?: () => void;
}): Promise<{
committed: ConfigReplaceResult | void;
nextInstallRecords: Record<string, PluginInstallRecord>;
@@ -406,6 +407,7 @@ async function commitPluginInstallRecordsWithWriter(params: {
try {
const storeOptions = { filePath: lease.databasePath };
const prepared = await params.prepareInstallRecords(storeOptions);
params.commitPublication?.();
tentativeWrite = await writePersistedInstalledPluginIndexInstallRecordsWithLease(
prepared.nextInstallRecords,
{
@@ -475,6 +477,7 @@ export async function commitPluginInstallRecordsWithConfig(params: {
nextConfig: OpenClawConfig;
baseHash?: string;
writeOptions?: ConfigWriteOptions;
commitPublication?: () => void;
}): Promise<void> {
await commitPluginInstallRecordsWithWriter({
prepareInstallRecords: async (storeOptions) => ({
@@ -484,6 +487,7 @@ export async function commitPluginInstallRecordsWithConfig(params: {
nextInstallRecords: params.nextInstallRecords,
}),
nextConfig: params.nextConfig,
...(params.commitPublication ? { commitPublication: params.commitPublication } : {}),
...(params.writeOptions ? { writeOptions: params.writeOptions } : {}),
commit: async (nextConfig, writeOptions) => {
return await replaceConfigFile({
+138 -14
View File
@@ -3,14 +3,16 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
const runInstallPolicyMock = vi.fn();
type RunInstallPolicy = typeof import("../security/install-policy.js").runInstallPolicy;
const runInstallPolicyMock = vi.fn<RunInstallPolicy>();
const getGlobalHookRunnerMock = vi.fn();
vi.mock("../security/install-policy.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../security/install-policy.js")>();
return {
...actual,
runInstallPolicy: (...args: unknown[]) => runInstallPolicyMock(...args),
runInstallPolicy: runInstallPolicyMock,
};
});
@@ -326,10 +328,18 @@ describe("legacy file install scan compatibility", () => {
const onInstallPolicyWarning = vi.fn().mockResolvedValue({ status: "approved" });
runInstallPolicyMock
.mockResolvedValueOnce({
warning: { reason: "review this plugin", fingerprint: "warning-a" },
warning: {
reason: "review this plugin",
fingerprint: "warning-a",
approvalFingerprint: "approval-a",
},
})
.mockResolvedValueOnce({
warning: { reason: "review this plugin", fingerprint: "warning-a" },
warning: {
reason: "review this plugin",
fingerprint: "warning-a",
approvalFingerprint: "approval-a",
},
});
const result = await scanFileInstallSourceRuntime({
@@ -344,7 +354,20 @@ describe("legacy file install scan compatibility", () => {
targetName: "payload",
targetType: "plugin",
requestMode: "install",
approvalFingerprint: "approval-a",
scan: {
requestKind: "plugin-file",
originType: "plugin-file",
pluginContentType: "file",
},
warning: {
targetName: "payload",
targetType: "plugin",
requestMode: "install",
reason: "review this plugin",
},
});
expect(onInstallPolicyWarning).toHaveBeenCalledTimes(1);
expect(runInstallPolicyMock).toHaveBeenCalledTimes(2);
});
@@ -352,10 +375,18 @@ describe("legacy file install scan compatibility", () => {
const onInstallPolicyWarning = vi.fn().mockResolvedValue({ status: "approved" });
runInstallPolicyMock
.mockResolvedValueOnce({
warning: { reason: "review this plugin", fingerprint: "warning-a" },
warning: {
reason: "review this plugin",
fingerprint: "warning-a",
approvalFingerprint: "approval-a",
},
})
.mockResolvedValueOnce({
warning: { reason: "review the new finding", fingerprint: "warning-b" },
warning: {
reason: "review the new finding",
fingerprint: "warning-b",
approvalFingerprint: "approval-a",
},
findings: [
{
ruleId: "changed-warning",
@@ -374,6 +405,27 @@ describe("legacy file install scan compatibility", () => {
expect(result?.blocked).toMatchObject({
code: "security_scan_blocked",
installPolicyWarning: {
scan: {
requestKind: "plugin-file",
originType: "plugin-file",
pluginContentType: "file",
},
warning: {
targetName: "payload",
targetType: "plugin",
requestMode: "install",
reason: "review the new finding",
findings: [
{
ruleId: "changed-warning",
severity: "warn",
message: "new finding",
},
],
},
approvalFingerprint: "approval-a",
},
});
expect(result?.blocked?.reason).toContain("Reason: review the new finding");
expect(result?.blocked?.reason).toContain("new finding");
@@ -385,7 +437,11 @@ describe("legacy file install scan compatibility", () => {
it("renders metadata changes that require approval again", async () => {
const onInstallPolicyWarning = vi.fn().mockResolvedValue({ status: "approved" });
const initialWarning = {
warning: { reason: "same bounded reason", fingerprint: "full-warning-a" },
warning: {
reason: "same bounded reason",
fingerprint: "full-warning-a",
approvalFingerprint: "approval-a",
},
findings: [
{
ruleId: "initial-rule",
@@ -396,7 +452,11 @@ describe("legacy file install scan compatibility", () => {
],
};
runInstallPolicyMock.mockResolvedValueOnce(initialWarning).mockResolvedValueOnce({
warning: { ...initialWarning.warning, fingerprint: "full-warning-b" },
warning: {
...initialWarning.warning,
fingerprint: "full-warning-b",
approvalFingerprint: "approval-b",
},
findings: [
{
ruleId: "refreshed-rule",
@@ -421,6 +481,7 @@ describe("legacy file install scan compatibility", () => {
);
expect(result?.blocked?.reason).not.toContain("initial-rule");
expect(result?.blocked?.reason).not.toContain("initial evidence");
expect(result?.blocked?.installPolicyWarning?.approvalFingerprint).toBe("approval-b");
expect(onInstallPolicyWarning).toHaveBeenCalledTimes(1);
expect(runInstallPolicyMock).toHaveBeenCalledTimes(2);
});
@@ -428,7 +489,11 @@ describe("legacy file install scan compatibility", () => {
it("keeps a block from policy re-evaluation terminal", async () => {
runInstallPolicyMock
.mockResolvedValueOnce({
warning: { reason: "review this plugin", fingerprint: "warning-a" },
warning: {
reason: "review this plugin",
fingerprint: "warning-a",
approvalFingerprint: "approval-a",
},
})
.mockResolvedValueOnce({
blocked: { code: "security_scan_blocked", reason: "now blocked" },
@@ -446,7 +511,11 @@ describe("legacy file install scan compatibility", () => {
it("keeps the deprecated unsafe flag inert when policy warns", async () => {
runInstallPolicyMock.mockResolvedValue({
warning: { reason: "review this plugin", fingerprint: "warning-a" },
warning: {
reason: "review this plugin",
fingerprint: "warning-a",
approvalFingerprint: "approval-a",
},
});
const result = await scanFileInstallSourceRuntime({
@@ -458,6 +527,20 @@ describe("legacy file install scan compatibility", () => {
expect(result?.blocked).toEqual({
code: "security_scan_blocked",
installPolicyWarning: {
scan: {
requestKind: "plugin-file",
originType: "plugin-file",
pluginContentType: "file",
},
warning: {
targetName: "payload",
targetType: "plugin",
requestMode: "install",
reason: "review this plugin",
},
approvalFingerprint: "approval-a",
},
reason: expectedInstallPolicyNotice({
decision: "warn",
guidance: [
@@ -479,7 +562,11 @@ describe("legacy file install scan compatibility", () => {
it("keeps a block from acknowledged policy re-evaluation terminal", async () => {
runInstallPolicyMock
.mockResolvedValueOnce({
warning: { reason: "review this plugin", fingerprint: "warning-a" },
warning: {
reason: "review this plugin",
fingerprint: "warning-a",
approvalFingerprint: "approval-a",
},
})
.mockResolvedValueOnce({
blocked: { code: "security_scan_blocked", reason: "now blocked" },
@@ -498,7 +585,11 @@ describe("legacy file install scan compatibility", () => {
it("distinguishes an exhausted noninteractive approval from cancellation", async () => {
runInstallPolicyMock.mockResolvedValue({
warning: { reason: "review the dependency warning", fingerprint: "warning-a" },
warning: {
reason: "review the dependency warning",
fingerprint: "warning-a",
approvalFingerprint: "approval-a",
},
findings: [
{
ruleId: "dependency-warning",
@@ -523,23 +614,56 @@ describe("legacy file install scan compatibility", () => {
expect(result?.blocked?.reason).toContain("The noninteractive approval was already used.");
expect(result?.blocked?.reason).toContain("Review this warning and rerun interactively.");
expect(result?.blocked?.reason).not.toContain("Install cancelled");
expect(result?.blocked?.installPolicyWarning).toMatchObject({
scan: {
requestKind: "plugin-file",
originType: "plugin-file",
pluginContentType: "file",
},
warning: {
targetName: "payload",
targetType: "plugin",
requestMode: "install",
reason: "review the dependency warning",
},
approvalFingerprint: "approval-a",
});
expect(runInstallPolicyMock).toHaveBeenCalledTimes(1);
});
it("renders warning details as one readable review notice", async () => {
const warnings: string[] = [];
runInstallPolicyMock.mockResolvedValue({
warning: { reason: "review this plugin", fingerprint: "warning-a" },
warning: {
reason: "review this plugin",
fingerprint: "warning-a",
approvalFingerprint: "approval-a",
},
findings: [{ ruleId: "context", severity: "info", message: "Informational context." }],
});
await scanFileInstallSourceRuntime({
const result = await scanFileInstallSourceRuntime({
filePath: "/tmp/payload.js",
logger: { warn: (message) => warnings.push(message) },
onInstallPolicyWarning: vi.fn().mockResolvedValue({ status: "declined" }),
pluginId: "payload",
});
expect(result?.blocked?.installPolicyWarning).toEqual({
scan: {
requestKind: "plugin-file",
originType: "plugin-file",
pluginContentType: "file",
},
warning: {
targetName: "payload",
targetType: "plugin",
requestMode: "install",
reason: "review this plugin",
findings: [{ ruleId: "context", severity: "info", message: "Informational context." }],
},
approvalFingerprint: "approval-a",
});
expect(warnings).toEqual([
`${expectedInstallPolicyNotice({
decision: "warn",
+47 -5
View File
@@ -17,7 +17,11 @@ import {
import { isPathInside } from "../security/scan-paths.js";
import { getGlobalHookRunner } from "./hook-runner-global.js";
import { createBeforeInstallHookPayload } from "./install-policy-context.js";
import type { InstallSafetyOverrides } from "./install-security-scan.types.js";
import type {
InstallPolicyWarningDetails,
InstallPolicyWarningOccurrence,
InstallSafetyOverrides,
} from "./install-security-scan.types.js";
type InstallScanLogger = {
warn?: (message: string) => void;
@@ -109,6 +113,7 @@ export type InstallSecurityScanResult = {
blocked?: {
code?: "security_scan_blocked" | "security_scan_failed";
reason: string;
installPolicyWarning?: InstallPolicyWarningOccurrence;
};
};
@@ -821,10 +826,28 @@ async function runOperatorInstallPolicy(params: {
logPolicyResult(result);
return undefined;
}
const installPolicyWarning: InstallPolicyWarningDetails = {
targetName: params.targetName,
targetType: params.targetType,
requestMode: params.requestMode,
reason: result.warning.reason,
...(result.findings?.length ? { findings: result.findings } : {}),
};
const warningOccurrence: InstallPolicyWarningOccurrence = {
scan: {
requestKind: params.requestKind,
originType: params.origin.type,
...(params.plugin ? { pluginContentType: params.plugin.contentType } : {}),
...(params.skill ? { skillInstallId: params.skill.installId } : {}),
},
warning: installPolicyWarning,
approvalFingerprint: result.warning.approvalFingerprint,
};
if (!params.onInstallPolicyWarning) {
return {
blocked: {
code: "security_scan_blocked",
installPolicyWarning: warningOccurrence,
reason: formatInstallPolicyNotice({
decision: "warn",
findings: result.findings,
@@ -841,6 +864,7 @@ async function runOperatorInstallPolicy(params: {
targetName: params.targetName,
targetType: params.targetType,
requestMode: params.requestMode,
...warningOccurrence,
});
if (acknowledgement.status === "approved") {
const reevaluated = await evaluatePolicy();
@@ -866,6 +890,17 @@ async function runOperatorInstallPolicy(params: {
return {
blocked: {
code: "security_scan_blocked",
installPolicyWarning: {
scan: warningOccurrence.scan,
warning: {
targetName: params.targetName,
targetType: params.targetType,
requestMode: params.requestMode,
reason: reevaluated.warning.reason,
...(reevaluated.findings?.length ? { findings: reevaluated.findings } : {}),
},
approvalFingerprint: reevaluated.warning.approvalFingerprint,
},
reason: formatInstallPolicyNotice({
decision: "warn",
findings: reevaluated.findings,
@@ -883,19 +918,25 @@ async function runOperatorInstallPolicy(params: {
} else {
logPolicyResult(reevaluated);
}
// Approval covers only an unchanged warning after a fresh evaluation.
// One-shot callers still reject later or changed warnings.
return undefined;
}
if (acknowledgement.status === "unavailable") {
return {
blocked: {
code: "security_scan_blocked",
installPolicyWarning: warningOccurrence,
reason: formatInstallPolicyNotice({
decision: "warn",
findings: result.findings,
guidance: [
"The noninteractive approval was already used.",
"Review this warning and rerun interactively.",
],
guidance:
acknowledgement.reason === "approval-exhausted"
? [
"The noninteractive approval was already used.",
"Review this warning and rerun interactively.",
]
: ["This warning has not been approved.", "Review it and try again."],
reason: result.warning.reason,
targetName: params.targetName,
targetType: params.targetType,
@@ -906,6 +947,7 @@ async function runOperatorInstallPolicy(params: {
return {
blocked: {
code: "security_scan_blocked",
installPolicyWarning: warningOccurrence,
reason: "Install cancelled: the install policy warning was not approved.",
},
};
+5 -1
View File
@@ -6,7 +6,10 @@ import type {
InstallPolicySource,
} from "../security/install-policy.js";
export type { InstallSafetyOverrides } from "./install-security-scan.types.js";
import type { InstallSafetyOverrides } from "./install-security-scan.types.js";
import type {
InstallPolicyWarningOccurrence,
InstallSafetyOverrides,
} from "./install-security-scan.types.js";
type InstallScanLogger = {
warn?: (message: string) => void;
@@ -17,6 +20,7 @@ export type InstallSecurityScanResult = {
blocked?: {
code?: "security_scan_blocked" | "security_scan_failed";
reason: string;
installPolicyWarning?: InstallPolicyWarningOccurrence;
};
};
+25 -2
View File
@@ -1,7 +1,30 @@
// Defines plugin install security scan result types.
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { InstallPolicyFinding, InstallPolicyRequestKind } from "../security/install-policy.js";
export type InstallPolicyWarningAcknowledgementRequest = {
export type InstallPolicyWarningDetails = {
targetName: string;
targetType: "skill" | "plugin";
requestMode: "install" | "update";
reason: string;
findings?: InstallPolicyFinding[];
};
/** Stable policy-stage facts that scope an approval without exposing scan internals to clients. */
type InstallPolicyWarningScanIdentity = {
requestKind: InstallPolicyRequestKind;
originType: string;
pluginContentType?: "bundle" | "package" | "file" | "dependency-tree";
skillInstallId?: string;
};
export type InstallPolicyWarningOccurrence = {
scan: InstallPolicyWarningScanIdentity;
warning: InstallPolicyWarningDetails;
approvalFingerprint: string;
};
export type InstallPolicyWarningAcknowledgementRequest = InstallPolicyWarningOccurrence & {
targetName: string;
targetType: "skill" | "plugin";
requestMode: "install" | "update";
@@ -12,7 +35,7 @@ type InstallPolicyWarningAcknowledgementResult =
| { status: "declined" }
| {
status: "unavailable";
reason: "approval-exhausted";
reason: "approval-exhausted" | "warning-not-approved";
};
/** Overrides that intentionally loosen install safety policy for trusted/operator paths. */
+62
View File
@@ -0,0 +1,62 @@
import fs from "node:fs";
import path from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { installPluginDirectoryIntoExtensions } from "./install-shared.js";
import { PLUGIN_INSTALL_ERROR_CODE } from "./install-types.js";
import { createSyncSuiteTempRootTracker } from "./test-helpers/fs-fixtures.js";
describe("installPluginDirectoryIntoExtensions", () => {
const tempRoots = createSyncSuiteTempRootTracker("openclaw-install-shared");
afterAll(() => tempRoots.cleanup());
it("preserves structured warnings returned by the staged dependency scan", async () => {
const fixtureRoot = tempRoots.makeTempDir();
const sourceDir = path.join(fixtureRoot, "source");
const targetDir = path.join(fixtureRoot, "extensions", "demo");
fs.mkdirSync(sourceDir, { recursive: true });
fs.writeFileSync(path.join(sourceDir, "index.js"), "export default {};\n");
const installPolicyWarning = {
approvalFingerprint: "review-installed-dependency-tree",
scan: {
requestKind: "plugin-archive" as const,
originType: "plugin-dependency-tree",
pluginContentType: "dependency-tree" as const,
},
warning: {
targetName: "demo",
targetType: "plugin" as const,
requestMode: "install" as const,
reason: "Review the installed dependency tree",
},
};
const result = await installPluginDirectoryIntoExtensions({
sourceDir,
targetDir,
pluginId: "demo",
extensions: ["index.js"],
logger: {},
timeoutMs: 1_000,
mode: "install",
dryRun: false,
copyErrorPrefix: "failed to copy plugin",
hasDeps: false,
depsLogMessage: "Installing dependencies…",
afterInstall: async () => ({
ok: false,
error: installPolicyWarning.warning.reason,
code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED,
installPolicyWarning,
}),
});
expect(result).toEqual({
ok: false,
error: installPolicyWarning.warning.reason,
code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED,
installPolicyWarning,
});
expect(fs.existsSync(targetDir)).toBe(false);
});
});
+8 -10
View File
@@ -14,6 +14,7 @@ import {
} from "./install-transaction.js";
import {
PLUGIN_INSTALL_ERROR_CODE,
type InstallPublicationAuthority,
type InstallPluginResult,
type PackageManifest,
type PluginInstallErrorCode,
@@ -226,6 +227,9 @@ function buildBlockedInstallResult(params: {
return {
ok: false,
error: params.blocked.reason,
...(params.blocked.installPolicyWarning
? { installPolicyWarning: params.blocked.installPolicyWarning }
: {}),
...(params.blocked.code === "security_scan_failed"
? { code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED }
: params.blocked.code === "security_scan_blocked"
@@ -385,6 +389,7 @@ export async function installPluginDirectoryIntoExtensions(params: {
afterInstall?: (
installedDir: string,
) => Promise<Extract<InstallPluginResult, { ok: false }> | null>;
publicationAuthority?: InstallPublicationAuthority;
nameEncoder?: (pluginId: string) => string;
}): Promise<InstallPluginResult> {
const runtime = await loadPluginInstallRuntime();
@@ -437,12 +442,9 @@ export async function installPluginDirectoryIntoExtensions(params: {
if (!postInstallResult) {
return { ok: true as const };
}
return {
ok: false as const,
error: postInstallResult.error,
...(postInstallResult.code ? { code: postInstallResult.code } : {}),
};
return postInstallResult;
},
publicationAuthority: params.publicationAuthority,
};
const installRes = await runtime.installPackageDir(
isPluginInstallCommitDeferred(params)
@@ -450,11 +452,7 @@ export async function installPluginDirectoryIntoExtensions(params: {
: packageInstallParams,
);
if (!installRes.ok) {
return {
ok: false,
error: installRes.error,
...(installRes.code ? { code: installRes.code as PluginInstallErrorCode } : {}),
};
return installRes;
}
const result = {
+29 -12
View File
@@ -2,6 +2,7 @@ import type { NpmIntegrityDrift, NpmSpecResolution } from "../infra/install-sour
import type { InstallPolicySource } from "../security/install-policy.js";
import type { PluginInstallArtifactInspection } from "./install-artifact-inspection.js";
import type { InstallSafetyOverrides } from "./install-security-scan.js";
import type { InstallPolicyWarningOccurrence } from "./install-security-scan.types.js";
import type { PackageManifest as PluginPackageManifest, PluginManifestSetup } from "./manifest.js";
export type PluginInstallLogger = {
@@ -50,7 +51,13 @@ export type InstallPluginResult =
npmResolution?: NpmSpecResolution;
integrityDrift?: NpmIntegrityDrift;
}
| { ok: false; error: string; code?: PluginInstallErrorCode };
| {
ok: false;
error: string;
code?: PluginInstallErrorCode;
installPolicyWarning?: InstallPolicyWarningOccurrence;
npmResolution?: NpmSpecResolution;
};
export type PluginInstallFailureResult = Extract<InstallPluginResult, { ok: false }>;
@@ -67,19 +74,29 @@ export type PluginInstallPolicyRequest = {
source?: InstallPolicySource;
};
export type PackageInstallCommonParams = InstallSafetyOverrides & {
extensionsDir?: string;
npmDir?: string;
timeoutMs?: number;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
requirePluginManifest?: boolean;
allowSourceTypeScriptEntries?: boolean;
installPolicyRequest?: PluginInstallPolicyRequest;
export type InstallPublicationAuthority = {
assertCurrent: () => void;
commit: () => void;
};
export type InstallPublicationOptions = {
publicationAuthority?: InstallPublicationAuthority;
};
export type PackageInstallCommonParams = InstallSafetyOverrides &
InstallPublicationOptions & {
extensionsDir?: string;
npmDir?: string;
timeoutMs?: number;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
requirePluginManifest?: boolean;
allowSourceTypeScriptEntries?: boolean;
installPolicyRequest?: PluginInstallPolicyRequest;
};
export type InternalPackageInstallCommonParams = PackageInstallCommonParams & {
onEffectiveMode?: (mode: "install" | "update") => void;
};
+78 -14
View File
@@ -79,35 +79,47 @@ async function readJson<T>(filePath: string): Promise<T> {
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
}
const installedPackageTreePolicySource = `
function installPolicySource(
decision: "block" | "warn",
sourcePathKind: "directory" | "file" = "directory",
): string {
const subject = sourcePathKind === "directory" ? "installed package tree" : "npm metadata";
const reason = `${decision === "block" ? "blocked" : "review"} ${subject}`;
return `
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { input += chunk; });
process.stdin.on("end", () => {
const request = JSON.parse(input);
if (request.sourcePathKind === "directory") {
if (request.sourcePathKind === "${sourcePathKind}") {
process.stdout.write(JSON.stringify({
protocolVersion: 1,
decision: "block",
reason: "blocked installed package tree",
decision: "${decision}",
reason: "${reason}",
}));
return;
}
process.stdout.write(JSON.stringify({ protocolVersion: 1, decision: "allow" }));
});
`;
}
async function createInstalledPackageTreePolicyExec(rootDir: string) {
async function createInstalledPackageTreePolicyExec(
rootDir: string,
decision: "block" | "warn" = "block",
sourcePathKind: "directory" | "file" = "directory",
) {
const policySource = installPolicySource(decision, sourcePathKind);
if (process.platform === "win32") {
return { command: process.execPath, args: ["-e", installedPackageTreePolicySource] };
return { command: process.execPath, args: ["-e", policySource] };
}
const command = path.join(rootDir, "install-policy.cjs");
await fs.writeFile(command, `#!${process.execPath}\n${installedPackageTreePolicySource}`, "utf8");
await fs.writeFile(command, `#!${process.execPath}\n${policySource}`, "utf8");
await fs.chmod(command, 0o700);
return { command, args: [] };
}
function configWithInstalledPackageTreeBlockPolicy(exec: {
function configWithInstalledPackageTreePolicy(exec: {
command: string;
args: string[];
}): OpenClawConfig {
@@ -742,15 +754,14 @@ describe("installPluginFromNpmSpec e2e", () => {
]);
const result = await installNpmPlugin({
config: configWithInstalledPackageTreeBlockPolicy(policyExec),
config: configWithInstalledPackageTreePolicy(policyExec),
spec: `${blockedPlugin}@1.0.0`,
npmRoot,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain("blocked by install policy: blocked installed package tree");
expect(result.error).toContain("blocked installed package tree");
}
const projectRoot = pluginNpmProjectRoot(npmRoot, blockedPlugin);
try {
@@ -772,6 +783,60 @@ describe("installPluginFromNpmSpec e2e", () => {
).rejects.toHaveProperty("code", "ENOENT");
});
it("preserves npm resolution on installed package policy warnings", async () => {
const { rootDir, npmRoot } = await makeInstallFixture("npm-plugin-policy-warning-e2e");
const policyExec = await createInstalledPackageTreePolicyExec(rootDir, "warn");
const packageName = uniquePackageName("warning-plugin");
const packageVersion = await packPlugin({ packageName, rootDir });
await useStaticRegistry([
{ packageName, latest: packageVersion.version, versions: [packageVersion] },
]);
const result = await installNpmPlugin({
config: configWithInstalledPackageTreePolicy(policyExec),
spec: `${packageName}@latest`,
npmRoot,
});
if (result.ok) {
throw new Error("expected installed package policy warning");
}
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.installPolicyWarning?.warning.reason).toBe("review installed package tree");
expect(result.npmResolution).toMatchObject({
resolvedSpec: `${packageName}@${packageVersion.version}`,
integrity: packageVersion.integrity,
});
await expect(
fs.lstat(path.join(pluginNpmProjectRoot(npmRoot, packageName), "node_modules", packageName)),
).rejects.toHaveProperty("code", "ENOENT");
});
it("preserves npm resolution on preflight policy warnings", async () => {
const { rootDir, npmRoot } = await makeInstallFixture("npm-plugin-preflight-warning-e2e");
const policyExec = await createInstalledPackageTreePolicyExec(rootDir, "warn", "file");
const packageName = uniquePackageName("preflight-warning-plugin");
const packageVersion = await packPlugin({ packageName, rootDir });
await useStaticRegistry([
{ packageName, latest: packageVersion.version, versions: [packageVersion] },
]);
const result = await installNpmPlugin({
config: configWithInstalledPackageTreePolicy(policyExec),
spec: `${packageName}@latest`,
npmRoot,
});
if (result.ok) {
throw new Error("expected preflight policy warning");
}
expect(result.installPolicyWarning?.warning.reason).toBe("review npm metadata");
expect(result.npmResolution).toMatchObject({
resolvedSpec: `${packageName}@${packageVersion.version}`,
integrity: packageVersion.integrity,
});
});
it("falls back to the legacy npm peer mode inside the plugin project when npm cannot plan third-party peers", async () => {
const { rootDir, npmRoot } = await makeInstallFixture("npm-plugin-peer-plan-fallback-e2e");
const blockedPlugin = uniquePackageName("missing-peer-plugin");
@@ -831,15 +896,14 @@ describe("installPluginFromNpmSpec e2e", () => {
});
const result = await installNpmPlugin({
config: configWithInstalledPackageTreeBlockPolicy(policyExec),
config: configWithInstalledPackageTreePolicy(policyExec),
spec: `${blockedPlugin}@1.0.0`,
npmRoot,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain("blocked by install policy: blocked installed package tree");
expect(result.error).toContain("blocked installed package tree");
}
const rootManifest = await readJson<{
dependencies?: Record<string, string>;
+22
View File
@@ -3612,6 +3612,28 @@ describe("installPluginFromNpmSpec", () => {
});
});
it("fails closed when a pinned npm retry cannot resolve fresh integrity metadata", async () => {
mockNpmViewMetadataResult(runCommandWithTimeoutMock, {
name: "@openclaw/voice-call",
version: "0.0.1",
shasum: "reviewedshasum",
});
const onIntegrityDrift = vi.fn(async () => true);
const result = await installPluginFromNpmSpec({
spec: "@openclaw/voice-call@0.0.1",
expectedIntegrity: "sha512-reviewed",
onIntegrityDrift,
});
expect(result).toEqual({
ok: false,
error: "aborted: npm package integrity metadata missing for @openclaw/voice-call@0.0.1",
});
expect(onIntegrityDrift).not.toHaveBeenCalled();
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
});
it("classifies npm package-not-found errors with a stable error code", async () => {
runCommandWithTimeoutMock.mockResolvedValue({
code: 1,
+86
View File
@@ -1040,6 +1040,92 @@ describe("installPluginFromArchive", () => {
expect(updatedVersion).toBe("0.0.2");
});
it("does not publish a new archive install after its approval expires", async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const extensionsDir = path.join(stateDir, "extensions");
const archivePath = await ensureDynamicArchiveTemplate({
outName: "archive-publication-authority.tgz",
packageJson: {
name: "archive-publication-authority",
version: "1.0.0",
openclaw: { extensions: ["./dist/index.js"] },
},
withDistIndex: true,
});
const commit = vi.fn(() => {
throw new Error("approval expired");
});
const result = await installPluginFromArchive({
archivePath,
extensionsDir,
publicationAuthority: { assertCurrent: vi.fn(), commit },
});
expect(result).toEqual({
ok: false,
error: "failed to copy plugin: Error: approval expired",
});
expect(commit).toHaveBeenCalledOnce();
expect(
fs.existsSync(resolvePluginInstallDir("archive-publication-authority", extensionsDir)),
).toBe(false);
});
it("restores an archive update when its approval expires during publication", async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const extensionsDir = path.join(stateDir, "extensions");
const archiveV1 = await ensureDynamicArchiveTemplate({
outName: "archive-publication-update-1.tgz",
packageJson: {
name: "archive-publication-update",
version: "1.0.0",
openclaw: { extensions: ["./dist/index.js"] },
},
withDistIndex: true,
});
const archiveV2 = await ensureDynamicArchiveTemplate({
outName: "archive-publication-update-2.tgz",
packageJson: {
name: "archive-publication-update",
version: "2.0.0",
openclaw: { extensions: ["./dist/index.js"] },
},
withDistIndex: true,
});
const first = await installPluginFromArchive({ archivePath: archiveV1, extensionsDir });
expect(first.ok).toBe(true);
if (!first.ok) {
return;
}
let approvalCurrent = true;
const assertCurrent = vi.fn(() => {
approvalCurrent = false;
});
const commit = vi.fn(() => {
if (!approvalCurrent) {
throw new Error("approval expired");
}
});
const update = await installPluginFromArchive({
archivePath: archiveV2,
extensionsDir,
mode: "update",
publicationAuthority: { assertCurrent, commit },
});
expect(update).toEqual({
ok: false,
error: "failed to copy plugin: Error: approval expired",
});
expect(assertCurrent).toHaveBeenCalledOnce();
expect(commit).toHaveBeenCalledOnce();
expect(
JSON.parse(fs.readFileSync(path.join(first.targetDir, "package.json"), "utf8")),
).toMatchObject({ version: "1.0.0" });
});
it("emits effective install mode when requested archive update creates a new target", async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const extensionsDir = path.join(stateDir, "extensions");
@@ -1,4 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
emptyMetadataSnapshot,
metadataSnapshot,
} from "./test-helpers/management-service-fixtures.js";
const mocks = vi.hoisted(() => ({
metadata: vi.fn(),
@@ -19,64 +23,6 @@ vi.mock("./official-external-plugin-catalog.js", async (importOriginal) => ({
const { clearManagedPluginOfficialCatalogCache, listManagedPlugins, resolveManagedPluginIconUrl } =
await import("./management-service.js");
function metadataSnapshot(params: {
id?: string;
name?: string;
origin?: "bundled" | "global";
packageName?: string | null;
installRecord?: Record<string, unknown>;
featured?: boolean;
description?: string;
icon?: string;
}) {
const id = params.id ?? "workboard";
const packageName =
params.packageName === null ? undefined : (params.packageName ?? `@openclaw/${id}`);
const manifest = {
id,
name: params.name ?? "Workboard",
description: params.description ?? "Coordinate agent work in a shared board.",
catalog: { featured: params.featured ?? true, order: 10 },
...(params.icon ? { icon: params.icon } : {}),
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
origin: params.origin ?? "bundled",
rootDir: `/tmp/${id}`,
source: `/tmp/${id}/index.ts`,
manifestPath: `/tmp/${id}/openclaw.plugin.json`,
};
return {
index: {
plugins: [
{
pluginId: id,
...(packageName ? { packageName } : {}),
origin: params.origin ?? "bundled",
enabled: true,
},
],
installRecords: params.installRecord ? { [id]: params.installRecord } : {},
},
byPluginId: new Map([[id, manifest]]),
plugins: [manifest],
diagnostics: [],
normalizePluginId: (pluginId: string) => pluginId,
};
}
function emptyMetadataSnapshot() {
return {
index: { plugins: [], installRecords: {} },
byPluginId: new Map(),
plugins: [],
diagnostics: [],
normalizePluginId: (pluginId: string) => pluginId,
};
}
function hostedCatalog(entries: unknown[]) {
return {
source: "hosted",
@@ -0,0 +1,502 @@
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
InstallSafetyOverrides,
InstallPolicyWarningAcknowledgementRequest,
InstallPolicyWarningOccurrence,
} from "./install-security-scan.types.js";
import {
expectOneShotInstallPolicyWarningAcknowledgement,
officialDiffsWarningRequest,
} from "./test-helpers/install-policy-warning.js";
import { metadataSnapshot } from "./test-helpers/management-service-fixtures.js";
const mocks = vi.hoisted(() => ({
applyUninstall: vi.fn(),
clawhubInstall: vi.fn(),
installRecords: vi.fn(),
metadata: vi.fn(),
npmInstall: vi.fn(),
officialCatalog: vi.fn(),
persistInstall: vi.fn(),
preflight: vi.fn(),
providerAuthChoices: vi.fn(),
readConfig: vi.fn(),
recommendedInstalls: vi.fn(),
refreshRegistry: vi.fn(),
replaceConfig: vi.fn(),
selectWriteOptions: vi.fn((writeOptions: unknown) => writeOptions),
slotSelection: vi.fn((config: unknown): { config: unknown; warnings: string[] } => ({
config,
warnings: [],
})),
}));
vi.mock("../config/config.js", () => ({
assertConfigWriteAllowedInCurrentMode: () => undefined,
readConfigFileSnapshotForWrite: () => mocks.readConfig(),
replaceConfigFile: (params: unknown) => mocks.replaceConfig(params),
}));
vi.mock("./install-persistence.js", () => ({
persistPluginInstall: (...args: unknown[]) => mocks.persistInstall(...args),
resolveInstallConfigMutationPreflights: (...args: unknown[]) => mocks.preflight(...args),
selectInstallMutationWriteOptions: (writeOptions: unknown) =>
mocks.selectWriteOptions(writeOptions),
}));
vi.mock("./slot-selection.js", () => ({
applySlotSelectionForPlugin: (config: unknown) => mocks.slotSelection(config),
}));
vi.mock("./registry-refresh.js", () => ({
refreshPluginRegistryAfterConfigMutation: (...args: unknown[]) => mocks.refreshRegistry(...args),
}));
vi.mock("./plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: (...args: unknown[]) => mocks.metadata(...args),
resolvePluginMetadataSnapshot: (...args: unknown[]) => mocks.metadata(...args),
}));
vi.mock("./clawhub.js", () => ({
installPluginFromClawHub: (...args: unknown[]) => mocks.clawhubInstall(...args),
}));
vi.mock("./install.js", () => ({
installPluginFromNpmSpec: (...args: unknown[]) => mocks.npmInstall(...args),
}));
vi.mock("./installed-plugin-index-records.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./installed-plugin-index-records.js")>()),
loadInstalledPluginIndexInstallRecords: (...args: unknown[]) => mocks.installRecords(...args),
}));
vi.mock("./uninstall.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./uninstall.js")>()),
applyPluginUninstallDirectoryRemoval: (...args: unknown[]) => mocks.applyUninstall(...args),
}));
vi.mock("./official-external-plugin-catalog.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./official-external-plugin-catalog.js")>()),
loadConfiguredHostedOfficialExternalPluginCatalogEntries: (...args: unknown[]) =>
mocks.officialCatalog(...args),
}));
vi.mock("./provider-auth-choices.js", () => ({
resolveManifestProviderAuthChoices: (...args: unknown[]) => mocks.providerAuthChoices(...args),
}));
vi.mock("./recommended-tool-installs.js", () => ({
listRecommendedToolInstalls: (...args: unknown[]) => mocks.recommendedInstalls(...args),
}));
const {
clearManagedPluginOfficialCatalogCache,
installManagedPlugin,
ManagedPluginLifecycleError,
} = await import("./management-service.js");
function configSnapshot() {
return {
snapshot: {
valid: true,
parsed: {},
path: "/tmp/openclaw.json",
sourceConfig: {},
hash: "base-hash",
},
writeOptions: {
expectedConfigPath: "/tmp/openclaw.json",
includeFileHashesForWrite: { "/tmp/plugins.json": "include-hash" },
includeFileTargetsForWrite: { "/tmp/plugins.json": "/tmp/plugins.json" },
},
};
}
function mockHostedOfficialCatalog(entries: unknown[]) {
mocks.officialCatalog.mockResolvedValue({
source: "hosted",
entries,
feed: { schemaVersion: 1, id: "test", generatedAt: "now", sequence: 1, entries: [] },
metadata: { url: "https://clawhub.ai/feed", status: 200, checksum: "hash" },
});
}
function mockClawHubInstall(pluginId: string, packageName: string) {
mocks.clawhubInstall.mockResolvedValue({
ok: true,
pluginId,
targetDir: `/tmp/extensions/${pluginId}`,
extensions: ["index.js"],
packageName,
clawhub: {
source: "clawhub",
clawhubUrl: "https://clawhub.ai",
clawhubPackage: packageName,
clawhubFamily: "code-plugin",
},
});
}
const hostedFeedDiffsEntry = {
id: "@openclaw/diffs",
title: "Diffs",
state: "available",
featured: true,
publisher: { id: "openclaw", trust: "official" },
install: {
candidates: [
{
sourceRef: "public-clawhub",
package: "@openclaw/diffs",
version: "2026.6.11",
integrity: `sha256:${"a".repeat(64)}`,
},
],
},
};
describe("plugin management install-policy acknowledgements", () => {
beforeEach(() => {
clearManagedPluginOfficialCatalogCache();
for (const mock of Object.values(mocks)) {
if (typeof mock === "function" && "mockReset" in mock) {
mock.mockReset();
}
}
mocks.selectWriteOptions.mockImplementation((writeOptions) => writeOptions);
mocks.preflight.mockReturnValue({
hookMutation: { mode: "allowed" },
pluginMutation: { mode: "allowed" },
});
mocks.slotSelection.mockImplementation((config) => ({ config, warnings: [] }));
mocks.installRecords.mockResolvedValue({});
mocks.applyUninstall.mockResolvedValue({ directoryRemoved: true, warnings: [] });
mocks.providerAuthChoices.mockReturnValue([]);
mocks.recommendedInstalls.mockReturnValue([]);
mockHostedOfficialCatalog([]);
});
it("threads hosted ClawHub candidate integrity into official installs", async () => {
mocks.readConfig.mockResolvedValue(configSnapshot());
mockHostedOfficialCatalog([hostedFeedDiffsEntry]);
mockClawHubInstall("diffs", "@openclaw/diffs");
mocks.persistInstall.mockResolvedValue({});
mocks.metadata.mockReturnValue(
metadataSnapshot({ enabled: true, id: "diffs", name: "Diffs", origin: "global" }),
);
await installManagedPlugin({
request: {
...officialDiffsWarningRequest,
installPolicyWarningAcknowledgement: {
...officialDiffsWarningRequest.installPolicyWarningAcknowledgement,
warnings: [...officialDiffsWarningRequest.installPolicyWarningAcknowledgement.warnings],
},
},
env: {},
});
expect(mocks.clawhubInstall).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:@openclaw/diffs@2026.6.11",
expectedPluginId: "diffs",
expectedIntegrity: `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`,
publicationAuthority:
officialDiffsWarningRequest.installPolicyWarningAcknowledgement.publicationAuthority,
}),
);
expect(mocks.persistInstall).toHaveBeenCalledWith(
expect.objectContaining({
publicationAuthority:
officialDiffsWarningRequest.installPolicyWarningAcknowledgement.publicationAuthority,
}),
);
await expectOneShotInstallPolicyWarningAcknowledgement(mocks.clawhubInstall);
});
it("acknowledges the same warning only for the same target and scan stage once", async () => {
mocks.readConfig.mockResolvedValue(configSnapshot());
mockHostedOfficialCatalog([hostedFeedDiffsEntry]);
mockClawHubInstall("diffs", "@openclaw/diffs");
mocks.persistInstall.mockResolvedValue({});
mocks.metadata.mockReturnValue(
metadataSnapshot({ enabled: true, id: "diffs", name: "Diffs", origin: "global" }),
);
const packageWarning: InstallPolicyWarningOccurrence = expectDefined(
officialDiffsWarningRequest.installPolicyWarningAcknowledgement.warnings[0],
"first approved warning",
);
const dependencyWarning: InstallPolicyWarningOccurrence = {
approvalFingerprint: packageWarning.approvalFingerprint,
scan: {
...packageWarning.scan,
originType: "plugin-dependency-tree",
pluginContentType: "dependency-tree",
},
warning: packageWarning.warning,
};
const changedFingerprintWarning: InstallPolicyWarningOccurrence = {
...packageWarning,
approvalFingerprint: "changed-warning-approval",
};
const wrongTargetWarning: InstallPolicyWarningOccurrence = {
...packageWarning,
warning: { ...packageWarning.warning, targetName: "other-plugin" },
};
const restagedPackageWarning: InstallPolicyWarningOccurrence = {
...packageWarning,
warning: {
...packageWarning.warning,
reason: "Review this warning in /tmp/openclaw-stage-b",
},
};
await installManagedPlugin({
request: {
...officialDiffsWarningRequest,
installPolicyWarningAcknowledgement: {
...officialDiffsWarningRequest.installPolicyWarningAcknowledgement,
warnings: [packageWarning],
},
},
env: {},
});
const call = expectDefined(mocks.clawhubInstall.mock.calls[0], "clawhub install call");
const acknowledge = expectDefined(
(
call[0] as {
onInstallPolicyWarning?: InstallSafetyOverrides["onInstallPolicyWarning"];
}
).onInstallPolicyWarning,
"install-policy acknowledgement callback",
);
expect(
await acknowledge({
targetName: changedFingerprintWarning.warning.targetName,
targetType: changedFingerprintWarning.warning.targetType,
requestMode: changedFingerprintWarning.warning.requestMode,
...changedFingerprintWarning,
}),
).toEqual({ status: "unavailable", reason: "warning-not-approved" });
expect(
await acknowledge({
targetName: dependencyWarning.warning.targetName,
targetType: dependencyWarning.warning.targetType,
requestMode: dependencyWarning.warning.requestMode,
...dependencyWarning,
}),
).toEqual({ status: "unavailable", reason: "warning-not-approved" });
expect(
await acknowledge({
targetName: wrongTargetWarning.warning.targetName,
targetType: wrongTargetWarning.warning.targetType,
requestMode: wrongTargetWarning.warning.requestMode,
...wrongTargetWarning,
}),
).toEqual({ status: "unavailable", reason: "warning-not-approved" });
const packageRequest: InstallPolicyWarningAcknowledgementRequest = {
targetName: restagedPackageWarning.warning.targetName,
targetType: restagedPackageWarning.warning.targetType,
requestMode: restagedPackageWarning.warning.requestMode,
...restagedPackageWarning,
};
expect(await acknowledge(packageRequest)).toEqual({ status: "approved" });
expect(await acknowledge(packageRequest)).toEqual({
status: "unavailable",
reason: "warning-not-approved",
});
});
it("pins reviewed npm warnings to the first resolved version and integrity", async () => {
const warning: InstallPolicyWarningOccurrence = {
approvalFingerprint: "review-npm-package",
scan: {
requestKind: "plugin-npm",
originType: "plugin-npm",
pluginContentType: "package",
},
warning: {
targetName: "npm-demo",
targetType: "plugin",
requestMode: "install",
reason: "Review this npm package",
},
};
const npmResolution = {
name: "@openclaw/npm-demo",
version: "1.2.3",
resolvedSpec: "@openclaw/npm-demo@1.2.3",
integrity: "sha512-reviewed",
resolvedAt: "2026-08-10T00:00:00.000Z",
};
mocks.readConfig.mockResolvedValue(configSnapshot());
mockHostedOfficialCatalog([
{
name: "@openclaw/npm-demo",
openclaw: {
plugin: { id: "npm-demo" },
install: { npmSpec: "@openclaw/npm-demo", defaultChoice: "npm" },
},
},
]);
mocks.npmInstall
.mockResolvedValueOnce({
ok: false,
error: warning.warning.reason,
installPolicyWarning: warning,
npmResolution,
})
.mockResolvedValueOnce({ ok: false, error: "stop after inspecting the pinned retry" });
let firstFailure: unknown;
try {
await installManagedPlugin({
request: { source: "official", pluginId: "npm-demo" },
env: {},
});
} catch (error) {
firstFailure = error;
}
expect(firstFailure).toBeInstanceOf(ManagedPluginLifecycleError);
if (!(firstFailure instanceof ManagedPluginLifecycleError)) {
throw new Error("expected managed plugin lifecycle failure");
}
const resolvedRequest = expectDefined(
firstFailure.installPolicyResolvedRequest,
"pinned npm install request",
);
expect(resolvedRequest).toMatchObject({
source: "official",
spec: npmResolution.resolvedSpec,
installPolicyRequestedSpecifier: "@openclaw/npm-demo",
expectedIntegrity: npmResolution.integrity,
});
await expect(
installManagedPlugin({
request: {
source: "official",
pluginId: "npm-demo",
installPolicyWarningAcknowledgement: {
publicationAuthority: {
assertCurrent: () => {},
commit: () => {},
},
warnings: [warning],
resolvedRequest,
},
},
env: {},
}),
).rejects.toThrow("stop after inspecting the pinned retry");
expect(mocks.npmInstall).toHaveBeenLastCalledWith(
expect.objectContaining({
spec: npmResolution.resolvedSpec,
installPolicyRequestedSpecifier: "@openclaw/npm-demo",
expectedIntegrity: npmResolution.integrity,
}),
);
});
it("keeps npm warnings terminal when immutable resolution metadata is incomplete", async () => {
const warning: InstallPolicyWarningOccurrence = {
approvalFingerprint: "review-npm-package",
scan: {
requestKind: "plugin-npm",
originType: "plugin-npm",
pluginContentType: "package",
},
warning: {
targetName: "npm-demo",
targetType: "plugin",
requestMode: "install",
reason: "Review this npm package",
},
};
mocks.readConfig.mockResolvedValue(configSnapshot());
mockHostedOfficialCatalog([
{
name: "@openclaw/npm-demo",
openclaw: {
plugin: { id: "npm-demo" },
install: { npmSpec: "@openclaw/npm-demo", defaultChoice: "npm" },
},
},
]);
mocks.npmInstall.mockResolvedValue({
ok: false,
error: warning.warning.reason,
installPolicyWarning: warning,
npmResolution: {
name: "@openclaw/npm-demo",
version: "1.2.3",
resolvedSpec: "@openclaw/npm-demo@1.2.3",
resolvedAt: "2026-08-10T00:00:00.000Z",
},
});
let failure: unknown;
try {
await installManagedPlugin({
request: { source: "official", pluginId: "npm-demo" },
env: {},
});
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(ManagedPluginLifecycleError);
if (!(failure instanceof ManagedPluginLifecycleError)) {
throw new Error("expected managed plugin lifecycle failure");
}
expect(failure.installPolicyWarning).toEqual(warning);
expect(failure.installPolicyResolvedRequest).toBeUndefined();
expect(failure.message).toContain("immutable artifact resolution metadata");
});
it("pins reviewed ClawHub warnings to the downloaded archive integrity", async () => {
const warning: InstallPolicyWarningOccurrence = {
approvalFingerprint: "review-clawhub-package",
scan: {
requestKind: "plugin-archive",
originType: "plugin-package",
pluginContentType: "package",
},
warning: {
targetName: "demo",
targetType: "plugin",
requestMode: "install",
reason: "Review this ClawHub package",
},
};
mocks.readConfig.mockResolvedValue(configSnapshot());
mocks.clawhubInstall.mockResolvedValue({
ok: false,
error: warning.warning.reason,
installPolicyWarning: warning,
version: "1.2.3",
integrity: "sha256-reviewed",
});
let failure: unknown;
try {
await installManagedPlugin({
request: { source: "clawhub", packageName: "community/demo" },
env: {},
});
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(ManagedPluginLifecycleError);
if (!(failure instanceof ManagedPluginLifecycleError)) {
throw new Error("expected managed plugin lifecycle failure");
}
expect(failure.installPolicyResolvedRequest).toMatchObject({
source: "clawhub",
spec: "clawhub:community/demo@1.2.3",
installPolicyRequestedSpecifier: "clawhub:community/demo",
expectedIntegrity: "sha256-reviewed",
});
});
});
+5 -83
View File
@@ -1,5 +1,9 @@
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
emptyMetadataSnapshot,
metadataSnapshot,
} from "./test-helpers/management-service-fixtures.js";
const mocks = vi.hoisted(() => ({
applyUninstall: vi.fn(),
@@ -124,66 +128,6 @@ function configSnapshot(config: Record<string, unknown> = {}) {
};
}
function metadataSnapshot(params: {
enabled: boolean;
id?: string;
name?: string;
origin?: "bundled" | "global";
installRecord?: Record<string, unknown>;
icon?: string;
}) {
const id = params.id ?? "workboard";
const origin = params.origin ?? "bundled";
const installRecord =
params.installRecord ??
(origin === "global" ? { source: "path", installPath: `/tmp/${id}` } : undefined);
const manifest = {
id,
name: params.name ?? "Workboard",
description: "Coordinate agent work in a shared board.",
catalog: { featured: true, order: 10 },
...(params.icon ? { icon: params.icon } : {}),
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
origin,
rootDir: `/tmp/${id}`,
source: `/tmp/${id}/index.ts`,
manifestPath: `/tmp/${id}/openclaw.plugin.json`,
};
return {
index: {
plugins: [
{
pluginId: id,
...(origin === "global" ? { installOwner: id } : {}),
packageName: `@openclaw/${id}`,
origin,
enabled: params.enabled,
rootDir: `/tmp/${id}`,
},
],
installRecords: installRecord ? { [id]: installRecord } : {},
},
byPluginId: new Map([[id, manifest]]),
plugins: [manifest],
diagnostics: [],
normalizePluginId: (pluginId: string) => pluginId,
};
}
function emptyMetadataSnapshot() {
return {
index: { plugins: [], installRecords: {} },
byPluginId: new Map(),
plugins: [],
diagnostics: [],
normalizePluginId: (pluginId: string) => pluginId,
};
}
function mockHostedOfficialCatalog(entries: unknown[]) {
mocks.officialCatalog.mockResolvedValue({
source: "hosted",
@@ -292,6 +236,7 @@ describe("plugin management service", () => {
expect.objectContaining({
id: "diffs",
name: "Diffs",
packageName: "@openclaw/diffs",
installed: false,
featured: true,
order: 40,
@@ -779,29 +724,6 @@ describe("plugin management service", () => {
);
});
it("threads hosted ClawHub candidate integrity into official installs", async () => {
mocks.readConfig.mockResolvedValue(configSnapshot());
mockHostedOfficialCatalog([hostedFeedDiffsEntry]);
mockClawHubInstall("diffs", "@openclaw/diffs");
mocks.persistInstall.mockResolvedValue({});
mocks.metadata.mockReturnValue(
metadataSnapshot({ enabled: true, id: "diffs", name: "Diffs", origin: "global" }),
);
await installManagedPlugin({
request: { source: "official", pluginId: "diffs" },
env: {},
});
expect(mocks.clawhubInstall).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:@openclaw/diffs@2026.6.11",
expectedPluginId: "diffs",
expectedIntegrity: `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`,
}),
);
});
it("removes only the newly installed managed target after persistence conflicts", async () => {
const env = { HOME: "/tmp/openclaw-managed-install-conflict-home" };
const conflict = new Error("config changed during plugin install");
+178 -21
View File
@@ -1,5 +1,6 @@
// Structured plugin catalog and lifecycle operations shared by Gateway-facing surfaces.
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { asSafeIntegerInRange } from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
@@ -42,7 +43,8 @@ import {
} from "./install-persistence.js";
import { commitPluginInstallRecordsWithConfig } from "./install-record-commit.js";
import type { InstallSafetyOverrides } from "./install-security-scan.js";
import type { PluginInstallLogger } from "./install-types.js";
import type { InstallPolicyWarningOccurrence } from "./install-security-scan.types.js";
import type { InstallPublicationAuthority, PluginInstallLogger } from "./install-types.js";
import {
installPluginFromNpmPackArchive,
installPluginFromNpmSpec,
@@ -123,14 +125,25 @@ type ManagedPluginCatalog = {
mutationAllowed: boolean;
};
type ManagedPluginInstallRequest =
export type ManagedPluginInstallPolicyAcknowledgement = {
warnings: InstallPolicyWarningOccurrence[];
resolvedRequest: ManagedPluginSourceInstallRequest;
publicationAuthority: InstallPublicationAuthority;
};
export type ManagedPluginInstallRequest =
| {
source: "clawhub";
packageName: string;
version?: string;
acknowledgeClawHubRisk?: boolean;
installPolicyWarningAcknowledgement?: ManagedPluginInstallPolicyAcknowledgement;
}
| { source: "official"; pluginId: string };
| {
source: "official";
pluginId: string;
installPolicyWarningAcknowledgement?: ManagedPluginInstallPolicyAcknowledgement;
};
export type ManagedPluginSourceInstallRequest =
| {
@@ -150,6 +163,7 @@ export type ManagedPluginSourceInstallRequest =
| {
source: "clawhub";
spec: string;
installPolicyRequestedSpecifier?: string;
mode?: "install" | "update";
expectedPluginId?: string;
expectedIntegrity?: string;
@@ -165,6 +179,7 @@ export type ManagedPluginSourceInstallRequest =
| {
source: "official";
spec: string;
installPolicyRequestedSpecifier?: string;
pluginId: string;
expectedIntegrity?: string;
mode: "install" | "update";
@@ -173,6 +188,7 @@ export type ManagedPluginSourceInstallRequest =
| {
source: "npm";
spec: string;
installPolicyRequestedSpecifier?: string;
mode: "install" | "update";
pin?: boolean;
expectedPluginId?: string;
@@ -192,10 +208,28 @@ type ManagedPluginSourceInstallResult =
npmResolution?: NpmSpecResolution;
clawhub?: ClawHubPluginInstallRecordFields;
}
| { ok: false; error: string; code?: string; version?: string; warning?: string };
| {
ok: false;
error: string;
code?: string;
integrity?: string;
version?: string;
warning?: string;
installPolicyWarning?: InstallPolicyWarningOccurrence;
npmResolution?: NpmSpecResolution;
};
type SourceInstallerResult =
| { ok: false; error: string; code?: string; version?: string; warning?: string }
| {
ok: false;
error: string;
code?: string;
integrity?: string;
version?: string;
warning?: string;
installPolicyWarning?: InstallPolicyWarningOccurrence;
npmResolution?: NpmSpecResolution;
}
| {
ok: true;
pluginId: string;
@@ -209,6 +243,9 @@ export class ManagedPluginLifecycleError extends Error {
readonly code?: string;
readonly version?: string;
readonly warning?: string;
readonly installPolicyWarning?: InstallPolicyWarningOccurrence;
readonly installPolicyAcknowledgedWarnings?: InstallPolicyWarningOccurrence[];
readonly installPolicyResolvedRequest?: ManagedPluginSourceInstallRequest;
constructor(
message: string,
@@ -217,6 +254,9 @@ export class ManagedPluginLifecycleError extends Error {
code?: string;
version?: string;
warning?: string;
installPolicyWarning?: InstallPolicyWarningOccurrence;
installPolicyAcknowledgedWarnings?: InstallPolicyWarningOccurrence[];
installPolicyResolvedRequest?: ManagedPluginSourceInstallRequest;
cause?: unknown;
},
) {
@@ -226,6 +266,9 @@ export class ManagedPluginLifecycleError extends Error {
this.code = details?.code;
this.version = details?.version;
this.warning = details?.warning;
this.installPolicyWarning = details?.installPolicyWarning;
this.installPolicyAcknowledgedWarnings = details?.installPolicyAcknowledgedWarnings;
this.installPolicyResolvedRequest = details?.installPolicyResolvedRequest;
}
}
@@ -844,6 +887,9 @@ export async function listManagedPlugins(params: {
}
const kind = normalizeKinds(entry.kind);
const install = resolveCatalogInstallAction({ entry, pluginId });
const clawhubPackageName = resolveCatalogPackageSourceIdentities(entry).find(
(identity) => identity.source === "clawhub",
)?.packageName;
const description = normalizeOptionalString(entry.description);
const version = normalizeOptionalString(entry.version);
const featuredAt =
@@ -851,6 +897,7 @@ export async function listManagedPlugins(params: {
plugins.push({
id: pluginId,
name: resolveOfficialExternalPluginLabel(entry),
...(clawhubPackageName ? { packageName: clawhubPackageName } : {}),
...(description ? { description } : {}),
...(version ? { version } : {}),
...(kind ? { kind } : {}),
@@ -981,24 +1028,84 @@ function buildClawHubSpec(packageName: string, version?: string): string {
return `clawhub:${packageName}${version ? `@${version}` : ""}`;
}
function throwInstallFailure(result: {
error: string;
code?: string;
version?: string;
warning?: string;
}): never {
function pinInstallPolicyResolvedRequest(
request: ManagedPluginSourceInstallRequest,
version: string | undefined,
integrity: string | undefined,
npmResolution: NpmSpecResolution | undefined,
): ManagedPluginSourceInstallRequest | undefined {
if (request.source === "official" || request.source === "npm") {
if (!npmResolution?.resolvedSpec || !npmResolution.integrity) {
return undefined;
}
return {
...request,
spec: npmResolution.resolvedSpec,
installPolicyRequestedSpecifier: request.installPolicyRequestedSpecifier ?? request.spec,
expectedIntegrity: npmResolution.integrity,
};
}
if (request.source !== "clawhub") {
return request;
}
if (!version || !integrity) {
return undefined;
}
const parsed = parseClawHubPluginSpec(request.spec);
return parsed
? {
...request,
spec: buildClawHubSpec(parsed.name, version),
installPolicyRequestedSpecifier: request.installPolicyRequestedSpecifier ?? request.spec,
expectedIntegrity: integrity,
}
: undefined;
}
function throwInstallFailure(
result: {
error: string;
code?: string;
integrity?: string;
version?: string;
warning?: string;
installPolicyWarning?: InstallPolicyWarningOccurrence;
npmResolution?: NpmSpecResolution;
},
resolvedRequest?: ManagedPluginSourceInstallRequest,
installPolicyAcknowledgedWarnings?: InstallPolicyWarningOccurrence[],
): never {
const installPolicyResolvedRequest =
result.installPolicyWarning && resolvedRequest
? pinInstallPolicyResolvedRequest(
resolvedRequest,
result.version,
result.integrity,
result.npmResolution,
)
: undefined;
const resolutionUnavailable =
result.installPolicyWarning && resolvedRequest && !installPolicyResolvedRequest;
const unavailable =
!result.code ||
result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_UNAVAILABLE ||
result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_DOWNLOAD_UNAVAILABLE ||
result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE;
throw new ManagedPluginLifecycleError(result.error, {
kind: unavailable ? "unavailable" : "invalid-request",
code: result.code,
version: result.version,
warning: result.warning,
cause: result,
});
throw new ManagedPluginLifecycleError(
resolutionUnavailable
? `${result.error}\nOpenClaw could not bind this approval to immutable artifact resolution metadata, so the warning remains terminal.`
: result.error,
{
kind: unavailable ? "unavailable" : "invalid-request",
code: result.code,
version: result.version,
warning: result.warning,
installPolicyWarning: result.installPolicyWarning,
...(installPolicyAcknowledgedWarnings?.length ? { installPolicyAcknowledgedWarnings } : {}),
...(installPolicyResolvedRequest ? { installPolicyResolvedRequest } : {}),
cause: result,
},
);
}
function installRecordOwnsTarget(
@@ -1078,6 +1185,9 @@ function throwPersistenceFailureWithCleanupWarnings(error: unknown, warnings: st
code: error.code,
version: error.version,
warning: [error.warning, cleanupWarning].filter(Boolean).join("\n"),
installPolicyWarning: error.installPolicyWarning,
installPolicyAcknowledgedWarnings: error.installPolicyAcknowledgedWarnings,
installPolicyResolvedRequest: error.installPolicyResolvedRequest,
cause: error,
});
}
@@ -1100,6 +1210,7 @@ async function persistManagedSourceInstall(params: {
persistenceLogger?: PluginInstallLogger;
successMessage?: string;
cleanupOnPersistenceFailure?: boolean;
publicationAuthority?: InstallPublicationAuthority;
}): Promise<OpenClawConfig> {
const persist = () =>
persistPluginInstall({
@@ -1110,6 +1221,7 @@ async function persistManagedSourceInstall(params: {
runtime: params.runtime,
...(params.persistenceLogger ? { persistenceLogger: params.persistenceLogger } : {}),
...(params.successMessage ? { successMessage: params.successMessage } : {}),
...(params.publicationAuthority ? { publicationAuthority: params.publicationAuthority } : {}),
});
if (!params.cleanupOnPersistenceFailure) {
return await persist();
@@ -1134,6 +1246,7 @@ export async function installManagedPluginSource(params: {
runtime?: RuntimeEnv;
invalidateRuntimeCache?: boolean;
cleanupOnPersistenceFailure?: boolean;
publicationAuthority?: InstallPublicationAuthority;
}): Promise<ManagedPluginSourceInstallResult> {
const { request } = params;
const env = params.env ?? process.env;
@@ -1159,6 +1272,7 @@ export async function installManagedPluginSource(params: {
config: params.snapshot.config,
extensionsDir,
logger: params.logger,
publicationAuthority: params.publicationAuthority,
};
const complete = async <T extends SourceInstallerResult>(
installResult: Promise<T>,
@@ -1296,6 +1410,9 @@ export async function installManagedPluginSource(params: {
installPluginFromClawHub({
...common,
spec: request.spec,
...(request.installPolicyRequestedSpecifier
? { installPolicyRequestedSpecifier: request.installPolicyRequestedSpecifier }
: {}),
mode: request.mode,
...(request.expectedPluginId ? { expectedPluginId: request.expectedPluginId } : {}),
...(request.expectedIntegrity ? { expectedIntegrity: request.expectedIntegrity } : {}),
@@ -1319,6 +1436,9 @@ export async function installManagedPluginSource(params: {
installPluginFromNpmSpec({
...common,
spec: request.spec,
...(request.installPolicyRequestedSpecifier
? { installPolicyRequestedSpecifier: request.installPolicyRequestedSpecifier }
: {}),
mode: request.mode,
...(request.source === "official" || request.trustedSourceLinkedOfficialInstall
? { trustedSourceLinkedOfficialInstall: true }
@@ -1427,8 +1547,13 @@ export async function installManagedPlugin(params: {
const officialCatalog = await loadOfficialCatalog();
const warnings: string[] = [];
const installLogger = createInstallLogger(warnings);
const remainingInstallPolicyWarnings = [
...(params.request.installPolicyWarningAcknowledgement?.warnings ?? []),
];
const acknowledgedInstallPolicyWarnings: InstallPolicyWarningOccurrence[] = [];
const request =
params.request.source === "clawhub"
params.request.installPolicyWarningAcknowledgement?.resolvedRequest ??
(params.request.source === "clawhub"
? resolveManagedClawHubInstallRequest({
request: params.request,
officialEntries: officialCatalog.entries,
@@ -1436,19 +1561,51 @@ export async function installManagedPlugin(params: {
: resolveManagedOfficialInstallRequest({
request: params.request,
officialEntries: officialCatalog.entries,
});
}));
const installed = await installManagedPluginSource({
request,
snapshot,
env,
logger: installLogger,
persistenceLogger: installLogger,
publicationAuthority:
params.request.installPolicyWarningAcknowledgement?.publicationAuthority,
...(params.request.installPolicyWarningAcknowledgement
? {
safetyOverrides: {
onInstallPolicyWarning: async ({
scan,
approvalFingerprint,
targetName,
targetType,
requestMode,
}) => {
const warningIndex = remainingInstallPolicyWarnings.findIndex(
(approved) =>
approved.approvalFingerprint === approvalFingerprint &&
isDeepStrictEqual(approved.scan, scan) &&
approved.warning.targetName === targetName &&
approved.warning.targetType === targetType &&
approved.warning.requestMode === requestMode,
);
if (warningIndex < 0) {
return { status: "unavailable", reason: "warning-not-approved" };
}
const [approved] = remainingInstallPolicyWarnings.splice(warningIndex, 1);
if (approved) {
acknowledgedInstallPolicyWarnings.push(approved);
}
return { status: "approved" };
},
},
}
: {}),
cleanupOnPersistenceFailure: true,
invalidateRuntimeCache: false,
runtime: createSilentRuntime(),
});
if (!installed.ok) {
return throwInstallFailure(installed);
return throwInstallFailure(installed, request, acknowledgedInstallPolicyWarnings);
}
const catalog = await listManagedPlugins({
config: installed.config,
@@ -0,0 +1,66 @@
import { expectDefined } from "@openclaw/normalization-core";
import { expect } from "vitest";
import type {
InstallSafetyOverrides,
InstallPolicyWarningAcknowledgementRequest,
InstallPolicyWarningOccurrence,
} from "../install-security-scan.types.js";
const officialDiffsWarningOccurrence: InstallPolicyWarningOccurrence = {
approvalFingerprint: "review-diffs-warning",
scan: {
requestKind: "plugin-archive",
originType: "plugin-package",
pluginContentType: "package",
},
warning: {
targetName: "diffs",
targetType: "plugin",
requestMode: "install",
reason: "Review this warning",
},
};
type InstallPolicyWarningCall = {
onInstallPolicyWarning?: InstallSafetyOverrides["onInstallPolicyWarning"];
};
export const officialDiffsWarningRequest = {
source: "official",
pluginId: "diffs",
installPolicyWarningAcknowledgement: {
publicationAuthority: {
assertCurrent: () => {},
commit: () => {},
},
resolvedRequest: {
source: "clawhub",
spec: "clawhub:@openclaw/diffs@2026.6.11",
expectedPluginId: "diffs",
expectedIntegrity: `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`,
},
warnings: [officialDiffsWarningOccurrence],
},
} as const;
export async function expectOneShotInstallPolicyWarningAcknowledgement(mock: {
mock: { calls: unknown[][] };
}): Promise<void> {
const call = expectDefined(mock.mock.calls[0], "clawhub install call test invariant");
const params = call[0] as InstallPolicyWarningCall;
const acknowledge = expectDefined(
params.onInstallPolicyWarning,
"expected install-policy acknowledgement callback",
);
const request: InstallPolicyWarningAcknowledgementRequest = {
targetName: "diffs",
targetType: "plugin",
requestMode: "install",
...officialDiffsWarningOccurrence,
};
expect(await acknowledge(request)).toEqual({ status: "approved" });
expect(await acknowledge(request)).toEqual({
status: "unavailable",
reason: "warning-not-approved",
});
}
@@ -0,0 +1,64 @@
export function metadataSnapshot(params: {
enabled?: boolean;
id?: string;
name?: string;
origin?: "bundled" | "global";
packageName?: string | null;
installRecord?: Record<string, unknown>;
featured?: boolean;
description?: string;
icon?: string;
}) {
const id = params.id ?? "workboard";
const origin = params.origin ?? "bundled";
const packageName =
params.packageName === null ? undefined : (params.packageName ?? `@openclaw/${id}`);
const installRecord =
params.installRecord ??
(origin === "global" ? { source: "path", installPath: `/tmp/${id}` } : undefined);
const manifest = {
id,
name: params.name ?? "Workboard",
description: params.description ?? "Coordinate agent work in a shared board.",
catalog: { featured: params.featured ?? true, order: 10 },
...(params.icon ? { icon: params.icon } : {}),
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
origin,
rootDir: `/tmp/${id}`,
source: `/tmp/${id}/index.ts`,
manifestPath: `/tmp/${id}/openclaw.plugin.json`,
};
return {
index: {
plugins: [
{
pluginId: id,
...(origin === "global" ? { installOwner: id } : {}),
...(packageName ? { packageName } : {}),
origin,
enabled: params.enabled ?? true,
rootDir: `/tmp/${id}`,
},
],
installRecords: installRecord ? { [id]: installRecord } : {},
},
byPluginId: new Map([[id, manifest]]),
plugins: [manifest],
diagnostics: [],
normalizePluginId: (pluginId: string) => pluginId,
};
}
export function emptyMetadataSnapshot() {
return {
index: { plugins: [], installRecords: {} },
byPluginId: new Map(),
plugins: [],
diagnostics: [],
normalizePluginId: (pluginId: string) => pluginId,
};
}