mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(gateway): fence approved plugin publication
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"acc5ea798de9e4dcec8592770178ec56752f2e490d3f6b0bdebba1957be0416c","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
{"contentHash":"ddeb05e8e39f31b9a1518e1d5d79b9eebf4f8ad245f41caeb1373a70adf99b26","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user