fix(gateway): fence approved plugin publication

This commit is contained in:
jesse-merhi
2026-08-13 02:59:36 +10:00
parent 248261dbb6
commit 6608df1558
19 changed files with 421 additions and 98 deletions
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"acc5ea798de9e4dcec8592770178ec56752f2e490d3f6b0bdebba1957be0416c","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"ddeb05e8e39f31b9a1518e1d5d79b9eebf4f8ad245f41caeb1373a70adf99b26","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+115
View File
@@ -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<void>((resolve) => {
resumeInstall = resolve;
});
managementMocks.install.mockImplementationOnce(async ({ request }) => {
await installCanPublish;
request.installPolicyWarningAcknowledgement?.publicationAuthority.commit();
return {
plugin: { ...workboard, id: "diffs", name: "Diffs", enabled: true, state: "enabled" },
};
});
const retry = callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
installPolicyWarningAcknowledgement: acknowledgementToken,
});
await vi.waitFor(() => expect(managementMocks.install).toHaveBeenCalledTimes(2));
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],
},
+34 -1
View File
@@ -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<InstallPolicyAcknowledgement, "resolvedRequest" | "warnings"> | 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),
};
}
+84
View File
@@ -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");
+6
View File
@@ -185,6 +185,10 @@ export async function installPackageDir<
depsLogMessage: string;
afterCopy?: (installedDir: string) => void | Promise<void>;
afterInstall?: (installedDir: string) => Promise<InstallPackageDirSuccess | TAfterInstallFailure>;
publicationAuthority?: {
assertCurrent: () => void;
commit: () => void;
};
}): Promise<InstallPackageDirSuccess | InstallPackageDirFailure | TAfterInstallFailure> {
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,
+19 -16
View File
@@ -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<boolean>;
},
params: InstallSafetyOverrides &
InstallPublicationOptions & {
spec: string;
baseUrl?: string;
token?: string;
logger?: PluginInstallLogger;
mode?: "install" | "update";
extensionsDir?: string;
timeoutMs?: number;
dryRun?: boolean;
expectedPluginId?: string;
expectedIntegrity?: string;
installPolicyRequestedSpecifier?: string;
env?: RuntimeVersionEnv;
acknowledgeClawHubRisk?: boolean;
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise<boolean>;
},
): Promise<
| ({
ok: true;
@@ -1445,6 +1447,7 @@ export async function installPluginFromClawHub(
);
const installResult = await installPluginFromArchive({
archivePath: archive.archivePath,
publicationAuthority: params.publicationAuthority,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
onInstallPolicyWarning: params.onInstallPolicyWarning,
trustedSourceLinkedOfficialInstall:
+13 -10
View File
@@ -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<GitPluginInstallResult> {
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,
+24 -21
View File
@@ -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<InstallPluginResult> {
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,
+16 -13
View File
@@ -27,6 +27,7 @@ import {
} from "./install-shared.js";
import {
PLUGIN_INSTALL_ERROR_CODE,
type InstallPublicationOptions,
type InstallPluginResult,
type PluginInstallErrorCode,
type PluginInstallLogger,
@@ -156,19 +157,20 @@ async function stageNpmPackArchiveInManagedRoot(params: {
}
export async function installPluginFromNpmPackArchive(
params: InstallSafetyOverrides & {
archivePath: string;
extensionsDir?: string;
npmDir?: string;
timeoutMs?: number;
signal?: AbortSignal;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
expectedIntegrity?: string;
onIntegrityDrift?: (params: PluginNpmIntegrityDriftParams) => boolean | Promise<boolean>;
},
params: InstallSafetyOverrides &
InstallPublicationOptions & {
archivePath: string;
extensionsDir?: string;
npmDir?: string;
timeoutMs?: number;
signal?: AbortSignal;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
expectedIntegrity?: string;
onIntegrityDrift?: (params: PluginNpmIntegrityDriftParams) => boolean | Promise<boolean>;
},
): Promise<InstallPluginResult & { npmTarballName?: string }> {
const runtime = await loadPluginInstallRuntime();
const { logger, timeoutMs, mode, dryRun } = runtime.resolveTimedInstallModeOptions(
@@ -233,6 +235,7 @@ export async function installPluginFromNpmPackArchive(
: targetMode;
const result = await installPluginFromManagedNpmRoot({
publicationAuthority: params.publicationAuthority,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
onInstallPolicyWarning: params.onInstallPolicyWarning,
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
+18 -15
View File
@@ -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<boolean>;
},
params: InstallSafetyOverrides &
InstallPublicationOptions & {
spec: string;
extensionsDir?: string;
npmDir?: string;
timeoutMs?: number;
signal?: AbortSignal;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
expectedReplacementPluginId?: string;
expectedIntegrity?: string;
installPolicyRequestedSpecifier?: string;
onIntegrityDrift?: (params: PluginNpmIntegrityDriftParams) => boolean | Promise<boolean>;
},
): Promise<InstallPluginResult> {
const runtime = await loadPluginInstallRuntime();
const { logger, timeoutMs, mode, dryRun } = runtime.resolveTimedInstallModeOptions(
@@ -257,6 +259,7 @@ export async function installPluginFromNpmSpec(
}
const result = await installPluginFromManagedNpmRoot({
publicationAuthority: params.publicationAuthority,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
onInstallPolicyWarning: params.onInstallPolicyWarning,
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
+3
View File
@@ -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({
+5 -1
View File
@@ -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<OpenClawConfig> {
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" },
+24
View File
@@ -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(
+4
View File
@@ -389,6 +389,7 @@ async function commitPluginInstallRecordsWithWriter(params: {
nextConfig: OpenClawConfig;
writeOptions?: ConfigWriteOptions;
commit: ConfigCommit;
commitPublication?: () => void;
}): Promise<{
committed: ConfigReplaceResult | void;
nextInstallRecords: Record<string, PluginInstallRecord>;
@@ -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<void> {
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({
+3
View File
@@ -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<Extract<InstallPluginResult, { ok: false }> | null>;
publicationAuthority?: InstallPublicationAuthority;
nameEncoder?: (pluginId: string) => string;
}): Promise<InstallPluginResult> {
const runtime = await loadPluginInstallRuntime();
@@ -434,6 +436,7 @@ export async function installPluginDirectoryIntoExtensions(params: {
}
return postInstallResult;
},
publicationAuthority: params.publicationAuthority,
});
if (!installRes.ok) {
return installRes;
+21 -11
View File
@@ -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;
};
@@ -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,
},
+15 -9
View File
@@ -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<OpenClawConfig> {
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<ManagedPluginSourceInstallResult> {
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 <T extends SourceInstallerResult>(
installResult: Promise<T>,
@@ -1545,6 +1549,8 @@ export async function installManagedPlugin(params: {
env,
logger: installLogger,
persistenceLogger: installLogger,
publicationAuthority:
params.request.installPolicyWarningAcknowledgement?.publicationAuthority,
...(params.request.installPolicyWarningAcknowledgement
? {
safetyOverrides: {
@@ -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",