diff --git a/src/cli/install-policy-warning-acknowledgement.test.ts b/src/cli/install-policy-warning-acknowledgement.test.ts index 83d3bf39eb25..8629df1948a0 100644 --- a/src/cli/install-policy-warning-acknowledgement.test.ts +++ b/src/cli/install-policy-warning-acknowledgement.test.ts @@ -48,7 +48,7 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => { targetName: "demo\npkg", targetType: "plugin", requestMode: fixture.requestMode, - warningFingerprint: "review-required", + approvalFingerprint: "review-required", scan: { requestKind: "plugin-npm", originType: "plugin-npm", @@ -79,7 +79,7 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => { targetName: "demo", targetType: "skill", requestMode: "install", - warningFingerprint: "review-required", + approvalFingerprint: "review-required", scan: { requestKind: "skill-install", originType: "skill-managed-install", @@ -127,7 +127,7 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => { targetName: "demo", targetType: "plugin", requestMode: "install", - warningFingerprint: "review-required", + approvalFingerprint: "review-required", scan: { requestKind: "plugin-npm", originType: "plugin-npm", diff --git a/src/gateway/server-methods/plugins.test.ts b/src/gateway/server-methods/plugins.test.ts index 6397d6999c22..b0811db19fe8 100644 --- a/src/gateway/server-methods/plugins.test.ts +++ b/src/gateway/server-methods/plugins.test.ts @@ -50,7 +50,7 @@ function warningOccurrence( warning: InstallPolicyWarningDetails, scan: InstallPolicyWarningScanIdentity = packageScan, ): InstallPolicyWarningOccurrence { - return { scan, warning, warningFingerprint: `fingerprint:${warning.reason}` }; + return { scan, warning, approvalFingerprint: `fingerprint:${warning.reason}` }; } async function callHandler( @@ -435,7 +435,7 @@ describe("plugin management Gateway handlers", () => { warnings: [ { scan: packageScan, - warningFingerprint: "fingerprint:Scanner found behavior that needs review", + approvalFingerprint: "fingerprint:Scanner found behavior that needs review", warning: { targetName: "demo-plugin", targetType: "plugin", @@ -485,7 +485,7 @@ describe("plugin management Gateway handlers", () => { warnings: [ { scan: packageScan, - warningFingerprint: "fingerprint:Scanner found a different issue", + approvalFingerprint: "fingerprint:Scanner found a different issue", warning: { targetName: "demo-plugin", targetType: "plugin", @@ -591,7 +591,7 @@ describe("plugin management Gateway handlers", () => { "scan", ); expect((first.error as { details?: Record }).details).not.toHaveProperty( - "warningFingerprint", + "approvalFingerprint", ); managementMocks.install.mockRejectedValueOnce( @@ -616,7 +616,7 @@ describe("plugin management Gateway handlers", () => { "scan", ); expect((second.error as { details?: Record }).details).not.toHaveProperty( - "warningFingerprint", + "approvalFingerprint", ); managementMocks.install.mockResolvedValueOnce({ diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index 430d02ca5805..65ec66851563 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -1784,6 +1784,15 @@ describe("installPluginFromClawHub", () => { expect(archiveInstallCall().onInstallPolicyWarning).toBe(onInstallPolicyWarning); }); + 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, diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 6e05bddcdd1b..56ae10ae9b01 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -1218,6 +1218,7 @@ export async function installPluginFromClawHub( dryRun?: boolean; expectedPluginId?: string; expectedIntegrity?: string; + installPolicyRequestedSpecifier?: string; env?: RuntimeVersionEnv; acknowledgeClawHubRisk?: boolean; onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; @@ -1457,7 +1458,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, 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 dbd30767f53f..302354a8ad18 100644 --- a/src/plugins/install-npm.ts +++ b/src/plugins/install-npm.ts @@ -56,6 +56,7 @@ export async function installPluginFromNpmSpec( expectedPluginId?: string; expectedReplacementPluginId?: string; expectedIntegrity?: string; + installPolicyRequestedSpecifier?: string; onIntegrityDrift?: (params: PluginNpmIntegrityDriftParams) => boolean | Promise; }, ): Promise { @@ -66,6 +67,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 { @@ -211,14 +213,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, @@ -239,7 +243,7 @@ export async function installPluginFromNpmSpec( mode: policyMode, packageName: parsedSpec.name, ...(expectedPluginId ? { pluginId: expectedPluginId } : {}), - requestedSpecifier: spec, + requestedSpecifier: installPolicyRequestedSpecifier, source: npmInstallPolicySource, sourcePath: policyMetadataPath, sourcePathKind: "file", @@ -265,7 +269,7 @@ export async function installPluginFromNpmSpec( displaySpec: spec, installPolicyRequest: { kind: "plugin-npm", - requestedSpecifier: spec, + requestedSpecifier: installPolicyRequestedSpecifier, source: npmInstallPolicySource, }, extensionsDir: params.extensionsDir, diff --git a/src/plugins/install-security-scan.runtime.test.ts b/src/plugins/install-security-scan.runtime.test.ts index 83f18de6528f..f184db734bc8 100644 --- a/src/plugins/install-security-scan.runtime.test.ts +++ b/src/plugins/install-security-scan.runtime.test.ts @@ -3,7 +3,9 @@ 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) => { @@ -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,7 @@ describe("legacy file install scan compatibility", () => { targetName: "payload", targetType: "plugin", requestMode: "install", - warningFingerprint: "warning-a", + approvalFingerprint: "approval-a", scan: { requestKind: "plugin-file", originType: "plugin-file", @@ -365,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", @@ -406,7 +424,7 @@ describe("legacy file install scan compatibility", () => { }, ], }, - warningFingerprint: "warning-b", + approvalFingerprint: "approval-a", }, }); expect(result?.blocked?.reason).toContain("Reason: review the new finding"); @@ -419,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", @@ -430,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", @@ -455,7 +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?.warningFingerprint).toBe("full-warning-b"); + expect(result?.blocked?.installPolicyWarning?.approvalFingerprint).toBe("approval-b"); expect(onInstallPolicyWarning).toHaveBeenCalledTimes(1); expect(runInstallPolicyMock).toHaveBeenCalledTimes(2); }); @@ -463,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" }, @@ -481,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({ @@ -505,7 +539,7 @@ describe("legacy file install scan compatibility", () => { requestMode: "install", reason: "review this plugin", }, - warningFingerprint: "warning-a", + approvalFingerprint: "approval-a", }, reason: expectedInstallPolicyNotice({ decision: "warn", @@ -528,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" }, @@ -547,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", @@ -584,7 +626,7 @@ describe("legacy file install scan compatibility", () => { requestMode: "install", reason: "review the dependency warning", }, - warningFingerprint: "warning-a", + approvalFingerprint: "approval-a", }); expect(runInstallPolicyMock).toHaveBeenCalledTimes(1); }); @@ -592,7 +634,11 @@ describe("legacy file install scan compatibility", () => { 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." }], }); @@ -616,7 +662,7 @@ describe("legacy file install scan compatibility", () => { reason: "review this plugin", findings: [{ ruleId: "context", severity: "info", message: "Informational context." }], }, - warningFingerprint: "warning-a", + approvalFingerprint: "approval-a", }); expect(warnings).toEqual([ `${expectedInstallPolicyNotice({ diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index 3ea8f31f1ae9..dacb732ba8f4 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -841,7 +841,7 @@ async function runOperatorInstallPolicy(params: { ...(params.skill ? { skillInstallId: params.skill.installId } : {}), }, warning: installPolicyWarning, - warningFingerprint: result.warning.fingerprint, + approvalFingerprint: result.warning.approvalFingerprint, }; if (!params.onInstallPolicyWarning) { return { @@ -899,7 +899,7 @@ async function runOperatorInstallPolicy(params: { reason: reevaluated.warning.reason, ...(reevaluated.findings?.length ? { findings: reevaluated.findings } : {}), }, - warningFingerprint: reevaluated.warning.fingerprint, + approvalFingerprint: reevaluated.warning.approvalFingerprint, }, reason: formatInstallPolicyNotice({ decision: "warn", diff --git a/src/plugins/install-security-scan.types.ts b/src/plugins/install-security-scan.types.ts index ee53022af1b9..3aa04f2ee12d 100644 --- a/src/plugins/install-security-scan.types.ts +++ b/src/plugins/install-security-scan.types.ts @@ -21,7 +21,7 @@ type InstallPolicyWarningScanIdentity = { export type InstallPolicyWarningOccurrence = { scan: InstallPolicyWarningScanIdentity; warning: InstallPolicyWarningDetails; - warningFingerprint: string; + approvalFingerprint: string; }; export type InstallPolicyWarningAcknowledgementRequest = InstallPolicyWarningOccurrence & { @@ -30,7 +30,7 @@ export type InstallPolicyWarningAcknowledgementRequest = InstallPolicyWarningOcc requestMode: "install" | "update"; }; -type InstallPolicyWarningAcknowledgementResult = +export type InstallPolicyWarningAcknowledgementResult = | { status: "approved" } | { status: "declined" } | { diff --git a/src/plugins/install-shared.test.ts b/src/plugins/install-shared.test.ts index d15ee36a9fb8..9f503884f4c1 100644 --- a/src/plugins/install-shared.test.ts +++ b/src/plugins/install-shared.test.ts @@ -17,7 +17,7 @@ describe("installPluginDirectoryIntoExtensions", () => { fs.mkdirSync(sourceDir, { recursive: true }); fs.writeFileSync(path.join(sourceDir, "index.js"), "export default {};\n"); const installPolicyWarning = { - warningFingerprint: "review-installed-dependency-tree", + approvalFingerprint: "review-installed-dependency-tree", scan: { requestKind: "plugin-archive" as const, originType: "plugin-dependency-tree", diff --git a/src/plugins/management-service-install-policy.test.ts b/src/plugins/management-service-install-policy.test.ts index 8b0dd2284670..44091f124056 100644 --- a/src/plugins/management-service-install-policy.test.ts +++ b/src/plugins/management-service-install-policy.test.ts @@ -207,7 +207,7 @@ describe("plugin management install-policy acknowledgements", () => { await expectOneShotInstallPolicyWarningAcknowledgement(mocks.clawhubInstall); }); - it("acknowledges a reviewed warning only at the same scan stage and only once", async () => { + 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"); @@ -220,7 +220,7 @@ describe("plugin management install-policy acknowledgements", () => { "first approved warning", ); const dependencyWarning: InstallPolicyWarningOccurrence = { - warningFingerprint: packageWarning.warningFingerprint, + approvalFingerprint: packageWarning.approvalFingerprint, scan: { ...packageWarning.scan, originType: "plugin-dependency-tree", @@ -230,7 +230,18 @@ describe("plugin management install-policy acknowledgements", () => { }; const changedFingerprintWarning: InstallPolicyWarningOccurrence = { ...packageWarning, - warningFingerprint: "changed-full-warning", + 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({ @@ -269,11 +280,19 @@ describe("plugin management install-policy acknowledgements", () => { ...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: packageWarning.warning.targetName, - targetType: packageWarning.warning.targetType, - requestMode: packageWarning.warning.requestMode, - ...packageWarning, + 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({ @@ -284,7 +303,7 @@ describe("plugin management install-policy acknowledgements", () => { it("pins reviewed npm warnings to the first resolved version and integrity", async () => { const warning: InstallPolicyWarningOccurrence = { - warningFingerprint: "review-npm-package", + approvalFingerprint: "review-npm-package", scan: { requestKind: "plugin-npm", originType: "plugin-npm", @@ -343,6 +362,7 @@ describe("plugin management install-policy acknowledgements", () => { expect(resolvedRequest).toMatchObject({ source: "official", spec: npmResolution.resolvedSpec, + installPolicyRequestedSpecifier: "@openclaw/npm-demo", expectedIntegrity: npmResolution.integrity, }); @@ -362,6 +382,7 @@ describe("plugin management install-policy acknowledgements", () => { expect(mocks.npmInstall).toHaveBeenLastCalledWith( expect.objectContaining({ spec: npmResolution.resolvedSpec, + installPolicyRequestedSpecifier: "@openclaw/npm-demo", expectedIntegrity: npmResolution.integrity, }), ); @@ -369,7 +390,7 @@ describe("plugin management install-policy acknowledgements", () => { it("keeps npm warnings terminal when immutable resolution metadata is incomplete", async () => { const warning: InstallPolicyWarningOccurrence = { - warningFingerprint: "review-npm-package", + approvalFingerprint: "review-npm-package", scan: { requestKind: "plugin-npm", originType: "plugin-npm", @@ -424,7 +445,7 @@ describe("plugin management install-policy acknowledgements", () => { it("pins reviewed ClawHub warnings to the downloaded archive integrity", async () => { const warning: InstallPolicyWarningOccurrence = { - warningFingerprint: "review-clawhub-package", + approvalFingerprint: "review-clawhub-package", scan: { requestKind: "plugin-archive", originType: "plugin-package", @@ -462,6 +483,7 @@ describe("plugin management install-policy acknowledgements", () => { 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.ts b/src/plugins/management-service.ts index 237468951359..a9b3b1581272 100644 --- a/src/plugins/management-service.ts +++ b/src/plugins/management-service.ts @@ -156,6 +156,7 @@ export type ManagedPluginSourceInstallRequest = | { source: "clawhub"; spec: string; + installPolicyRequestedSpecifier?: string; mode?: "install" | "update"; expectedPluginId?: string; expectedIntegrity?: string; @@ -171,6 +172,7 @@ export type ManagedPluginSourceInstallRequest = | { source: "official"; spec: string; + installPolicyRequestedSpecifier?: string; pluginId: string; expectedIntegrity?: string; mode: "install" | "update"; @@ -179,6 +181,7 @@ export type ManagedPluginSourceInstallRequest = | { source: "npm"; spec: string; + installPolicyRequestedSpecifier?: string; mode: "install" | "update"; pin?: boolean; expectedPluginId?: string; @@ -1020,6 +1023,7 @@ function pinInstallPolicyResolvedRequest( return { ...request, spec: npmResolution.resolvedSpec, + installPolicyRequestedSpecifier: request.installPolicyRequestedSpecifier ?? request.spec, expectedIntegrity: npmResolution.integrity, }; } @@ -1034,6 +1038,7 @@ function pinInstallPolicyResolvedRequest( ? { ...request, spec: buildClawHubSpec(parsed.name, version), + installPolicyRequestedSpecifier: request.installPolicyRequestedSpecifier ?? request.spec, expectedIntegrity: integrity, } : undefined; @@ -1378,6 +1383,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 } : {}), @@ -1401,6 +1409,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 } @@ -1533,10 +1544,20 @@ export async function installManagedPlugin(params: { ...(params.request.installPolicyWarningAcknowledgement ? { safetyOverrides: { - onInstallPolicyWarning: async ({ scan, warning, warningFingerprint }) => { - const currentWarning = { scan, warning, warningFingerprint }; - const warningIndex = remainingInstallPolicyWarnings.findIndex((approved) => - isDeepStrictEqual(currentWarning, approved), + 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" }; diff --git a/src/plugins/test-helpers/install-policy-warning.ts b/src/plugins/test-helpers/install-policy-warning.ts index 55664aea8eea..cea51e40d237 100644 --- a/src/plugins/test-helpers/install-policy-warning.ts +++ b/src/plugins/test-helpers/install-policy-warning.ts @@ -7,7 +7,7 @@ import type { } from "../install-security-scan.types.js"; const officialDiffsWarningOccurrence: InstallPolicyWarningOccurrence = { - warningFingerprint: "review-diffs-warning", + approvalFingerprint: "review-diffs-warning", scan: { requestKind: "plugin-archive", originType: "plugin-package",