diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 4ef659db3cbb..9438075f32bf 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -551,7 +551,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, 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`, target metadata, `reason`, optional `findings`, and a server-issued `acknowledgementToken`. After showing those details, a client may retry with that token as `installPolicyWarningAcknowledgement`. The Gateway consumes the token once and only for the same install request and resolved artifact; policy then re-evaluates the staged source and continues only when the warning is unchanged. 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.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`. The Gateway consumes the token once and only for the same install request and resolved artifact; policy then re-evaluates the staged source and continues only when the warning is unchanged. 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/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/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,