From 1712e7fd615c0f85930a50f7ebcd21cfbe2949f9 Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:43:54 +1000 Subject: [PATCH] feat(security): expose install policy warning approval --- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-plugin-common.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../gateway-runtime.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- docs/gateway/protocol.md | 2 +- docs/tools/plugin.md | 36 +- docs/tools/skills-config.md | 17 + packages/gateway-protocol/src/index.ts | 1 + .../install-policy-warning-error-details.ts | 109 +++ .../src/plugins-validators.test.ts | 16 +- .../gateway-protocol/src/schema/plugins.ts | 2 + ...all-policy-warning-acknowledgement.test.ts | 59 +- ...stall-policy-warning-error-details.test.ts | 95 +++ .../install-policy-warning-error-details.ts | 1 + ...in-install-policy-acknowledgement-state.ts | 37 + src/gateway/server-import-boundary.test.ts | 18 +- src/gateway/server-kernel.test.ts | 6 + src/gateway/server-lifecycle.ts | 4 + src/gateway/server-methods/plugins.test.ts | 757 +++++++++++++++++- src/gateway/server-methods/plugins.ts | 222 ++++- src/infra/install-package-dir.test.ts | 127 +++ src/infra/install-package-dir.ts | 40 +- src/infra/npm-integrity.test.ts | 24 + src/infra/npm-integrity.ts | 8 + src/plugins/clawhub.test.ts | 31 +- src/plugins/clawhub.ts | 43 +- src/plugins/git-install.ts | 23 +- src/plugins/install-managed-npm.ts | 44 +- src/plugins/install-npm-pack.ts | 29 +- .../install-npm-policy-metadata.test.ts | 85 ++ src/plugins/install-npm.ts | 47 +- src/plugins/install-package.ts | 4 + src/plugins/install-persistence.ts | 6 +- .../install-record-commit-publication.test.ts | 44 + src/plugins/install-record-commit.ts | 4 + .../install-security-scan.runtime.test.ts | 152 +++- src/plugins/install-security-scan.runtime.ts | 52 +- src/plugins/install-security-scan.ts | 6 +- src/plugins/install-security-scan.types.ts | 27 +- src/plugins/install-shared.test.ts | 62 ++ src/plugins/install-shared.ts | 18 +- src/plugins/install-types.ts | 41 +- src/plugins/install.npm-spec.e2e.test.ts | 92 ++- src/plugins/install.npm-spec.test.ts | 22 + src/plugins/install.test.ts | 86 ++ .../management-service-featured.test.ts | 62 +- .../management-service-install-policy.test.ts | 502 ++++++++++++ src/plugins/management-service.test.ts | 88 +- src/plugins/management-service.ts | 199 ++++- .../test-helpers/install-policy-warning.ts | 66 ++ .../management-service-fixtures.ts | 64 ++ 64 files changed, 3101 insertions(+), 413 deletions(-) create mode 100644 packages/gateway-protocol/src/install-policy-warning-error-details.ts create mode 100644 src/gateway/install-policy-warning-error-details.test.ts create mode 100644 src/gateway/install-policy-warning-error-details.ts create mode 100644 src/gateway/plugin-install-policy-acknowledgement-state.ts create mode 100644 src/plugins/install-npm-policy-metadata.test.ts create mode 100644 src/plugins/install-record-commit-publication.test.ts create mode 100644 src/plugins/install-shared.test.ts create mode 100644 src/plugins/management-service-install-policy.test.ts create mode 100644 src/plugins/test-helpers/install-policy-warning.ts create mode 100644 src/plugins/test-helpers/management-service-fixtures.ts diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index ff57f49e1c7d..6aac3f7310fe 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 862db30e09ec..e33569ffea33 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"ae00c815c607cea46536c879daf88c76490920cc9d5beefa1822f384b15610e1","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"0f06f4f4822eadd8f372a44210452712cdbb6f374d9e71d742b81f3358356929","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 31b85bb7faaa..78ed5490c277 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"d8b7c867b29b4651375455e938ae634e7213e3e8c17444dc2b0ef091e46fda6c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"3d7b5d15182a191670d0f1c0b1ee8e07337b9cf7055653d3e85a6c9cf2e72732","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 94ceeb3194f9..826c8684636c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 205deefc917c..5df0502229e7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"2f85610859f047db1d76efbe52d9e63d8126ce8e944d53d6fdafb4e7f503b5dc","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"2ec34a7ea4128251e0b5c694703d9c57b1f6e9bacb21474e164ead33e40f1331","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index f1ffe9571fdc..510490e8345a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"6b1f575e77dc71e72c4b2575f366e7e9990d391e85364844e0ecec2146e7f902","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"006b5a333d131e09438f838c5200c3da1c2ccb30c96a269b085626b9f18e1eb7","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index a6647ae1cf97..976753ee1f20 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index f0957d7f1ba9..8b03d202cb55 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"2ccfe8eb85378f3be3f78f79f2638b00faa943e351ff69464bfed44ed63e09c1","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"a27a7ab5f3a2a166675222eda5755a6991b99c982106548b848616c14e977e18","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index c9fa00676e85..7d0e272f0575 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"d23cd5df763235dbe35ede1c23d26735f4c183b03dc216ac212aefe75c0c1f96","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"af1acb53d110c0ebec021ad878018e57f028c201348bf813f652e13d0db2c674","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 7bbe64aef447..12fc2608260a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"b691fcfb34a5f228938d06c50f9f8a26bb63ce06644e9df25f44876601e4cebb","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"5669646ab04c2aaf510924b853e925a6e5e8798fa5c3e3b500678d228131e49b","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index ccf2daaf63e6..04716803ae62 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index a4180100b928..fd73ddbfedbb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"5008615c80d86383e18527d71442f1501e4f02bdb3faa1494a48edf40fd66f84","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"5d8f9b45e4338ab4623a15d77fc95c850a6dec6d30e6f38e4c355730229c678d","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index d7bd00e6e002..67248bb8cd87 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"6b73741c889f68cc19a5d1f8703df2eca650fe57803795045cff122d746cbb6a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"cc9aeab1ce02f61610eb10aeee25023f729c8b81a4b7d2759235953e016cdf4f","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index b8e9d32eb33e..89f1aa959da2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"15704fc50b81cebceb677a5a69a7bc8c97afd962081c647de58a027bd816f19d","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"76570c04f575db917766195be05f7ae0daa457cb3f82915e4478c5890f95694b","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 3153f34476f5..7363b2eed7d9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index b48deb66cf47..e786af004a5a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"8b0c71c8abe13163eee28ca65e6afb490d0ae3c16288292143ad021975b96ca6","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"054fa8033deea8ae745cd47a80b8e9bffe963464674f73a45160452da1f8fc1a","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index bb5a15cdc566..15939bda570f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"eb0096fd25551a7f4a58dcf6c6b53120f3f1ddc1248b2b962cca60c75f6daa12","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"047e294fde7ac6b68feb7b1b490a4ed708b2cabb5f9db51ffc1f7fc1ae39645f","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 461031e14b76..280a2b5c828d 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -560,7 +560,7 @@ methods. Treat this as feature discovery, not a full enumeration of - `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. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 8ce87acc07e9..c7c1f38eadcc 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -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 diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index 559406210764..9c6f4963a209 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -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 diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 52541d3e6f5b..d6cab21496ba 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -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, diff --git a/packages/gateway-protocol/src/install-policy-warning-error-details.ts b/packages/gateway-protocol/src/install-policy-warning-error-details.ts new file mode 100644 index 000000000000..0c4159513c9d --- /dev/null +++ b/packages/gateway-protocol/src/install-policy-warning-error-details.ts @@ -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 } : {}), + }; +} diff --git a/packages/gateway-protocol/src/plugins-validators.test.ts b/packages/gateway-protocol/src/plugins-validators.test.ts index eaeedbfb9001..881b67400960 100644 --- a/packages/gateway-protocol/src/plugins-validators.test.ts +++ b/packages/gateway-protocol/src/plugins-validators.test.ts @@ -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", () => { diff --git a/packages/gateway-protocol/src/schema/plugins.ts b/packages/gateway-protocol/src/schema/plugins.ts index 529cebf05b18..227b2a991bb8 100644 --- a/packages/gateway-protocol/src/schema/plugins.ts +++ b/packages/gateway-protocol/src/schema/plugins.ts @@ -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), }), ]); diff --git a/src/cli/install-policy-warning-acknowledgement.test.ts b/src/cli/install-policy-warning-acknowledgement.test.ts index bb4dd4f6188b..8629df1948a0 100644 --- a/src/cli/install-policy-warning-acknowledgement.test.ts +++ b/src/cli/install-policy-warning-acknowledgement.test.ts @@ -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(); }); }); diff --git a/src/gateway/install-policy-warning-error-details.test.ts b/src/gateway/install-policy-warning-error-details.test.ts new file mode 100644 index 000000000000..4610dabd52a4 --- /dev/null +++ b/src/gateway/install-policy-warning-error-details.test.ts @@ -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 = { + 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", + }); + }); +}); diff --git a/src/gateway/install-policy-warning-error-details.ts b/src/gateway/install-policy-warning-error-details.ts new file mode 100644 index 000000000000..90b50e6efdf0 --- /dev/null +++ b/src/gateway/install-policy-warning-error-details.ts @@ -0,0 +1 @@ +export { readInstallPolicyWarningErrorDetails } from "../../packages/gateway-protocol/src/install-policy-warning-error-details.js"; diff --git a/src/gateway/plugin-install-policy-acknowledgement-state.ts b/src/gateway/plugin-install-policy-acknowledgement-state.ts new file mode 100644 index 000000000000..bb6d995789cf --- /dev/null +++ b/src/gateway/plugin-install-policy-acknowledgement-state.ts @@ -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; +}; + +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); +} diff --git a/src/gateway/server-import-boundary.test.ts b/src/gateway/server-import-boundary.test.ts index b45d3b9efde4..5228c0c29c5a 100644 --- a/src/gateway/server-import-boundary.test.ts +++ b/src/gateway/server-import-boundary.test.ts @@ -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( diff --git a/src/gateway/server-kernel.test.ts b/src/gateway/server-kernel.test.ts index 1e0c556fb33c..79839c35f38e 100644 --- a/src/gateway/server-kernel.test.ts +++ b/src/gateway/server-kernel.test.ts @@ -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(); diff --git a/src/gateway/server-lifecycle.ts b/src/gateway/server-lifecycle.ts index 8ba899d3a8a0..2ff55839b280 100644 --- a/src/gateway/server-lifecycle.ts +++ b/src/gateway/server-lifecycle.ts @@ -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(); diff --git a/src/gateway/server-methods/plugins.test.ts b/src/gateway/server-methods/plugins.test.ts index cf00b34ef201..0b9a858b391c 100644 --- a/src/gateway/server-methods/plugins.test.ts +++ b/src/gateway/server-methods/plugins.test.ts @@ -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(), + list: vi.fn(), + setEnabled: vi.fn(), + uninstall: vi.fn(), }; }); 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( + "../../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, @@ -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((_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((_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((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 }).details).not.toHaveProperty( + "scan", + ); + expect((first.error as { details?: Record }).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 }).details).not.toHaveProperty( + "scan", + ); + expect((second.error as { details?: Record }).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", ), ); diff --git a/src/gateway/server-methods/plugins.ts b/src/gateway/server-methods/plugins.ts index 77a8f7535233..782b78a29ee2 100644 --- a/src/gateway/server-methods/plugins.ts +++ b/src/gateway/server-methods/plugins.ts @@ -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>( + Symbol.for("openclaw.pendingPluginLifecycleOperations"), + "close-only", +); + +async function runTrackedPluginLifecycleOperation(run: () => Promise): Promise { + const operation = Promise.resolve().then(run); + pendingPluginLifecycleOperations.add(operation); + try { + return await operation; + } finally { + pendingPluginLifecycleOperations.delete(operation); + } +} + +async function waitForPendingPluginLifecycleOperations(): Promise { + 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, { diff --git a/src/infra/install-package-dir.test.ts b/src/infra/install-package-dir.test.ts index 5af8a50e5dae..219e12c2eed8 100644 --- a/src/infra/install-package-dir.test.ts +++ b/src/infra/install-package-dir.test.ts @@ -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"); diff --git a/src/infra/install-package-dir.ts b/src/infra/install-package-dir.ts index 7b756c067af1..866d02fea0c4 100644 --- a/src/infra/install-package-dir.ts +++ b/src/infra/install-package-dir.ts @@ -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 { const manifestPath = path.join(targetDir, "package.json"); const parsed = await tryReadJson(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; - 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; + publicationAuthority?: { + assertCurrent: () => void; + commit: () => void; + }; +}): Promise { 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; afterCopy?: (installedDir: string) => void | Promise; - 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; +}): Promise { const hasDeps = Object.keys(params.manifestDependencies ?? {}).length > 0; - return installPackageDir({ + return installPackageDir({ ...params, hasDeps, sourceHardlinks: hasDeps ? "package-manager" : "reject", diff --git a/src/infra/npm-integrity.test.ts b/src/infra/npm-integrity.test.ts index 27c38ff6d78c..4752617ebef2 100644 --- a/src/infra/npm-integrity.test.ts +++ b/src/infra/npm-integrity.test.ts @@ -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", + ); + }); }); diff --git a/src/infra/npm-integrity.ts b/src/infra/npm-integrity.ts index 3f117ee754bd..ff0f629a6e56 100644 --- a/src/infra/npm-integrity.ts +++ b/src/infra/npm-integrity.ts @@ -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({ spec: params.spec, expectedIntegrity: params.expectedIntegrity, diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index a6365b03eb9e..fba8a84362ac 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -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); }); diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index bf84d01db321..bf7631bf250d 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -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; - }, + 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; + }, ): 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!; diff --git a/src/plugins/git-install.ts b/src/plugins/git-install.ts index 60a37a6c95b7..3f1b76511317 100644 --- a/src/plugins/git-install.ts +++ b/src/plugins/git-install.ts @@ -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 { 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, diff --git a/src/plugins/install-managed-npm.ts b/src/plugins/install-managed-npm.ts index 73a130fd0502..4951905bc886 100644 --- a/src/plugins/install-managed-npm.ts +++ b/src/plugins/install-managed-npm.ts @@ -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 { const runtime = await loadPluginInstallRuntime(); const { logger, timeoutMs, mode, dryRun } = runtime.resolveTimedInstallModeOptions( diff --git a/src/plugins/install-npm-pack.ts b/src/plugins/install-npm-pack.ts index c925ff7ff784..4cda0f2eec23 100644 --- a/src/plugins/install-npm-pack.ts +++ b/src/plugins/install-npm-pack.ts @@ -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; - }, + 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; + }, ): Promise { 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, diff --git a/src/plugins/install-npm-policy-metadata.test.ts b/src/plugins/install-npm-policy-metadata.test.ts new file mode 100644 index 000000000000..780a2aeb6870 --- /dev/null +++ b/src/plugins/install-npm-policy-metadata.test.ts @@ -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(); +const preflightPluginNpmInstallPolicyMock = vi.fn(); + +vi.mock("../infra/install-source-utils.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveNpmSpecMetadata: resolveNpmSpecMetadataMock }; +}); + +vi.mock("./install-security-scan.js", async (importOriginal) => { + const actual = await importOriginal(); + 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; + }> = []; + 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"); + }); +}); diff --git a/src/plugins/install-npm.ts b/src/plugins/install-npm.ts index 70b0307561fb..6af38be0e94d 100644 --- a/src/plugins/install-npm.ts +++ b/src/plugins/install-npm.ts @@ -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; - }, + 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; + }, ): Promise { 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 }; } diff --git a/src/plugins/install-package.ts b/src/plugins/install-package.ts index 4d3bbc04acba..1b0595fcc47f 100644 --- a/src/plugins/install-package.ts +++ b/src/plugins/install-package.ts @@ -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, diff --git a/src/plugins/install-persistence.ts b/src/plugins/install-persistence.ts index a05c9d26b404..e6e455909a3c 100644 --- a/src/plugins/install-persistence.ts +++ b/src/plugins/install-persistence.ts @@ -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 { 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" }, diff --git a/src/plugins/install-record-commit-publication.test.ts b/src/plugins/install-record-commit-publication.test.ts new file mode 100644 index 000000000000..ec50747b8b31 --- /dev/null +++ b/src/plugins/install-record-commit-publication.test.ts @@ -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()), + writePersistedInstalledPluginIndexInstallRecordsWithLease: mocks.writeInstallRecords, +})); + +vi.mock("./plugin-lifecycle-lease.js", () => ({ + withPluginLifecycleLease: async ( + _options: unknown, + run: (lease: { databasePath: string }) => Promise, + ) => 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(); +}); diff --git a/src/plugins/install-record-commit.ts b/src/plugins/install-record-commit.ts index 45d6cbc35862..6c42714043fe 100644 --- a/src/plugins/install-record-commit.ts +++ b/src/plugins/install-record-commit.ts @@ -395,6 +395,7 @@ async function commitPluginInstallRecordsWithWriter(params: { nextConfig: OpenClawConfig; writeOptions?: ConfigWriteOptions; commit: ConfigCommit; + commitPublication?: () => void; }): Promise<{ committed: ConfigReplaceResult | void; nextInstallRecords: Record; @@ -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 { 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({ diff --git a/src/plugins/install-security-scan.runtime.test.ts b/src/plugins/install-security-scan.runtime.test.ts index 0407ee8021c3..534fdc73c23f 100644 --- a/src/plugins/install-security-scan.runtime.test.ts +++ b/src/plugins/install-security-scan.runtime.test.ts @@ -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(); const getGlobalHookRunnerMock = vi.fn(); vi.mock("../security/install-policy.js", async (importOriginal) => { const actual = await importOriginal(); 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", diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index 1dae6d3f86e8..9ec00d3a16d6 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -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.", }, }; diff --git a/src/plugins/install-security-scan.ts b/src/plugins/install-security-scan.ts index 40885f9c0a5f..46c48c90491a 100644 --- a/src/plugins/install-security-scan.ts +++ b/src/plugins/install-security-scan.ts @@ -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; }; }; diff --git a/src/plugins/install-security-scan.types.ts b/src/plugins/install-security-scan.types.ts index e0c99ab4834d..fa75dc3e1453 100644 --- a/src/plugins/install-security-scan.types.ts +++ b/src/plugins/install-security-scan.types.ts @@ -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. */ diff --git a/src/plugins/install-shared.test.ts b/src/plugins/install-shared.test.ts new file mode 100644 index 000000000000..92e4e1e94844 --- /dev/null +++ b/src/plugins/install-shared.test.ts @@ -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); + }); +}); diff --git a/src/plugins/install-shared.ts b/src/plugins/install-shared.ts index 90fc644e4b69..aeeab64257ff 100644 --- a/src/plugins/install-shared.ts +++ b/src/plugins/install-shared.ts @@ -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 | null>; + publicationAuthority?: InstallPublicationAuthority; nameEncoder?: (pluginId: string) => string; }): Promise { 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 = { diff --git a/src/plugins/install-types.ts b/src/plugins/install-types.ts index 70dc8081c44b..1bf343c8f71d 100644 --- a/src/plugins/install-types.ts +++ b/src/plugins/install-types.ts @@ -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; @@ -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; }; diff --git a/src/plugins/install.npm-spec.e2e.test.ts b/src/plugins/install.npm-spec.e2e.test.ts index 4222cb753e20..41761562732c 100644 --- a/src/plugins/install.npm-spec.e2e.test.ts +++ b/src/plugins/install.npm-spec.e2e.test.ts @@ -79,35 +79,47 @@ async function readJson(filePath: string): Promise { 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; diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index e28f89cf114c..cd0de5cb7816 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -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, diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 0342236a5b48..f3daa841b930 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -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"); diff --git a/src/plugins/management-service-featured.test.ts b/src/plugins/management-service-featured.test.ts index 664067cb6b6a..990f0ede5d29 100644 --- a/src/plugins/management-service-featured.test.ts +++ b/src/plugins/management-service-featured.test.ts @@ -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; - 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", diff --git a/src/plugins/management-service-install-policy.test.ts b/src/plugins/management-service-install-policy.test.ts new file mode 100644 index 000000000000..5024597d587b --- /dev/null +++ b/src/plugins/management-service-install-policy.test.ts @@ -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()), + loadInstalledPluginIndexInstallRecords: (...args: unknown[]) => mocks.installRecords(...args), +})); + +vi.mock("./uninstall.js", async (importOriginal) => ({ + ...(await importOriginal()), + applyPluginUninstallDirectoryRemoval: (...args: unknown[]) => mocks.applyUninstall(...args), +})); + +vi.mock("./official-external-plugin-catalog.js", async (importOriginal) => ({ + ...(await importOriginal()), + 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", + }); + }); +}); diff --git a/src/plugins/management-service.test.ts b/src/plugins/management-service.test.ts index 71ea0917fc94..8130a8b328b0 100644 --- a/src/plugins/management-service.test.ts +++ b/src/plugins/management-service.test.ts @@ -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 = {}) { }; } -function metadataSnapshot(params: { - enabled: boolean; - id?: string; - name?: string; - origin?: "bundled" | "global"; - installRecord?: Record; - 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"); diff --git a/src/plugins/management-service.ts b/src/plugins/management-service.ts index 17fb189d6e7b..9fec391160df 100644 --- a/src/plugins/management-service.ts +++ b/src/plugins/management-service.ts @@ -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 { 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 { 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 ( installResult: Promise, @@ -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, diff --git a/src/plugins/test-helpers/install-policy-warning.ts b/src/plugins/test-helpers/install-policy-warning.ts new file mode 100644 index 000000000000..09ca0f8edc9f --- /dev/null +++ b/src/plugins/test-helpers/install-policy-warning.ts @@ -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 { + 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", + }); +} diff --git a/src/plugins/test-helpers/management-service-fixtures.ts b/src/plugins/test-helpers/management-service-fixtures.ts new file mode 100644 index 000000000000..a1fae0b18ffb --- /dev/null +++ b/src/plugins/test-helpers/management-service-fixtures.ts @@ -0,0 +1,64 @@ +export function metadataSnapshot(params: { + enabled?: boolean; + id?: string; + name?: string; + origin?: "bundled" | "global"; + packageName?: string | null; + installRecord?: Record; + 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, + }; +}