fix(security): bind approvals to resolved artifacts

This commit is contained in:
jesse-merhi
2026-08-10 23:42:36 +10:00
parent ef88c58712
commit 85fe665b3c
18 changed files with 761 additions and 260 deletions
+1 -1
View File
@@ -551,7 +551,7 @@ methods. Treat this as feature discovery, not a full enumeration of
<Accordion title="Plugin management">
- `plugins.list` (`operator.read`) returns the installed plugin inventory plus locally curated official picks, diagnostics, and whether the current install mode allows mutations.
- `plugins.search` (`operator.read`) searches installable ClawHub code-plugin and bundle-plugin families. Pass non-empty `query` and optional `limit` from 1 to 100.
- `plugins.install` (`operator.admin`) installs either an official catalog entry with `{ source: "official", pluginId, installPolicyWarningAcknowledgement? }` or a ClawHub package with `{ source: "clawhub", packageName, version?, acknowledgeClawHubRisk?, installPolicyWarningAcknowledgement? }`. ClawHub installs preserve Gateway trust, integrity, and install-policy checks. When install policy warns, the request fails before commit with structured `error.details` containing `installPolicyCode`, target metadata, `reason`, optional `findings`, and a server-issued `acknowledgementToken`. After showing those details, a client may retry with that token as `installPolicyWarningAcknowledgement`. The Gateway consumes the token once and only for the same install request and resolved artifact; policy then re-evaluates the staged source and continues only when the warning is unchanged. A block or changed/later warning remains terminal for that request and is returned with its own structured details and token. Successful installs require a Gateway restart.
- `plugins.install` (`operator.admin`) installs either an official catalog entry with `{ source: "official", pluginId, installPolicyWarningAcknowledgement? }` or a ClawHub package with `{ source: "clawhub", packageName, version?, acknowledgeClawHubRisk?, installPolicyWarningAcknowledgement? }`. ClawHub installs preserve Gateway trust, integrity, and install-policy checks. When install policy warns and the Gateway can bind the request to an immutable resolved artifact, the request fails before commit with structured `error.details` containing `installPolicyCode`, target metadata, `reason`, optional `findings`, and a server-issued `acknowledgementToken`. After showing those details, a client may retry with that token as `installPolicyWarningAcknowledgement`. The Gateway consumes the token once and only for the same install request and resolved artifact; policy then re-evaluates the staged source and continues only when the warning is unchanged. A changed or later warning fails that request with its own structured details and, when the artifact remains immutably resolved, a fresh token for another reviewed retry. A block or a warning without immutable resolution metadata is terminal and does not include an acknowledgement token. Successful installs require a Gateway restart.
- `plugins.setEnabled` (`operator.admin`) changes one installed plugin's enabled policy with `{ pluginId, enabled }`. The response includes the updated catalog entry, restart metadata, and any slot-selection warnings.
- `plugins.uninstall` (`operator.admin`) removes one externally installed plugin with `{ pluginId }`: config references, the install record, and managed files. Bundled plugins cannot be uninstalled, only disabled. The response lists the removal actions and always requires a Gateway restart.
+13 -10
View File
@@ -153,19 +153,22 @@ copy as suspicious ClawHub releases; policy is then re-evaluated. Reviewed
non-interactive direct CLI commands can use `--acknowledge-install-policy-warning`.
That flag is consumed by the first warning in one command; a later warning
fails closed and requires interactive review.
Gateway `plugins.install` clients receive structured warning details and may
make one explicit retry with the returned `acknowledgementToken` as
Gateway `plugins.install` clients receive structured warning details when the
Gateway can bind the request to an immutable resolved artifact, and may make
one explicit retry with the returned `acknowledgementToken` as
`installPolicyWarningAcknowledgement`. The Gateway consumes that server-issued
token once and only for the same install request and resolved artifact. OpenClaw
re-evaluates the staged source and continues only when the warning is unchanged.
A block, a changed warning, or a warning from a later package or dependency scan
stops the request before commit and returns its own details. Other Gateway-backed
and automatic installs remain blocked on warnings because they have no
operator-confirmation flow. When an equivalent direct plugin or skill command
exists, use that command to review and approve the warning. Otherwise, change
`security.installPolicy` to return `allow` for the reviewed request, then retry
the managed flow. Neither `--force` nor the deprecated plugin install/update
flag `--dangerously-force-unsafe-install` approves a policy warning. Plugin
A changed or later warning stops the request before commit and receives a fresh
token when the artifact remains immutably resolved. A block or a warning without
immutable resolution metadata is terminal and has no acknowledgement token.
Other Gateway-backed and automatic installs remain blocked on warnings because
they have no operator-confirmation flow. When an equivalent direct plugin or
skill command exists, use that command to review and approve the warning.
Otherwise, change `security.installPolicy` to return `allow` for the reviewed
request, then retry the managed flow. Neither `--force` nor the deprecated
plugin install/update flag `--dangerously-force-unsafe-install` approves a
policy warning. Plugin
`before_install` hooks run later, and only in OpenClaw processes where plugin
hooks are loaded, so use `security.installPolicy` for operator-owned install
decisions instead. `--acknowledge-install-policy-warning` does not override a
+7 -5
View File
@@ -201,13 +201,15 @@ approval after review;
every approved warning is re-evaluated before continuing.
The flag is consumed by the first warning in one command; a later warning
fails closed and requires interactive review.
Gateway `plugins.install` clients receive structured warning details and may
make one explicit retry with the returned `acknowledgementToken` as
Gateway `plugins.install` clients receive structured warning details when the
Gateway can bind the request to an immutable resolved artifact, and may make
one explicit retry with the returned `acknowledgementToken` as
`installPolicyWarningAcknowledgement`. The Gateway consumes that server-issued
token once and only for the same install request and resolved artifact. OpenClaw
re-evaluates the staged source and continues only when the warning is unchanged. A block, a
changed warning, or a warning from a later package or dependency scan stops the
request before commit and returns its own details. Other
re-evaluates the staged source and continues only when the warning is unchanged.
A changed or later warning stops the request before commit and receives a fresh
token when the artifact remains immutably resolved. A block or a warning without
immutable resolution metadata is terminal and has no acknowledgement token. Other
Gateway-backed and automatic installs remain blocked on warnings because they
have no operator-confirmation flow. Use an equivalent direct plugin or skill
command to review and approve the warning when one exists. Otherwise, change
@@ -89,8 +89,8 @@ export function readInstallPolicyWarningErrorDetails(
return undefined;
}
findings = [];
for (const value of record.findings) {
const finding = readFinding(value);
for (const findingValue of record.findings) {
const finding = readFinding(findingValue);
if (!finding) {
return undefined;
}
+43
View File
@@ -277,6 +277,49 @@ describe("installPackageDir", () => {
).resolves.toHaveLength(0);
});
it("preserves caller-owned failure details from staged post-install validation", 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 installPolicyWarning = {
targetName: "demo",
targetType: "plugin" as const,
requestMode: "install" as const,
reason: "Review the installed dependency tree",
};
const result = await installPackageDir({
sourceDir,
targetDir,
mode: "install",
timeoutMs: 1_000,
copyErrorPrefix: "failed to copy plugin",
hasDeps: false,
depsLogMessage: "Installing deps…",
afterInstall: async () => ({
ok: false as const,
error: installPolicyWarning.reason,
code: "security_scan_blocked",
installPolicyWarning,
}),
});
expect(result).toEqual({
ok: false,
error: installPolicyWarning.reason,
code: "security_scan_blocked",
installPolicyWarning,
});
await expectMissingPath(targetDir);
await expect(
listMatchingDirs(installBaseDir, ".openclaw-install-stage-"),
).resolves.toHaveLength(0);
});
it("restores the original install if publish rename fails", async () => {
await fixtureRootTracker.setup();
const fixtureRoot = await fixtureRootTracker.make("case");
+18 -16
View File
@@ -26,6 +26,9 @@ type HiddenProjectConfigFile = {
hiddenPath: string;
} | null;
type InstallPackageDirFailure = { ok: false; error: string };
type InstallPackageDirSuccess = { ok: true };
async function sanitizeManifestForNpmInstall(targetDir: string): Promise<void> {
const manifestPath = path.join(targetDir, "package.json");
const parsed = await tryReadJson<unknown>(manifestPath);
@@ -168,7 +171,9 @@ async function resolveInstallPublishTarget(params: {
* Update mode backs up the existing target, runs optional validation hooks,
* and rolls back when copy, dependency install, or validation fails.
*/
export async function installPackageDir(params: {
export async function installPackageDir<
TAfterInstallFailure extends InstallPackageDirFailure = InstallPackageDirFailure,
>(params: {
sourceDir: string;
targetDir: string;
mode: "install" | "update";
@@ -179,10 +184,8 @@ export async function installPackageDir(params: {
sourceHardlinks?: InstallSourceHardlinks;
depsLogMessage: string;
afterCopy?: (installedDir: string) => void | Promise<void>;
afterInstall?: (
installedDir: string,
) => Promise<{ ok: true } | { ok: false; error: string; code?: string }>;
}): Promise<{ ok: true } | { ok: false; error: string; code?: string }> {
afterInstall?: (installedDir: string) => Promise<InstallPackageDirSuccess | TAfterInstallFailure>;
}): Promise<InstallPackageDirSuccess | InstallPackageDirFailure | TAfterInstallFailure> {
params.logger?.info?.(`Installing to ${params.targetDir}`);
const installBaseDir = path.dirname(params.targetDir);
let initialInstallBaseRealPath: string;
@@ -231,10 +234,6 @@ export async function installPackageDir(params: {
}
return { ok: false as const, error };
};
const failWithCode = async (paramsLocal: { error: string; code?: string }, cause?: unknown) => {
const failed = await fail(paramsLocal.error, cause);
return paramsLocal.code ? { ...failed, code: paramsLocal.code } : failed;
};
const restoreBackup = async () => {
if (!backupDir) {
return;
@@ -306,7 +305,10 @@ export async function installPackageDir(params: {
try {
const postInstallResult = await params.afterInstall(stageDir);
if (!postInstallResult.ok) {
return await failWithCode(postInstallResult);
const failed = await fail(postInstallResult.error);
// The caller owns post-install failure metadata. Preserve the exact
// result so domain details survive cleanup and rollback.
return { ...postInstallResult, error: failed.error };
}
} catch (err) {
return await fail(`post-install validation failed: ${String(err)}`, err);
@@ -378,7 +380,9 @@ export async function installPackageDir(params: {
* Installs a manifest-backed package directory while deriving whether npm
* dependencies must be installed and which hardlink policy is safe to use.
*/
export async function installPackageDirWithManifestDeps(params: {
export async function installPackageDirWithManifestDeps<
TAfterInstallFailure extends InstallPackageDirFailure = InstallPackageDirFailure,
>(params: {
sourceDir: string;
targetDir: string;
mode: "install" | "update";
@@ -388,12 +392,10 @@ export async function installPackageDirWithManifestDeps(params: {
depsLogMessage: string;
manifestDependencies?: Record<string, unknown>;
afterCopy?: (installedDir: string) => void | Promise<void>;
afterInstall?: (
installedDir: string,
) => Promise<{ ok: true } | { ok: false; error: string; code?: string }>;
}): Promise<{ ok: true } | { ok: false; error: string; code?: string }> {
afterInstall?: (installedDir: string) => Promise<InstallPackageDirSuccess | TAfterInstallFailure>;
}): Promise<InstallPackageDirSuccess | InstallPackageDirFailure | TAfterInstallFailure> {
const hasDeps = Object.keys(params.manifestDependencies ?? {}).length > 0;
return installPackageDir({
return installPackageDir<TAfterInstallFailure>({
...params,
hasDeps,
sourceHardlinks: hasDeps ? "package-manager" : "reject",
+6 -1
View File
@@ -299,6 +299,7 @@ type InstallSuccess = {
type InstallFailure = {
code?: string;
error: string;
integrity?: string;
ok: false;
version?: string;
warning?: string;
@@ -1794,7 +1795,11 @@ describe("installPluginFromClawHub", () => {
baseUrl: "https://clawhub.ai",
});
expect(expectInstallFailure(result).error).toBe("bad archive");
expect(expectInstallFailure(result)).toMatchObject({
error: "bad archive",
integrity: DEMO_ARCHIVE_INTEGRITY,
version: "2026.3.22",
});
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
});
+6 -1
View File
@@ -73,6 +73,7 @@ type ClawHubInstallFailure = {
ok: false;
error: string;
code?: ClawHubInstallErrorCode;
integrity?: string;
warning?: string;
version?: string;
};
@@ -1466,7 +1467,11 @@ export async function installPluginFromClawHub(
},
});
if (!installResult.ok) {
return { ...installResult, version: versionState.version };
return {
...installResult,
integrity: archive.integrity,
version: versionState.version,
};
}
const pkg = detail.package!;
+2 -2
View File
@@ -246,7 +246,7 @@ export async function installPluginFromNpmSpec(
}),
});
if (preflightPolicyResult) {
return preflightPolicyResult;
return { ...preflightPolicyResult, npmResolution };
}
} finally {
await fs.rm(policyTempDir, { recursive: true, force: true });
@@ -287,5 +287,5 @@ export async function installPluginFromNpmSpec(
sourceFamily: "npm",
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
});
return result;
return result.ok ? result : { ...result, npmResolution };
}
+54
View File
@@ -0,0 +1,54 @@
import fs from "node:fs";
import path from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { installPluginDirectoryIntoExtensions } from "./install-shared.js";
import { PLUGIN_INSTALL_ERROR_CODE } from "./install-types.js";
import { createSuiteTempRootTracker } from "./test-helpers/fs-fixtures.js";
describe("installPluginDirectoryIntoExtensions", () => {
const tempRoots = createSuiteTempRootTracker("openclaw-install-shared");
afterAll(() => tempRoots.cleanup());
it("preserves structured warnings returned by the staged dependency scan", async () => {
const fixtureRoot = tempRoots.makeTempDir();
const sourceDir = path.join(fixtureRoot, "source");
const targetDir = path.join(fixtureRoot, "extensions", "demo");
fs.mkdirSync(sourceDir, { recursive: true });
fs.writeFileSync(path.join(sourceDir, "index.js"), "export default {};\n");
const installPolicyWarning = {
targetName: "demo",
targetType: "plugin" as const,
requestMode: "install" as const,
reason: "Review the installed dependency tree",
};
const result = await installPluginDirectoryIntoExtensions({
sourceDir,
targetDir,
pluginId: "demo",
extensions: ["index.js"],
logger: {},
timeoutMs: 1_000,
mode: "install",
dryRun: false,
copyErrorPrefix: "failed to copy plugin",
hasDeps: false,
depsLogMessage: "Installing dependencies…",
afterInstall: async () => ({
ok: false,
error: installPolicyWarning.reason,
code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED,
installPolicyWarning,
}),
});
expect(result).toEqual({
ok: false,
error: installPolicyWarning.reason,
code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED,
installPolicyWarning,
});
expect(fs.existsSync(targetDir)).toBe(false);
});
});
+2 -10
View File
@@ -432,19 +432,11 @@ export async function installPluginDirectoryIntoExtensions(params: {
if (!postInstallResult) {
return { ok: true as const };
}
return {
ok: false as const,
error: postInstallResult.error,
...(postInstallResult.code ? { code: postInstallResult.code } : {}),
};
return postInstallResult;
},
});
if (!installRes.ok) {
return {
ok: false,
error: installRes.error,
...(installRes.code ? { code: installRes.code as PluginInstallErrorCode } : {}),
};
return installRes;
}
return buildDirectoryInstallResult({
+1
View File
@@ -56,6 +56,7 @@ export type InstallPluginResult =
error: string;
code?: PluginInstallErrorCode;
installPolicyWarning?: InstallPolicyWarningDetails;
npmResolution?: NpmSpecResolution;
};
export type PluginInstallFailureResult = Extract<InstallPluginResult, { ok: false }>;
+78 -14
View File
@@ -79,35 +79,47 @@ async function readJson<T>(filePath: string): Promise<T> {
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
}
const installedPackageTreePolicySource = `
function installPolicySource(
decision: "block" | "warn",
sourcePathKind: "directory" | "file" = "directory",
): string {
const subject = sourcePathKind === "directory" ? "installed package tree" : "npm metadata";
const reason = `${decision === "block" ? "blocked" : "review"} ${subject}`;
return `
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { input += chunk; });
process.stdin.on("end", () => {
const request = JSON.parse(input);
if (request.sourcePathKind === "directory") {
if (request.sourcePathKind === "${sourcePathKind}") {
process.stdout.write(JSON.stringify({
protocolVersion: 1,
decision: "block",
reason: "blocked installed package tree",
decision: "${decision}",
reason: "${reason}",
}));
return;
}
process.stdout.write(JSON.stringify({ protocolVersion: 1, decision: "allow" }));
});
`;
}
async function createInstalledPackageTreePolicyExec(rootDir: string) {
async function createInstalledPackageTreePolicyExec(
rootDir: string,
decision: "block" | "warn" = "block",
sourcePathKind: "directory" | "file" = "directory",
) {
const policySource = installPolicySource(decision, sourcePathKind);
if (process.platform === "win32") {
return { command: process.execPath, args: ["-e", installedPackageTreePolicySource] };
return { command: process.execPath, args: ["-e", policySource] };
}
const command = path.join(rootDir, "install-policy.cjs");
await fs.writeFile(command, `#!${process.execPath}\n${installedPackageTreePolicySource}`, "utf8");
await fs.writeFile(command, `#!${process.execPath}\n${policySource}`, "utf8");
await fs.chmod(command, 0o700);
return { command, args: [] };
}
function configWithInstalledPackageTreeBlockPolicy(exec: {
function configWithInstalledPackageTreePolicy(exec: {
command: string;
args: string[];
}): OpenClawConfig {
@@ -742,15 +754,14 @@ describe("installPluginFromNpmSpec e2e", () => {
]);
const result = await installNpmPlugin({
config: configWithInstalledPackageTreeBlockPolicy(policyExec),
config: configWithInstalledPackageTreePolicy(policyExec),
spec: `${blockedPlugin}@1.0.0`,
npmRoot,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain("blocked by install policy: blocked installed package tree");
expect(result.error).toContain("blocked installed package tree");
}
const projectRoot = pluginNpmProjectRoot(npmRoot, blockedPlugin);
try {
@@ -772,6 +783,60 @@ describe("installPluginFromNpmSpec e2e", () => {
).rejects.toHaveProperty("code", "ENOENT");
});
it("preserves npm resolution on installed package policy warnings", async () => {
const { rootDir, npmRoot } = await makeInstallFixture("npm-plugin-policy-warning-e2e");
const policyExec = await createInstalledPackageTreePolicyExec(rootDir, "warn");
const packageName = uniquePackageName("warning-plugin");
const packageVersion = await packPlugin({ packageName, rootDir });
await useStaticRegistry([
{ packageName, latest: packageVersion.version, versions: [packageVersion] },
]);
const result = await installNpmPlugin({
config: configWithInstalledPackageTreePolicy(policyExec),
spec: `${packageName}@latest`,
npmRoot,
});
if (result.ok) {
throw new Error("expected installed package policy warning");
}
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.installPolicyWarning?.reason).toBe("review installed package tree");
expect(result.npmResolution).toMatchObject({
resolvedSpec: `${packageName}@${packageVersion.version}`,
integrity: packageVersion.integrity,
});
await expect(
fs.lstat(path.join(pluginNpmProjectRoot(npmRoot, packageName), "node_modules", packageName)),
).rejects.toHaveProperty("code", "ENOENT");
});
it("preserves npm resolution on preflight policy warnings", async () => {
const { rootDir, npmRoot } = await makeInstallFixture("npm-plugin-preflight-warning-e2e");
const policyExec = await createInstalledPackageTreePolicyExec(rootDir, "warn", "file");
const packageName = uniquePackageName("preflight-warning-plugin");
const packageVersion = await packPlugin({ packageName, rootDir });
await useStaticRegistry([
{ packageName, latest: packageVersion.version, versions: [packageVersion] },
]);
const result = await installNpmPlugin({
config: configWithInstalledPackageTreePolicy(policyExec),
spec: `${packageName}@latest`,
npmRoot,
});
if (result.ok) {
throw new Error("expected preflight policy warning");
}
expect(result.installPolicyWarning?.reason).toBe("review npm metadata");
expect(result.npmResolution).toMatchObject({
resolvedSpec: `${packageName}@${packageVersion.version}`,
integrity: packageVersion.integrity,
});
});
it("falls back to the legacy npm peer mode inside the plugin project when npm cannot plan third-party peers", async () => {
const { rootDir, npmRoot } = await makeInstallFixture("npm-plugin-peer-plan-fallback-e2e");
const blockedPlugin = uniquePackageName("missing-peer-plugin");
@@ -831,15 +896,14 @@ describe("installPluginFromNpmSpec e2e", () => {
});
const result = await installNpmPlugin({
config: configWithInstalledPackageTreeBlockPolicy(policyExec),
config: configWithInstalledPackageTreePolicy(policyExec),
spec: `${blockedPlugin}@1.0.0`,
npmRoot,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain("blocked by install policy: blocked installed package tree");
expect(result.error).toContain("blocked installed package tree");
}
const rootManifest = await readJson<{
dependencies?: Record<string, string>;
@@ -1,4 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
emptyMetadataSnapshot,
metadataSnapshot,
} from "./test-helpers/management-service-fixtures.js";
const mocks = vi.hoisted(() => ({
metadata: vi.fn(),
@@ -19,64 +23,6 @@ vi.mock("./official-external-plugin-catalog.js", async (importOriginal) => ({
const { clearManagedPluginOfficialCatalogCache, listManagedPlugins, resolveManagedPluginIconUrl } =
await import("./management-service.js");
function metadataSnapshot(params: {
id?: string;
name?: string;
origin?: "bundled" | "global";
packageName?: string | null;
installRecord?: Record<string, unknown>;
featured?: boolean;
description?: string;
icon?: string;
}) {
const id = params.id ?? "workboard";
const packageName =
params.packageName === null ? undefined : (params.packageName ?? `@openclaw/${id}`);
const manifest = {
id,
name: params.name ?? "Workboard",
description: params.description ?? "Coordinate agent work in a shared board.",
catalog: { featured: params.featured ?? true, order: 10 },
...(params.icon ? { icon: params.icon } : {}),
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
origin: params.origin ?? "bundled",
rootDir: `/tmp/${id}`,
source: `/tmp/${id}/index.ts`,
manifestPath: `/tmp/${id}/openclaw.plugin.json`,
};
return {
index: {
plugins: [
{
pluginId: id,
...(packageName ? { packageName } : {}),
origin: params.origin ?? "bundled",
enabled: true,
},
],
installRecords: params.installRecord ? { [id]: params.installRecord } : {},
},
byPluginId: new Map([[id, manifest]]),
plugins: [manifest],
diagnostics: [],
normalizePluginId: (pluginId: string) => pluginId,
};
}
function emptyMetadataSnapshot() {
return {
index: { plugins: [], installRecords: {} },
byPluginId: new Map(),
plugins: [],
diagnostics: [],
normalizePluginId: (pluginId: string) => pluginId,
};
}
function hostedCatalog(entries: unknown[]) {
return {
source: "hosted",
@@ -0,0 +1,407 @@
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { InstallPolicyWarningDetails } from "./install-security-scan.types.js";
import {
expectOneShotInstallPolicyWarningAcknowledgement,
officialDiffsWarningRequest,
} from "./test-helpers/install-policy-warning.js";
import { metadataSnapshot } from "./test-helpers/management-service-fixtures.js";
const mocks = vi.hoisted(() => ({
applyUninstall: vi.fn(),
clawhubInstall: vi.fn(),
installRecords: vi.fn(),
metadata: vi.fn(),
npmInstall: vi.fn(),
officialCatalog: vi.fn(),
persistInstall: vi.fn(),
preflight: vi.fn(),
providerAuthChoices: vi.fn(),
readConfig: vi.fn(),
recommendedInstalls: vi.fn(),
refreshRegistry: vi.fn(),
replaceConfig: vi.fn(),
selectWriteOptions: vi.fn((writeOptions: unknown) => writeOptions),
slotSelection: vi.fn((config: unknown): { config: unknown; warnings: string[] } => ({
config,
warnings: [],
})),
}));
vi.mock("../config/config.js", () => ({
assertConfigWriteAllowedInCurrentMode: () => undefined,
readConfigFileSnapshotForWrite: () => mocks.readConfig(),
replaceConfigFile: (params: unknown) => mocks.replaceConfig(params),
}));
vi.mock("./install-persistence.js", () => ({
persistPluginInstall: (...args: unknown[]) => mocks.persistInstall(...args),
resolveInstallConfigMutationPreflights: (...args: unknown[]) => mocks.preflight(...args),
selectInstallMutationWriteOptions: (writeOptions: unknown) =>
mocks.selectWriteOptions(writeOptions),
}));
vi.mock("./slot-selection.js", () => ({
applySlotSelectionForPlugin: (config: unknown) => mocks.slotSelection(config),
}));
vi.mock("./registry-refresh.js", () => ({
refreshPluginRegistryAfterConfigMutation: (...args: unknown[]) => mocks.refreshRegistry(...args),
}));
vi.mock("./plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: (...args: unknown[]) => mocks.metadata(...args),
resolvePluginMetadataSnapshot: (...args: unknown[]) => mocks.metadata(...args),
}));
vi.mock("./clawhub.js", () => ({
installPluginFromClawHub: (...args: unknown[]) => mocks.clawhubInstall(...args),
}));
vi.mock("./install.js", () => ({
installPluginFromNpmSpec: (...args: unknown[]) => mocks.npmInstall(...args),
}));
vi.mock("./installed-plugin-index-records.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./installed-plugin-index-records.js")>()),
loadInstalledPluginIndexInstallRecords: (...args: unknown[]) => mocks.installRecords(...args),
}));
vi.mock("./uninstall.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./uninstall.js")>()),
applyPluginUninstallDirectoryRemoval: (...args: unknown[]) => mocks.applyUninstall(...args),
}));
vi.mock("./official-external-plugin-catalog.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./official-external-plugin-catalog.js")>()),
loadConfiguredHostedOfficialExternalPluginCatalogEntries: (...args: unknown[]) =>
mocks.officialCatalog(...args),
}));
vi.mock("./provider-auth-choices.js", () => ({
resolveManifestProviderAuthChoices: (...args: unknown[]) => mocks.providerAuthChoices(...args),
}));
vi.mock("./recommended-tool-installs.js", () => ({
listRecommendedToolInstalls: (...args: unknown[]) => mocks.recommendedInstalls(...args),
}));
const {
clearManagedPluginOfficialCatalogCache,
installManagedPlugin,
ManagedPluginLifecycleError,
} = await import("./management-service.js");
function configSnapshot() {
return {
snapshot: {
valid: true,
parsed: {},
path: "/tmp/openclaw.json",
sourceConfig: {},
hash: "base-hash",
},
writeOptions: {
expectedConfigPath: "/tmp/openclaw.json",
includeFileHashesForWrite: { "/tmp/plugins.json": "include-hash" },
includeFileTargetsForWrite: { "/tmp/plugins.json": "/tmp/plugins.json" },
},
};
}
function mockHostedOfficialCatalog(entries: unknown[]) {
mocks.officialCatalog.mockResolvedValue({
source: "hosted",
entries,
feed: { schemaVersion: 1, id: "test", generatedAt: "now", sequence: 1, entries: [] },
metadata: { url: "https://clawhub.ai/feed", status: 200, checksum: "hash" },
});
}
function mockClawHubInstall(pluginId: string, packageName: string) {
mocks.clawhubInstall.mockResolvedValue({
ok: true,
pluginId,
targetDir: `/tmp/extensions/${pluginId}`,
extensions: ["index.js"],
packageName,
clawhub: {
source: "clawhub",
clawhubUrl: "https://clawhub.ai",
clawhubPackage: packageName,
clawhubFamily: "code-plugin",
},
});
}
const hostedFeedDiffsEntry = {
id: "@openclaw/diffs",
title: "Diffs",
state: "available",
featured: true,
publisher: { id: "openclaw", trust: "official" },
install: {
candidates: [
{
sourceRef: "public-clawhub",
package: "@openclaw/diffs",
version: "2026.6.11",
integrity: `sha256:${"a".repeat(64)}`,
},
],
},
};
describe("plugin management install-policy acknowledgements", () => {
beforeEach(() => {
clearManagedPluginOfficialCatalogCache();
for (const mock of Object.values(mocks)) {
if (typeof mock === "function" && "mockReset" in mock) {
mock.mockReset();
}
}
mocks.selectWriteOptions.mockImplementation((writeOptions) => writeOptions);
mocks.preflight.mockReturnValue({
hookMutation: { mode: "allowed" },
pluginMutation: { mode: "allowed" },
});
mocks.slotSelection.mockImplementation((config) => ({ config, warnings: [] }));
mocks.installRecords.mockResolvedValue({});
mocks.applyUninstall.mockResolvedValue({ directoryRemoved: true, warnings: [] });
mocks.providerAuthChoices.mockReturnValue([]);
mocks.recommendedInstalls.mockReturnValue([]);
mockHostedOfficialCatalog([]);
});
it("threads hosted ClawHub candidate integrity into official installs", async () => {
mocks.readConfig.mockResolvedValue(configSnapshot());
mockHostedOfficialCatalog([hostedFeedDiffsEntry]);
mockClawHubInstall("diffs", "@openclaw/diffs");
mocks.persistInstall.mockResolvedValue({});
mocks.metadata.mockReturnValue(
metadataSnapshot({ enabled: true, id: "diffs", name: "Diffs", origin: "global" }),
);
await installManagedPlugin({
request: {
...officialDiffsWarningRequest,
installPolicyWarningAcknowledgement: {
...officialDiffsWarningRequest.installPolicyWarningAcknowledgement,
warnings: [...officialDiffsWarningRequest.installPolicyWarningAcknowledgement.warnings],
},
},
env: {},
});
expect(mocks.clawhubInstall).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:@openclaw/diffs@2026.6.11",
expectedPluginId: "diffs",
expectedIntegrity: `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`,
}),
);
await expectOneShotInstallPolicyWarningAcknowledgement(mocks.clawhubInstall);
});
it("acknowledges each reviewed install-policy warning once", async () => {
mocks.readConfig.mockResolvedValue(configSnapshot());
mockHostedOfficialCatalog([hostedFeedDiffsEntry]);
mockClawHubInstall("diffs", "@openclaw/diffs");
mocks.persistInstall.mockResolvedValue({});
mocks.metadata.mockReturnValue(
metadataSnapshot({ enabled: true, id: "diffs", name: "Diffs", origin: "global" }),
);
const firstWarning: InstallPolicyWarningDetails = expectDefined(
officialDiffsWarningRequest.installPolicyWarningAcknowledgement.warnings[0],
"first approved warning",
);
const secondWarning: InstallPolicyWarningDetails = {
...firstWarning,
reason: "Review the dependency warning",
};
await installManagedPlugin({
request: {
...officialDiffsWarningRequest,
installPolicyWarningAcknowledgement: {
...officialDiffsWarningRequest.installPolicyWarningAcknowledgement,
warnings: [firstWarning, secondWarning],
},
},
env: {},
});
const call = expectDefined(mocks.clawhubInstall.mock.calls[0], "clawhub install call");
const acknowledge = expectDefined(
(
call[0] as {
onInstallPolicyWarning?: (request: { warning: typeof firstWarning }) => Promise<boolean>;
}
).onInstallPolicyWarning,
"install-policy acknowledgement callback",
);
expect(await acknowledge({ warning: firstWarning })).toBe(true);
expect(await acknowledge({ warning: secondWarning })).toBe(true);
expect(await acknowledge({ warning: firstWarning })).toBe(false);
});
it("pins reviewed npm warnings to the first resolved version and integrity", async () => {
const warning = {
targetName: "npm-demo",
targetType: "plugin" as const,
requestMode: "install" as const,
reason: "Review this npm package",
};
const npmResolution = {
name: "@openclaw/npm-demo",
version: "1.2.3",
resolvedSpec: "@openclaw/npm-demo@1.2.3",
integrity: "sha512-reviewed",
resolvedAt: "2026-08-10T00:00:00.000Z",
};
mocks.readConfig.mockResolvedValue(configSnapshot());
mockHostedOfficialCatalog([
{
name: "@openclaw/npm-demo",
openclaw: {
plugin: { id: "npm-demo" },
install: { npmSpec: "@openclaw/npm-demo", defaultChoice: "npm" },
},
},
]);
mocks.npmInstall
.mockResolvedValueOnce({
ok: false,
error: warning.reason,
installPolicyWarning: warning,
npmResolution,
})
.mockResolvedValueOnce({ ok: false, error: "stop after inspecting the pinned retry" });
let firstFailure: unknown;
try {
await installManagedPlugin({
request: { source: "official", pluginId: "npm-demo" },
env: {},
});
} catch (error) {
firstFailure = error;
}
expect(firstFailure).toBeInstanceOf(ManagedPluginLifecycleError);
if (!(firstFailure instanceof ManagedPluginLifecycleError)) {
throw new Error("expected managed plugin lifecycle failure");
}
const resolvedRequest = expectDefined(
firstFailure.installPolicyResolvedRequest,
"pinned npm install request",
);
expect(resolvedRequest).toMatchObject({
source: "official",
spec: npmResolution.resolvedSpec,
expectedIntegrity: npmResolution.integrity,
});
await expect(
installManagedPlugin({
request: {
source: "official",
pluginId: "npm-demo",
installPolicyWarningAcknowledgement: {
warnings: [warning],
resolvedRequest,
},
},
env: {},
}),
).rejects.toThrow("stop after inspecting the pinned retry");
expect(mocks.npmInstall).toHaveBeenLastCalledWith(
expect.objectContaining({
spec: npmResolution.resolvedSpec,
expectedIntegrity: npmResolution.integrity,
}),
);
});
it("keeps npm warnings terminal when immutable resolution metadata is incomplete", async () => {
const warning = {
targetName: "npm-demo",
targetType: "plugin" as const,
requestMode: "install" as const,
reason: "Review this npm package",
};
mocks.readConfig.mockResolvedValue(configSnapshot());
mockHostedOfficialCatalog([
{
name: "@openclaw/npm-demo",
openclaw: {
plugin: { id: "npm-demo" },
install: { npmSpec: "@openclaw/npm-demo", defaultChoice: "npm" },
},
},
]);
mocks.npmInstall.mockResolvedValue({
ok: false,
error: warning.reason,
installPolicyWarning: warning,
npmResolution: {
name: "@openclaw/npm-demo",
version: "1.2.3",
resolvedSpec: "@openclaw/npm-demo@1.2.3",
resolvedAt: "2026-08-10T00:00:00.000Z",
},
});
let failure: unknown;
try {
await installManagedPlugin({
request: { source: "official", pluginId: "npm-demo" },
env: {},
});
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(ManagedPluginLifecycleError);
if (!(failure instanceof ManagedPluginLifecycleError)) {
throw new Error("expected managed plugin lifecycle failure");
}
expect(failure.installPolicyWarning).toEqual(warning);
expect(failure.installPolicyResolvedRequest).toBeUndefined();
expect(failure.message).toContain("immutable artifact resolution metadata");
});
it("pins reviewed ClawHub warnings to the downloaded archive integrity", async () => {
const warning = {
targetName: "demo",
targetType: "plugin" as const,
requestMode: "install" as const,
reason: "Review this ClawHub package",
};
mocks.readConfig.mockResolvedValue(configSnapshot());
mocks.clawhubInstall.mockResolvedValue({
ok: false,
error: warning.reason,
installPolicyWarning: warning,
version: "1.2.3",
integrity: "sha256-reviewed",
});
let failure: unknown;
try {
await installManagedPlugin({
request: { source: "clawhub", packageName: "community/demo" },
env: {},
});
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(ManagedPluginLifecycleError);
if (!(failure instanceof ManagedPluginLifecycleError)) {
throw new Error("expected managed plugin lifecycle failure");
}
expect(failure.installPolicyResolvedRequest).toMatchObject({
source: "clawhub",
spec: "clawhub:community/demo@1.2.3",
expectedIntegrity: "sha256-reviewed",
});
});
});
+3 -120
View File
@@ -1,9 +1,9 @@
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
expectOneShotInstallPolicyWarningAcknowledgement,
officialDiffsWarningRequest,
} from "./test-helpers/install-policy-warning.js";
emptyMetadataSnapshot,
metadataSnapshot,
} from "./test-helpers/management-service-fixtures.js";
const mocks = vi.hoisted(() => ({
applyUninstall: vi.fn(),
@@ -128,60 +128,6 @@ function configSnapshot(config: Record<string, unknown> = {}) {
};
}
function metadataSnapshot(params: {
enabled: boolean;
id?: string;
name?: string;
origin?: "bundled" | "global";
installRecord?: Record<string, unknown>;
icon?: string;
}) {
const id = params.id ?? "workboard";
const manifest = {
id,
name: params.name ?? "Workboard",
description: "Coordinate agent work in a shared board.",
catalog: { featured: true, order: 10 },
...(params.icon ? { icon: params.icon } : {}),
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
origin: params.origin ?? "bundled",
rootDir: `/tmp/${id}`,
source: `/tmp/${id}/index.ts`,
manifestPath: `/tmp/${id}/openclaw.plugin.json`,
};
return {
index: {
plugins: [
{
pluginId: id,
packageName: `@openclaw/${id}`,
origin: params.origin ?? "bundled",
enabled: params.enabled,
},
],
installRecords: params.installRecord ? { [id]: params.installRecord } : {},
},
byPluginId: new Map([[id, manifest]]),
plugins: [manifest],
diagnostics: [],
normalizePluginId: (pluginId: string) => pluginId,
};
}
function emptyMetadataSnapshot() {
return {
index: { plugins: [], installRecords: {} },
byPluginId: new Map(),
plugins: [],
diagnostics: [],
normalizePluginId: (pluginId: string) => pluginId,
};
}
function mockHostedOfficialCatalog(entries: unknown[]) {
mocks.officialCatalog.mockResolvedValue({
source: "hosted",
@@ -778,69 +724,6 @@ describe("plugin management service", () => {
);
});
it("threads hosted ClawHub candidate integrity into official installs", async () => {
mocks.readConfig.mockResolvedValue(configSnapshot());
mockHostedOfficialCatalog([hostedFeedDiffsEntry]);
mockClawHubInstall("diffs", "@openclaw/diffs");
mocks.persistInstall.mockResolvedValue({});
mocks.metadata.mockReturnValue(
metadataSnapshot({ enabled: true, id: "diffs", name: "Diffs", origin: "global" }),
);
await installManagedPlugin({
request: officialDiffsWarningRequest,
env: {},
});
expect(mocks.clawhubInstall).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:@openclaw/diffs@2026.6.11",
expectedPluginId: "diffs",
expectedIntegrity: `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`,
}),
);
await expectOneShotInstallPolicyWarningAcknowledgement(mocks.clawhubInstall);
});
it("acknowledges each reviewed install-policy warning once", async () => {
mocks.readConfig.mockResolvedValue(configSnapshot());
mockHostedOfficialCatalog([hostedFeedDiffsEntry]);
mockClawHubInstall("diffs", "@openclaw/diffs");
mocks.persistInstall.mockResolvedValue({});
mocks.metadata.mockReturnValue(
metadataSnapshot({ enabled: true, id: "diffs", name: "Diffs", origin: "global" }),
);
const firstWarning = expectDefined(
officialDiffsWarningRequest.installPolicyWarningAcknowledgement.warnings[0],
"first approved warning",
);
const secondWarning = { ...firstWarning, reason: "Review the dependency warning" };
await installManagedPlugin({
request: {
...officialDiffsWarningRequest,
installPolicyWarningAcknowledgement: {
...officialDiffsWarningRequest.installPolicyWarningAcknowledgement,
warnings: [firstWarning, secondWarning],
},
},
env: {},
});
const call = expectDefined(mocks.clawhubInstall.mock.calls[0], "clawhub install call");
const acknowledge = expectDefined(
(
call[0] as {
onInstallPolicyWarning?: (request: { warning: typeof firstWarning }) => Promise<boolean>;
}
).onInstallPolicyWarning,
"install-policy acknowledgement callback",
);
expect(await acknowledge({ warning: firstWarning })).toBe(true);
expect(await acknowledge({ warning: secondWarning })).toBe(true);
expect(await acknowledge({ warning: firstWarning })).toBe(false);
});
it("removes only the newly installed managed target after persistence conflicts", async () => {
const env = { HOME: "/tmp/openclaw-managed-install-conflict-home" };
const conflict = new Error("config changed during plugin install");
+56 -20
View File
@@ -202,9 +202,11 @@ type ManagedPluginSourceInstallResult =
ok: false;
error: string;
code?: string;
integrity?: string;
version?: string;
warning?: string;
installPolicyWarning?: InstallPolicyWarningDetails;
npmResolution?: NpmSpecResolution;
};
type SourceInstallerResult =
@@ -212,9 +214,11 @@ type SourceInstallerResult =
ok: false;
error: string;
code?: string;
integrity?: string;
version?: string;
warning?: string;
installPolicyWarning?: InstallPolicyWarningDetails;
npmResolution?: NpmSpecResolution;
}
| {
ok: true;
@@ -1006,47 +1010,79 @@ function buildClawHubSpec(packageName: string, version?: string): string {
function pinInstallPolicyResolvedRequest(
request: ManagedPluginSourceInstallRequest,
version: string | undefined,
): ManagedPluginSourceInstallRequest {
if (request.source !== "clawhub" || !version) {
integrity: string | undefined,
npmResolution: NpmSpecResolution | undefined,
): ManagedPluginSourceInstallRequest | undefined {
if (request.source === "official" || request.source === "npm") {
if (!npmResolution?.resolvedSpec || !npmResolution.integrity) {
return undefined;
}
return {
...request,
spec: npmResolution.resolvedSpec,
expectedIntegrity: npmResolution.integrity,
};
}
if (request.source !== "clawhub") {
return request;
}
if (!version || !integrity) {
return undefined;
}
const parsed = parseClawHubPluginSpec(request.spec);
return parsed ? { ...request, spec: buildClawHubSpec(parsed.name, version) } : request;
return parsed
? {
...request,
spec: buildClawHubSpec(parsed.name, version),
expectedIntegrity: integrity,
}
: undefined;
}
function throwInstallFailure(
result: {
error: string;
code?: string;
integrity?: string;
version?: string;
warning?: string;
installPolicyWarning?: InstallPolicyWarningDetails;
npmResolution?: NpmSpecResolution;
},
resolvedRequest?: ManagedPluginSourceInstallRequest,
installPolicyAcknowledgedWarnings?: InstallPolicyWarningDetails[],
): never {
const installPolicyResolvedRequest =
result.installPolicyWarning && resolvedRequest
? pinInstallPolicyResolvedRequest(
resolvedRequest,
result.version,
result.integrity,
result.npmResolution,
)
: undefined;
const resolutionUnavailable =
result.installPolicyWarning && resolvedRequest && !installPolicyResolvedRequest;
const unavailable =
!result.code ||
result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_UNAVAILABLE ||
result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_DOWNLOAD_UNAVAILABLE ||
result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE;
throw new ManagedPluginLifecycleError(result.error, {
kind: unavailable ? "unavailable" : "invalid-request",
code: result.code,
version: result.version,
warning: result.warning,
installPolicyWarning: result.installPolicyWarning,
...(installPolicyAcknowledgedWarnings?.length ? { installPolicyAcknowledgedWarnings } : {}),
...(result.installPolicyWarning && resolvedRequest
? {
installPolicyResolvedRequest: pinInstallPolicyResolvedRequest(
resolvedRequest,
result.version,
),
}
: {}),
cause: result,
});
throw new ManagedPluginLifecycleError(
resolutionUnavailable
? `${result.error}\nOpenClaw could not bind this approval to immutable artifact resolution metadata, so the warning remains terminal.`
: result.error,
{
kind: unavailable ? "unavailable" : "invalid-request",
code: result.code,
version: result.version,
warning: result.warning,
installPolicyWarning: result.installPolicyWarning,
...(installPolicyAcknowledgedWarnings?.length ? { installPolicyAcknowledgedWarnings } : {}),
...(installPolicyResolvedRequest ? { installPolicyResolvedRequest } : {}),
cause: result,
},
);
}
function installRecordOwnsTarget(
@@ -0,0 +1,58 @@
export function metadataSnapshot(params: {
enabled?: boolean;
id?: string;
name?: string;
origin?: "bundled" | "global";
packageName?: string | null;
installRecord?: Record<string, unknown>;
featured?: boolean;
description?: string;
icon?: string;
}) {
const id = params.id ?? "workboard";
const packageName =
params.packageName === null ? undefined : (params.packageName ?? `@openclaw/${id}`);
const manifest = {
id,
name: params.name ?? "Workboard",
description: params.description ?? "Coordinate agent work in a shared board.",
catalog: { featured: params.featured ?? true, order: 10 },
...(params.icon ? { icon: params.icon } : {}),
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
origin: params.origin ?? "bundled",
rootDir: `/tmp/${id}`,
source: `/tmp/${id}/index.ts`,
manifestPath: `/tmp/${id}/openclaw.plugin.json`,
};
return {
index: {
plugins: [
{
pluginId: id,
...(packageName ? { packageName } : {}),
origin: params.origin ?? "bundled",
enabled: params.enabled ?? true,
},
],
installRecords: params.installRecord ? { [id]: params.installRecord } : {},
},
byPluginId: new Map([[id, manifest]]),
plugins: [manifest],
diagnostics: [],
normalizePluginId: (pluginId: string) => pluginId,
};
}
export function emptyMetadataSnapshot() {
return {
index: { plugins: [], installRecords: {} },
byPluginId: new Map(),
plugins: [],
diagnostics: [],
normalizePluginId: (pluginId: string) => pluginId,
};
}