From 6608df15581a030f83aced5c1e67f3dcaa7ca055 Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:59:36 +1000 Subject: [PATCH] fix(gateway): fence approved plugin publication --- .../agent-harness.json | 2 +- src/gateway/server-methods/plugins.test.ts | 115 ++++++++++++++++++ src/gateway/server-methods/plugins.ts | 35 +++++- src/infra/install-package-dir.test.ts | 84 +++++++++++++ src/infra/install-package-dir.ts | 6 + src/plugins/clawhub.ts | 35 +++--- src/plugins/git-install.ts | 23 ++-- src/plugins/install-managed-npm.ts | 45 +++---- src/plugins/install-npm-pack.ts | 29 +++-- src/plugins/install-npm.ts | 33 ++--- src/plugins/install-package.ts | 3 + src/plugins/install-persistence.ts | 6 +- src/plugins/install-record-commit.test.ts | 24 ++++ src/plugins/install-record-commit.ts | 4 + src/plugins/install-shared.ts | 3 + src/plugins/install-types.ts | 32 +++-- .../management-service-install-policy.test.ts | 12 ++ src/plugins/management-service.ts | 24 ++-- .../test-helpers/install-policy-warning.ts | 4 + 19 files changed, 421 insertions(+), 98 deletions(-) diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 51eef8cc54d5..c04610b81ecc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"acc5ea798de9e4dcec8592770178ec56752f2e490d3f6b0bdebba1957be0416c","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"ddeb05e8e39f31b9a1518e1d5d79b9eebf4f8ad245f41caeb1373a70adf99b26","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/src/gateway/server-methods/plugins.test.ts b/src/gateway/server-methods/plugins.test.ts index 33217b3e06bb..d7468c3c9438 100644 --- a/src/gateway/server-methods/plugins.test.ts +++ b/src/gateway/server-methods/plugins.test.ts @@ -483,6 +483,10 @@ describe("plugin management Gateway handlers", () => { 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", @@ -533,6 +537,10 @@ describe("plugin management Gateway handlers", () => { 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", @@ -744,6 +752,109 @@ describe("plugin management Gateway handlers", () => { 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)); + await drainGlobalSingletonLifecycleState("restart"); + 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", @@ -830,6 +941,10 @@ describe("plugin management Gateway handlers", () => { source: "clawhub", packageName: "community/plugin", installPolicyWarningAcknowledgement: { + publicationAuthority: { + assertCurrent: expect.any(Function), + commit: expect.any(Function), + }, resolvedRequest, warnings: [firstWarning, secondWarning], }, diff --git a/src/gateway/server-methods/plugins.ts b/src/gateway/server-methods/plugins.ts index 830fab5a9689..52ef7ae64e2a 100644 --- a/src/gateway/server-methods/plugins.ts +++ b/src/gateway/server-methods/plugins.ts @@ -25,6 +25,7 @@ import { setManagedPluginEnabled, uninstallManagedPlugin, type ManagedPluginInstallRequest, + type ManagedPluginInstallPolicyAcknowledgement, type ManagedPluginSourceInstallRequest, } from "../../plugins/management-service.js"; import { resolveGlobalSet, resolveGlobalSingleton } from "../../shared/global-singleton.js"; @@ -120,10 +121,41 @@ function issueInstallPolicyAcknowledgement(params: { 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, -): Pick | undefined { +): ManagedPluginInstallPolicyAcknowledgement | undefined { const token = request.installPolicyWarningAcknowledgement; if (!token) { return undefined; @@ -144,6 +176,7 @@ function consumeInstallPolicyAcknowledgement( return { resolvedRequest: acknowledgement.resolvedRequest, warnings: acknowledgement.warnings, + publicationAuthority: createInstallPolicyPublicationAuthority(generation), }; } diff --git a/src/infra/install-package-dir.test.ts b/src/infra/install-package-dir.test.ts index c757610c4efa..dca79892c85a 100644 --- a/src/infra/install-package-dir.test.ts +++ b/src/infra/install-package-dir.test.ts @@ -320,6 +320,90 @@ describe("installPackageDir", () => { ).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 ce99397cf38e..13e6f137e1ad 100644 --- a/src/infra/install-package-dir.ts +++ b/src/infra/install-package-dir.ts @@ -185,6 +185,10 @@ export async function installPackageDir< depsLogMessage: string; afterCopy?: (installedDir: string) => void | Promise; afterInstall?: (installedDir: string) => Promise; + publicationAuthority?: { + assertCurrent: () => void; + commit: () => void; + }; }): Promise { params.logger?.info?.(`Installing to ${params.targetDir}…`); const installBaseDir = path.dirname(params.targetDir); @@ -328,6 +332,7 @@ export async function installPackageDir< installBaseDir, expectedRealPath: installBaseRealPath, }); + params.publicationAuthority?.assertCurrent(); await movePathWithCopyFallback({ from: canonicalTargetDir, sourceHardlinks, @@ -343,6 +348,7 @@ export async function installPackageDir< installBaseDir, expectedRealPath: installBaseRealPath, }); + params.publicationAuthority?.commit(); await movePathWithCopyFallback({ from: stageDir, sourceHardlinks, diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 56ae10ae9b01..6cb84aa82533 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -52,6 +52,7 @@ import type { RuntimeVersionEnv } from "../version.js"; import { CLAWHUB_INSTALL_ERROR_CODE, type ClawHubInstallErrorCode } from "./clawhub-error-codes.js"; import type { ClawHubPluginInstallRecordFields } from "./clawhub-install-records.js"; import type { InstallSafetyOverrides } from "./install-security-scan.js"; +import type { InstallPublicationOptions } from "./install-types.js"; import { installPluginFromArchive, PLUGIN_INSTALL_ERROR_CODE, @@ -1207,22 +1208,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; - installPolicyRequestedSpecifier?: 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 +1447,7 @@ export async function installPluginFromClawHub( ); const installResult = await installPluginFromArchive({ archivePath: archive.archivePath, + publicationAuthority: params.publicationAuthority, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, onInstallPolicyWarning: params.onInstallPolicyWarning, trustedSourceLinkedOfficialInstall: diff --git a/src/plugins/git-install.ts b/src/plugins/git-install.ts index 2aabea20f87a..fc048c841e67 100644 --- a/src/plugins/git-install.ts +++ b/src/plugins/git-install.ts @@ -22,6 +22,7 @@ import { type InstallSafetyOverrides, type InstallSecurityScanResult, } from "./install-security-scan.js"; +import type { InstallPublicationOptions } from "./install-types.js"; import { installPluginFromInstalledPackageDir, PLUGIN_INSTALL_ERROR_CODE, @@ -351,16 +352,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) { @@ -498,6 +500,7 @@ export async function installPluginFromGitSpec( return result; } 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 bc19b0ae5eb5..ab6cef6b70f5 100644 --- a/src/plugins/install-managed-npm.ts +++ b/src/plugins/install-managed-npm.ts @@ -60,6 +60,7 @@ import { sourceFamilyForInstallPolicySource, } from "./install-shared.js"; import type { + InstallPublicationOptions, InstallPluginResult, PluginInstallLogger, PluginInstallPolicyRequest, @@ -71,27 +72,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( @@ -584,6 +586,7 @@ export async function installPluginFromManagedNpmRoot( } } const result = await installPluginFromInstalledPackageDir({ + publicationAuthority: params.publicationAuthority, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, onInstallPolicyWarning: params.onInstallPolicyWarning, config: params.config, 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.ts b/src/plugins/install-npm.ts index 302354a8ad18..47bb9d253662 100644 --- a/src/plugins/install-npm.ts +++ b/src/plugins/install-npm.ts @@ -37,6 +37,7 @@ import { } from "./install-shared.js"; import { PLUGIN_INSTALL_ERROR_CODE, + type InstallPublicationOptions, type InstallPluginResult, type PluginInstallLogger, type PluginNpmIntegrityDriftParams, @@ -44,21 +45,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; - installPolicyRequestedSpecifier?: 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( @@ -257,6 +259,7 @@ export async function installPluginFromNpmSpec( } const result = await installPluginFromManagedNpmRoot({ + publicationAuthority: params.publicationAuthority, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, onInstallPolicyWarning: params.onInstallPolicyWarning, trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, diff --git a/src/plugins/install-package.ts b/src/plugins/install-package.ts index cf485bec0017..5fd6b01773d2 100644 --- a/src/plugins/install-package.ts +++ b/src/plugins/install-package.ts @@ -48,6 +48,7 @@ function pickPackageInstallCommonParams( config: params.config, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, onInstallPolicyWarning: params.onInstallPolicyWarning, + publicationAuthority: params.publicationAuthority, trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, extensionsDir: params.extensionsDir, npmDir: params.npmDir, @@ -193,6 +194,7 @@ async function installBundleFromSourceDir( copyErrorPrefix: "failed to copy plugin bundle", hasDeps: false, depsLogMessage: "", + publicationAuthority: params.publicationAuthority, }); return installed.ok ? { @@ -330,6 +332,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({ diff --git a/src/plugins/install-persistence.ts b/src/plugins/install-persistence.ts index 58d64a93baa4..3ff43b99df30 100644 --- a/src/plugins/install-persistence.ts +++ b/src/plugins/install-persistence.ts @@ -18,7 +18,7 @@ import { resolveUserPath, shortenHomePath } from "../utils.js"; import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.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, @@ -471,6 +471,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. @@ -544,6 +545,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.test.ts b/src/plugins/install-record-commit.test.ts index 0f5e0c97a83c..3a953cb5feba 100644 --- a/src/plugins/install-record-commit.test.ts +++ b/src/plugins/install-record-commit.test.ts @@ -975,6 +975,30 @@ describe("commitConfigWithPendingPluginInstalls", () => { ); }); + 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.writePersistedInstalledPluginIndexInstallRecordsWithLease).not.toHaveBeenCalled(); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + }); + it("leaves marker state intact when a successor owns the plugin index", async () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-record-commit-")); const installPath = path.join( diff --git a/src/plugins/install-record-commit.ts b/src/plugins/install-record-commit.ts index 8c68c8e50c0a..74ebedf1e3cd 100644 --- a/src/plugins/install-record-commit.ts +++ b/src/plugins/install-record-commit.ts @@ -389,6 +389,7 @@ async function commitPluginInstallRecordsWithWriter(params: { nextConfig: OpenClawConfig; writeOptions?: ConfigWriteOptions; commit: ConfigCommit; + commitPublication?: () => void; }): Promise<{ committed: ConfigReplaceResult | void; nextInstallRecords: Record; @@ -400,6 +401,7 @@ async function commitPluginInstallRecordsWithWriter(params: { try { const storeOptions = { filePath: lease.databasePath }; const prepared = await params.prepareInstallRecords(storeOptions); + params.commitPublication?.(); tentativeWrite = await writePersistedInstalledPluginIndexInstallRecordsWithLease( prepared.nextInstallRecords, { @@ -469,6 +471,7 @@ export async function commitPluginInstallRecordsWithConfig(params: { nextConfig: OpenClawConfig; baseHash?: string; writeOptions?: ConfigWriteOptions; + commitPublication?: () => void; }): Promise { await commitPluginInstallRecordsWithWriter({ prepareInstallRecords: async (storeOptions) => ({ @@ -478,6 +481,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-shared.ts b/src/plugins/install-shared.ts index 2c900760d856..30d1f2ac5c2a 100644 --- a/src/plugins/install-shared.ts +++ b/src/plugins/install-shared.ts @@ -6,6 +6,7 @@ import { resolveDefaultPluginExtensionsDir } from "./install-paths.js"; import type { InstallSecurityScanResult } from "./install-security-scan.js"; import { PLUGIN_INSTALL_ERROR_CODE, + type InstallPublicationAuthority, type InstallPluginResult, type PackageManifest, type PluginInstallErrorCode, @@ -380,6 +381,7 @@ export async function installPluginDirectoryIntoExtensions(params: { afterInstall?: ( installedDir: string, ) => Promise | null>; + publicationAuthority?: InstallPublicationAuthority; nameEncoder?: (pluginId: string) => string; }): Promise { const runtime = await loadPluginInstallRuntime(); @@ -434,6 +436,7 @@ export async function installPluginDirectoryIntoExtensions(params: { } return postInstallResult; }, + publicationAuthority: params.publicationAuthority, }); if (!installRes.ok) { return installRes; diff --git a/src/plugins/install-types.ts b/src/plugins/install-types.ts index dc90bbd15a46..1bf343c8f71d 100644 --- a/src/plugins/install-types.ts +++ b/src/plugins/install-types.ts @@ -74,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/management-service-install-policy.test.ts b/src/plugins/management-service-install-policy.test.ts index 44091f124056..5024597d587b 100644 --- a/src/plugins/management-service-install-policy.test.ts +++ b/src/plugins/management-service-install-policy.test.ts @@ -202,6 +202,14 @@ describe("plugin management install-policy acknowledgements", () => { 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); @@ -372,6 +380,10 @@ describe("plugin management install-policy acknowledgements", () => { source: "official", pluginId: "npm-demo", installPolicyWarningAcknowledgement: { + publicationAuthority: { + assertCurrent: () => {}, + commit: () => {}, + }, warnings: [warning], resolvedRequest, }, diff --git a/src/plugins/management-service.ts b/src/plugins/management-service.ts index 001ecd26d502..9481b2f8dccb 100644 --- a/src/plugins/management-service.ts +++ b/src/plugins/management-service.ts @@ -41,7 +41,7 @@ import { import { commitPluginInstallRecordsWithConfig } from "./install-record-commit.js"; import type { InstallSafetyOverrides } from "./install-security-scan.js"; import type { InstallPolicyWarningOccurrence } from "./install-security-scan.types.js"; -import type { PluginInstallLogger } from "./install-types.js"; +import type { InstallPublicationAuthority, PluginInstallLogger } from "./install-types.js"; import { installPluginFromNpmPackArchive, installPluginFromNpmSpec, @@ -118,24 +118,24 @@ type ManagedPluginCatalog = { mutationAllowed: boolean; }; +export type ManagedPluginInstallPolicyAcknowledgement = { + warnings: InstallPolicyWarningOccurrence[]; + resolvedRequest: ManagedPluginSourceInstallRequest; + publicationAuthority: InstallPublicationAuthority; +}; + export type ManagedPluginInstallRequest = | { source: "clawhub"; packageName: string; version?: string; acknowledgeClawHubRisk?: boolean; - installPolicyWarningAcknowledgement?: { - warnings: InstallPolicyWarningOccurrence[]; - resolvedRequest: ManagedPluginSourceInstallRequest; - }; + installPolicyWarningAcknowledgement?: ManagedPluginInstallPolicyAcknowledgement; } | { source: "official"; pluginId: string; - installPolicyWarningAcknowledgement?: { - warnings: InstallPolicyWarningOccurrence[]; - resolvedRequest: ManagedPluginSourceInstallRequest; - }; + installPolicyWarningAcknowledgement?: ManagedPluginInstallPolicyAcknowledgement; }; export type ManagedPluginSourceInstallRequest = @@ -1191,6 +1191,7 @@ async function persistManagedSourceInstall(params: { persistenceLogger?: PluginInstallLogger; successMessage?: string; cleanupOnPersistenceFailure?: boolean; + publicationAuthority?: InstallPublicationAuthority; }): Promise { const persist = () => persistPluginInstall({ @@ -1201,6 +1202,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(); @@ -1225,6 +1227,7 @@ export async function installManagedPluginSource(params: { runtime?: RuntimeEnv; invalidateRuntimeCache?: boolean; cleanupOnPersistenceFailure?: boolean; + publicationAuthority?: InstallPublicationAuthority; }): Promise { const { request } = params; const env = params.env ?? process.env; @@ -1250,6 +1253,7 @@ export async function installManagedPluginSource(params: { config: params.snapshot.config, extensionsDir, logger: params.logger, + publicationAuthority: params.publicationAuthority, }; const complete = async ( installResult: Promise, @@ -1545,6 +1549,8 @@ export async function installManagedPlugin(params: { env, logger: installLogger, persistenceLogger: installLogger, + publicationAuthority: + params.request.installPolicyWarningAcknowledgement?.publicationAuthority, ...(params.request.installPolicyWarningAcknowledgement ? { safetyOverrides: { diff --git a/src/plugins/test-helpers/install-policy-warning.ts b/src/plugins/test-helpers/install-policy-warning.ts index cea51e40d237..09ca0f8edc9f 100644 --- a/src/plugins/test-helpers/install-policy-warning.ts +++ b/src/plugins/test-helpers/install-policy-warning.ts @@ -29,6 +29,10 @@ export const officialDiffsWarningRequest = { source: "official", pluginId: "diffs", installPolicyWarningAcknowledgement: { + publicationAuthority: { + assertCurrent: () => {}, + commit: () => {}, + }, resolvedRequest: { source: "clawhub", spec: "clawhub:@openclaw/diffs@2026.6.11",