fix(security): bind install warning approvals

This commit is contained in:
Jesse Merhi
2026-08-10 16:08:51 +10:00
committed by jesse-merhi
parent 7412828da8
commit 7cf4e84584
18 changed files with 406 additions and 60 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, acknowledgeInstallPolicyWarning? }` or a ClawHub package with `{ source: "clawhub", packageName, version?, acknowledgeClawHubRisk?, acknowledgeInstallPolicyWarning? }`. 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`, and optional `findings`. After showing those details, a client may retry once with `acknowledgeInstallPolicyWarning: true`; that approval is consumed by the first warning and policy re-evaluates the staged source before continuing. A block or later warning remains terminal for that request and is returned with its own structured details. 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, 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.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.
+10 -8
View File
@@ -154,12 +154,13 @@ non-interactive direct CLI commands can use `--acknowledge-install-policy-warnin
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 `acknowledgeInstallPolicyWarning: true`. That
approval is consumed by the first warning. OpenClaw evaluates that same staged
scan again before allowing the acknowledged warning to continue. A block from
that evaluation, or a warning from any 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
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
@@ -167,8 +168,9 @@ 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. The flag does not override a block or policy failure.
It also does not bypass `before_install` hook blocks.
decisions instead. `--acknowledge-install-policy-warning` does not override a
block or policy failure. Neither acknowledgement nor the deprecated flag
bypasses `before_install` hook blocks.
See [Skills config](/tools/skills-config#operator-install-policy-securityinstallpolicy)
for the shared `security.installPolicy` exec schema used by both skills and
+5 -4
View File
@@ -202,10 +202,11 @@ 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 `acknowledgeInstallPolicyWarning: true`. The first
warning consumes that approval. OpenClaw evaluates that same staged scan again
before allowing the acknowledged warning to continue. A block from that
evaluation, or a warning from any later package or dependency scan, stops the
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. Use an equivalent direct plugin or skill
@@ -19,6 +19,7 @@ export type InstallPolicyWarningErrorDetails = {
targetType: "skill" | "plugin";
requestMode: "install" | "update";
reason: string;
acknowledgementToken: string;
findings?: InstallPolicyWarningErrorFinding[];
};
@@ -37,6 +38,7 @@ const installPolicyWarningErrorDetailsSchema = z.object({
targetType: z.enum(["skill", "plugin"]),
requestMode: z.enum(["install", "update"]),
reason: z.string().trim().min(1),
acknowledgementToken: z.string().trim().min(1),
findings: z.array(installPolicyWarningFindingSchema).optional(),
});
@@ -31,14 +31,14 @@ describe("plugin lifecycle protocol validators", () => {
packageName: "memory-plus",
version: "2.1.0",
acknowledgeClawHubRisk: true,
acknowledgeInstallPolicyWarning: true,
installPolicyWarningAcknowledgement: "approval-token",
}),
).toBe(true);
expect(
validatePluginsInstallParams({
source: "official",
pluginId: "workboard",
acknowledgeInstallPolicyWarning: true,
installPolicyWarningAcknowledgement: "approval-token",
}),
).toBe(true);
expect(
@@ -52,7 +52,7 @@ describe("plugin lifecycle protocol validators", () => {
validatePluginsInstallParams({
source: "official",
pluginId: "workboard",
acknowledgeInstallPolicyWarning: "yes",
installPolicyWarningAcknowledgement: true,
}),
).toBe(false);
});
@@ -173,12 +173,12 @@ export const PluginsInstallParamsSchema = Type.Union([
packageName: NonEmptyString,
version: Type.Optional(NonEmptyString),
acknowledgeClawHubRisk: Type.Optional(Type.Boolean()),
acknowledgeInstallPolicyWarning: Type.Optional(Type.Boolean()),
installPolicyWarningAcknowledgement: Type.Optional(NonEmptyString),
}),
closedObject({
source: Type.Literal("official"),
pluginId: NonEmptyString,
acknowledgeInstallPolicyWarning: Type.Optional(Type.Boolean()),
installPolicyWarningAcknowledgement: Type.Optional(NonEmptyString),
}),
]);
@@ -47,6 +47,12 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => {
targetName: "demo\npkg",
targetType: "plugin",
requestMode: fixture.requestMode,
warning: {
targetName: "demo\npkg",
targetType: "plugin",
requestMode: fixture.requestMode,
reason: "Review required",
},
}),
).resolves.toEqual({ status: "approved" });
@@ -66,6 +72,12 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => {
targetName: "demo",
targetType: "skill",
requestMode: "install",
warning: {
targetName: "demo",
targetType: "skill",
requestMode: "install",
reason: "Review required",
},
}),
).resolves.toEqual({ status: "declined" });
});
@@ -104,6 +116,12 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => {
targetName: "demo",
targetType: "plugin",
requestMode: "install",
warning: {
targetName: "demo",
targetType: "plugin",
requestMode: "install",
reason: "Review required",
},
}),
).resolves.toEqual({ status: "approved" });
await expect(
@@ -11,6 +11,7 @@ describe("install policy warning error details", () => {
targetType: "plugin",
requestMode: "install",
reason: "Scanner found behavior that needs review",
acknowledgementToken: "approval-token",
findings: [
{
ruleId: "dynamic-eval",
+156 -13
View File
@@ -3,6 +3,7 @@
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { InstallPolicyWarningDetails } from "../../plugins/install-security-scan.types.js";
import type { ManagedPluginSourceInstallRequest } from "../../plugins/management-service.js";
const managementMocks = vi.hoisted(() => {
class ManagedPluginLifecycleError extends Error {
@@ -11,6 +12,7 @@ const managementMocks = vi.hoisted(() => {
readonly version?: string;
readonly warning?: string;
readonly installPolicyWarning?: InstallPolicyWarningDetails;
readonly installPolicyResolvedRequest?: ManagedPluginSourceInstallRequest;
constructor(
message: string,
@@ -20,6 +22,7 @@ const managementMocks = vi.hoisted(() => {
version?: string;
warning?: string;
installPolicyWarning?: InstallPolicyWarningDetails;
installPolicyResolvedRequest?: ManagedPluginSourceInstallRequest;
},
) {
super(message);
@@ -28,6 +31,7 @@ const managementMocks = vi.hoisted(() => {
this.version = details?.version;
this.warning = details?.warning;
this.installPolicyWarning = details?.installPolicyWarning;
this.installPolicyResolvedRequest = details?.installPolicyResolvedRequest;
}
}
return {
@@ -304,24 +308,18 @@ describe("plugin management Gateway handlers", () => {
});
});
it("forwards explicit install-policy warning acknowledgement", async () => {
managementMocks.install.mockResolvedValue({
plugin: { ...workboard, id: "diffs", name: "Diffs", enabled: true, state: "enabled" },
});
await callHandler("plugins.install", {
it("rejects an install-policy acknowledgement that the Gateway did not issue", async () => {
const result = await callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
acknowledgeInstallPolicyWarning: true,
installPolicyWarningAcknowledgement: "not-issued",
});
expect(managementMocks.install).toHaveBeenCalledWith({
request: {
source: "official",
pluginId: "diffs",
acknowledgeInstallPolicyWarning: true,
},
expect(result.error).toMatchObject({
code: "INVALID_REQUEST",
message: expect.stringContaining("does not match this plugin"),
});
expect(managementMocks.install).not.toHaveBeenCalled();
});
it("returns structured ClawHub acknowledgement details", async () => {
@@ -354,6 +352,10 @@ describe("plugin management Gateway handlers", () => {
it("returns structured install-policy warning details", async () => {
managementMocks.install.mockRejectedValue(
new managementMocks.ManagedPluginLifecycleError("Install requires approval", {
installPolicyResolvedRequest: {
source: "clawhub",
spec: "clawhub:community/plugin@1.0.0",
},
installPolicyWarning: {
targetName: "demo-plugin",
targetType: "plugin",
@@ -397,6 +399,147 @@ describe("plugin management Gateway handlers", () => {
],
},
});
const error = result.error as { details?: { acknowledgementToken?: unknown } };
const acknowledgementToken = expectDefined(
error.details?.acknowledgementToken,
"expected install-policy acknowledgement token",
);
expect(acknowledgementToken).toEqual(expect.any(String));
managementMocks.install.mockRejectedValueOnce(
new managementMocks.ManagedPluginLifecycleError("Warning changed", {
installPolicyResolvedRequest: {
source: "clawhub",
spec: "clawhub:community/plugin@1.0.0",
},
installPolicyWarning: {
targetName: "demo-plugin",
targetType: "plugin",
requestMode: "install",
reason: "Scanner found a different issue",
},
}),
);
const changed = await callHandler("plugins.install", {
source: "clawhub",
packageName: "community/plugin",
installPolicyWarningAcknowledgement: acknowledgementToken,
});
expect(changed.error).toMatchObject({
details: { reason: "Scanner found a different issue" },
});
expect(managementMocks.install).toHaveBeenLastCalledWith({
request: {
source: "clawhub",
packageName: "community/plugin",
installPolicyWarningAcknowledgement: {
resolvedRequest: {
source: "clawhub",
spec: "clawhub:community/plugin@1.0.0",
},
warning: {
targetName: "demo-plugin",
targetType: "plugin",
requestMode: "install",
reason: "Scanner found behavior that needs review",
findings: [
{
ruleId: "dynamic-eval",
severity: "warn",
message: "Dynamic code execution",
file: "index.js",
line: 12,
},
],
},
},
},
});
const changedError = changed.error as { details?: { acknowledgementToken?: unknown } };
const changedAcknowledgementToken = expectDefined(
changedError.details?.acknowledgementToken,
"expected changed-warning acknowledgement token",
);
expect(changedAcknowledgementToken).not.toBe(acknowledgementToken);
managementMocks.install.mockResolvedValue({
plugin: { ...workboard, id: "diffs", name: "Diffs", enabled: true, state: "enabled" },
});
const approved = await callHandler("plugins.install", {
source: "clawhub",
packageName: "community/plugin",
installPolicyWarningAcknowledgement: changedAcknowledgementToken,
});
expect(approved.ok).toBe(true);
expect(managementMocks.install).toHaveBeenLastCalledWith({
request: {
source: "clawhub",
packageName: "community/plugin",
installPolicyWarningAcknowledgement: {
resolvedRequest: {
source: "clawhub",
spec: "clawhub:community/plugin@1.0.0",
},
warning: {
targetName: "demo-plugin",
targetType: "plugin",
requestMode: "install",
reason: "Scanner found a different issue",
},
},
},
});
const replay = await callHandler("plugins.install", {
source: "clawhub",
packageName: "community/plugin",
installPolicyWarningAcknowledgement: changedAcknowledgementToken,
});
expect(replay.error).toMatchObject({ code: "INVALID_REQUEST" });
expect(managementMocks.install).toHaveBeenCalledTimes(3);
});
it("binds an install-policy acknowledgement to the request that received it", async () => {
managementMocks.install.mockRejectedValue(
new managementMocks.ManagedPluginLifecycleError("Install requires approval", {
installPolicyResolvedRequest: {
source: "official",
spec: "@openclaw/diffs@1.0.0",
pluginId: "diffs",
mode: "install",
},
installPolicyWarning: {
targetName: "diffs",
targetType: "plugin",
requestMode: "install",
reason: "Review required",
},
}),
);
const warning = await callHandler("plugins.install", {
source: "official",
pluginId: "diffs",
});
const error = warning.error as { details?: { acknowledgementToken?: unknown } };
const acknowledgementToken = expectDefined(
error.details?.acknowledgementToken,
"expected install-policy acknowledgement token",
);
const mismatch = await callHandler("plugins.install", {
source: "official",
pluginId: "workboard",
installPolicyWarningAcknowledgement: acknowledgementToken,
});
expect(mismatch.error).toMatchObject({
code: "INVALID_REQUEST",
message: expect.stringContaining("does not match this plugin"),
});
expect(managementMocks.install).toHaveBeenCalledOnce();
});
it("classifies ClawHub security outages as unavailable", async () => {
+115 -7
View File
@@ -1,4 +1,5 @@
// Gateway control-plane handlers for cold plugin catalog and lifecycle operations.
import { randomUUID } from "node:crypto";
import {
buildClawHubTrustErrorDetails,
INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED,
@@ -11,9 +12,11 @@ import {
validatePluginsSearchParams,
validatePluginsSetEnabledParams,
validatePluginsUninstallParams,
type PluginsInstallParams,
} from "../../../packages/gateway-protocol/src/index.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { searchInstallablePluginPackages } from "../../plugins/catalog-search.js";
import type { InstallPolicyWarningDetails } from "../../plugins/install-security-scan.types.js";
import {
formatManagedPluginLifecycleError,
installManagedPlugin,
@@ -21,6 +24,8 @@ import {
ManagedPluginLifecycleError,
setManagedPluginEnabled,
uninstallManagedPlugin,
type ManagedPluginInstallRequest,
type ManagedPluginSourceInstallRequest,
} from "../../plugins/management-service.js";
import { buildGatewayReloadPlan } from "../config-reload-plan.js";
import { resolveGatewayReloadSettings } from "../config-reload-settings.js";
@@ -28,6 +33,103 @@ import { readInstallPolicyWarningErrorDetails } from "../install-policy-warning-
import type { GatewayRequestHandlers } from "./types.js";
import { assertValidParams } from "./validation.js";
const INSTALL_POLICY_ACKNOWLEDGEMENT_TTL_MS = 5 * 60_000;
const MAX_INSTALL_POLICY_ACKNOWLEDGEMENTS = 256;
type InstallPolicyAcknowledgement = {
expiresAt: number;
requestKey: string;
resolvedRequest: ManagedPluginSourceInstallRequest;
warning: InstallPolicyWarningDetails;
};
const installPolicyAcknowledgements = new Map<string, InstallPolicyAcknowledgement>();
function installPolicyRequestKey(request: PluginsInstallParams): string {
return request.source === "clawhub"
? JSON.stringify({
source: request.source,
packageName: request.packageName,
version: request.version ?? null,
acknowledgeClawHubRisk: request.acknowledgeClawHubRisk ?? false,
})
: JSON.stringify({ source: request.source, pluginId: request.pluginId });
}
function pruneInstallPolicyAcknowledgements(now: number): void {
for (const [token, acknowledgement] of installPolicyAcknowledgements) {
if (acknowledgement.expiresAt <= now) {
installPolicyAcknowledgements.delete(token);
}
}
while (installPolicyAcknowledgements.size >= MAX_INSTALL_POLICY_ACKNOWLEDGEMENTS) {
const oldestToken = installPolicyAcknowledgements.keys().next().value;
if (typeof oldestToken !== "string") {
break;
}
installPolicyAcknowledgements.delete(oldestToken);
}
}
function issueInstallPolicyAcknowledgement(params: {
request: PluginsInstallParams;
resolvedRequest: ManagedPluginSourceInstallRequest;
warning: InstallPolicyWarningDetails;
}): string {
const now = Date.now();
pruneInstallPolicyAcknowledgements(now);
const token = randomUUID();
installPolicyAcknowledgements.set(token, {
expiresAt: now + INSTALL_POLICY_ACKNOWLEDGEMENT_TTL_MS,
requestKey: installPolicyRequestKey(params.request),
resolvedRequest: params.resolvedRequest,
warning: params.warning,
});
return token;
}
function consumeInstallPolicyAcknowledgement(
request: PluginsInstallParams,
): Pick<InstallPolicyAcknowledgement, "resolvedRequest" | "warning"> | undefined {
const token = request.installPolicyWarningAcknowledgement;
if (!token) {
return undefined;
}
const acknowledgement = installPolicyAcknowledgements.get(token);
installPolicyAcknowledgements.delete(token);
if (
!acknowledgement ||
acknowledgement.expiresAt <= Date.now() ||
acknowledgement.requestKey !== installPolicyRequestKey(request)
) {
throw new ManagedPluginLifecycleError(
"Install policy approval expired or does not match this plugin. Review the current warning and try again.",
);
}
return {
resolvedRequest: acknowledgement.resolvedRequest,
warning: acknowledgement.warning,
};
}
function managedInstallRequest(params: PluginsInstallParams): ManagedPluginInstallRequest {
const installPolicyWarningAcknowledgement = consumeInstallPolicyAcknowledgement(params);
if (params.source === "clawhub") {
return {
source: params.source,
packageName: params.packageName,
...(params.version ? { version: params.version } : {}),
...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
...(installPolicyWarningAcknowledgement ? { installPolicyWarningAcknowledgement } : {}),
};
}
return {
source: params.source,
pluginId: params.pluginId,
...(installPolicyWarningAcknowledgement ? { installPolicyWarningAcknowledgement } : {}),
};
}
function pluginPolicyRestartRequired(params: {
config: OpenClawConfig;
changedPaths: readonly string[];
@@ -120,7 +222,7 @@ export const pluginsHandlers: GatewayRequestHandlers = {
return;
}
try {
const result = await installManagedPlugin({ request: params });
const result = await installManagedPlugin({ request: managedInstallRequest(params) });
respond(
true,
{
@@ -144,12 +246,18 @@ export const pluginsHandlers: GatewayRequestHandlers = {
...(lifecycleError.warning ? { warning: lifecycleError.warning } : {}),
})
: undefined;
const installPolicyDetails = lifecycleError?.installPolicyWarning
? readInstallPolicyWarningErrorDetails({
installPolicyCode: INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED,
...lifecycleError.installPolicyWarning,
})
: undefined;
const installPolicyDetails =
lifecycleError?.installPolicyWarning && lifecycleError.installPolicyResolvedRequest
? readInstallPolicyWarningErrorDetails({
installPolicyCode: INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED,
...lifecycleError.installPolicyWarning,
acknowledgementToken: issueInstallPolicyAcknowledgement({
request: params,
resolvedRequest: lifecycleError.installPolicyResolvedRequest,
warning: lifecycleError.installPolicyWarning,
}),
})
: undefined;
const details = installPolicyDetails ?? trustDetails;
respond(
false,
-1
View File
@@ -284,7 +284,6 @@ type ArchiveInstallCall = {
requestedSpecifier?: string;
source?: { kind?: string; authority?: string; mutable?: boolean; network?: boolean };
};
onInstallPolicyWarning?: () => Promise<boolean>;
trustedSourceLinkedOfficialInstall?: boolean;
};
+1 -1
View File
@@ -1466,7 +1466,7 @@ export async function installPluginFromClawHub(
},
});
if (!installResult.ok) {
return installResult;
return { ...installResult, version: versionState.version };
}
const pkg = detail.package!;
@@ -344,6 +344,12 @@ describe("legacy file install scan compatibility", () => {
targetName: "payload",
targetType: "plugin",
requestMode: "install",
warning: {
targetName: "payload",
targetType: "plugin",
requestMode: "install",
reason: "review this plugin",
},
});
expect(onInstallPolicyWarning).toHaveBeenCalledTimes(1);
expect(runInstallPolicyMock).toHaveBeenCalledTimes(2);
@@ -853,6 +853,7 @@ async function runOperatorInstallPolicy(params: {
targetName: params.targetName,
targetType: params.targetType,
requestMode: params.requestMode,
warning: installPolicyWarning,
});
if (acknowledgement.status === "approved") {
const reevaluated = await evaluatePolicy();
@@ -14,6 +14,7 @@ export type InstallPolicyWarningAcknowledgementRequest = {
targetName: string;
targetType: "skill" | "plugin";
requestMode: "install" | "update";
warning: InstallPolicyWarningDetails;
};
type InstallPolicyWarningAcknowledgementResult =
+5 -3
View File
@@ -1,6 +1,9 @@
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { expectOneShotInstallPolicyWarningAcknowledgement } from "./test-helpers/install-policy-warning.js";
import {
expectOneShotInstallPolicyWarningAcknowledgement,
officialDiffsWarningRequest,
} from "./test-helpers/install-policy-warning.js";
const mocks = vi.hoisted(() => ({
applyUninstall: vi.fn(),
@@ -785,7 +788,7 @@ describe("plugin management service", () => {
);
await installManagedPlugin({
request: { source: "official", pluginId: "diffs", acknowledgeInstallPolicyWarning: true },
request: officialDiffsWarningRequest,
env: {},
});
@@ -794,7 +797,6 @@ describe("plugin management service", () => {
spec: "clawhub:@openclaw/diffs@2026.6.11",
expectedPluginId: "diffs",
expectedIntegrity: `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`,
onInstallPolicyWarning: expect.any(Function),
}),
);
await expectOneShotInstallPolicyWarningAcknowledgement(mocks.clawhubInstall);
+54 -17
View File
@@ -1,5 +1,6 @@
// Structured plugin catalog and lifecycle operations shared by Gateway-facing surfaces.
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { asSafeIntegerInRange } from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
@@ -117,18 +118,24 @@ type ManagedPluginCatalog = {
mutationAllowed: boolean;
};
type ManagedPluginInstallRequest =
export type ManagedPluginInstallRequest =
| {
source: "clawhub";
packageName: string;
version?: string;
acknowledgeClawHubRisk?: boolean;
acknowledgeInstallPolicyWarning?: boolean;
installPolicyWarningAcknowledgement?: {
warning: InstallPolicyWarningDetails;
resolvedRequest: ManagedPluginSourceInstallRequest;
};
}
| {
source: "official";
pluginId: string;
acknowledgeInstallPolicyWarning?: boolean;
installPolicyWarningAcknowledgement?: {
warning: InstallPolicyWarningDetails;
resolvedRequest: ManagedPluginSourceInstallRequest;
};
};
export type ManagedPluginSourceInstallRequest =
@@ -223,6 +230,7 @@ export class ManagedPluginLifecycleError extends Error {
readonly version?: string;
readonly warning?: string;
readonly installPolicyWarning?: InstallPolicyWarningDetails;
readonly installPolicyResolvedRequest?: ManagedPluginSourceInstallRequest;
constructor(
message: string,
@@ -232,6 +240,7 @@ export class ManagedPluginLifecycleError extends Error {
version?: string;
warning?: string;
installPolicyWarning?: InstallPolicyWarningDetails;
installPolicyResolvedRequest?: ManagedPluginSourceInstallRequest;
cause?: unknown;
},
) {
@@ -242,6 +251,7 @@ export class ManagedPluginLifecycleError extends Error {
this.version = details?.version;
this.warning = details?.warning;
this.installPolicyWarning = details?.installPolicyWarning;
this.installPolicyResolvedRequest = details?.installPolicyResolvedRequest;
}
}
@@ -990,13 +1000,27 @@ function buildClawHubSpec(packageName: string, version?: string): string {
return `clawhub:${packageName}${version ? `@${version}` : ""}`;
}
function throwInstallFailure(result: {
error: string;
code?: string;
version?: string;
warning?: string;
installPolicyWarning?: InstallPolicyWarningDetails;
}): never {
function pinInstallPolicyResolvedRequest(
request: ManagedPluginSourceInstallRequest,
version: string | undefined,
): ManagedPluginSourceInstallRequest {
if (request.source !== "clawhub" || !version) {
return request;
}
const parsed = parseClawHubPluginSpec(request.spec);
return parsed ? { ...request, spec: buildClawHubSpec(parsed.name, version) } : request;
}
function throwInstallFailure(
result: {
error: string;
code?: string;
version?: string;
warning?: string;
installPolicyWarning?: InstallPolicyWarningDetails;
},
resolvedRequest?: ManagedPluginSourceInstallRequest,
): never {
const unavailable =
!result.code ||
result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_UNAVAILABLE ||
@@ -1008,6 +1032,14 @@ function throwInstallFailure(result: {
version: result.version,
warning: result.warning,
installPolicyWarning: result.installPolicyWarning,
...(result.installPolicyWarning && resolvedRequest
? {
installPolicyResolvedRequest: pinInstallPolicyResolvedRequest(
resolvedRequest,
result.version,
),
}
: {}),
cause: result,
});
}
@@ -1085,6 +1117,7 @@ function throwPersistenceFailureWithCleanupWarnings(error: unknown, warnings: st
version: error.version,
warning: [error.warning, cleanupWarning].filter(Boolean).join("\n"),
installPolicyWarning: error.installPolicyWarning,
installPolicyResolvedRequest: error.installPolicyResolvedRequest,
cause: error,
});
}
@@ -1435,10 +1468,11 @@ export async function installManagedPlugin(params: {
const warnings: string[] = [];
const installLogger = createInstallLogger(warnings);
let installPolicyWarningAcknowledgementAvailable = Boolean(
params.request.acknowledgeInstallPolicyWarning,
params.request.installPolicyWarningAcknowledgement,
);
const request =
params.request.source === "clawhub"
params.request.installPolicyWarningAcknowledgement?.resolvedRequest ??
(params.request.source === "clawhub"
? resolveManagedClawHubInstallRequest({
request: params.request,
officialEntries: officialCatalog.entries,
@@ -1446,22 +1480,25 @@ export async function installManagedPlugin(params: {
: resolveManagedOfficialInstallRequest({
request: params.request,
officialEntries: officialCatalog.entries,
});
}));
const installed = await installManagedPluginSource({
request,
snapshot,
env,
logger: installLogger,
persistenceLogger: installLogger,
...(params.request.acknowledgeInstallPolicyWarning
...(params.request.installPolicyWarningAcknowledgement
? {
safetyOverrides: {
onInstallPolicyWarning: async () => {
onInstallPolicyWarning: async ({ warning }) => {
if (!installPolicyWarningAcknowledgementAvailable) {
return false;
}
installPolicyWarningAcknowledgementAvailable = false;
return true;
return isDeepStrictEqual(
warning,
params.request.installPolicyWarningAcknowledgement?.warning,
);
},
},
}
@@ -1471,7 +1508,7 @@ export async function installManagedPlugin(params: {
runtime: createSilentRuntime(),
});
if (!installed.ok) {
return throwInstallFailure(installed);
return throwInstallFailure(installed, request);
}
const catalog = await listManagedPlugins({
config: installed.config,
@@ -8,6 +8,25 @@ type InstallPolicyWarningCall = {
) => Promise<boolean>;
};
export const officialDiffsWarningRequest = {
source: "official",
pluginId: "diffs",
installPolicyWarningAcknowledgement: {
resolvedRequest: {
source: "clawhub",
spec: "clawhub:@openclaw/diffs@2026.6.11",
expectedPluginId: "diffs",
expectedIntegrity: `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`,
},
warning: {
targetName: "diffs",
targetType: "plugin",
requestMode: "install",
reason: "Review this warning",
},
},
} as const;
export async function expectOneShotInstallPolicyWarningAcknowledgement(mock: {
mock: { calls: unknown[][] };
}): Promise<void> {
@@ -21,6 +40,12 @@ export async function expectOneShotInstallPolicyWarningAcknowledgement(mock: {
targetName: "diffs",
targetType: "plugin",
requestMode: "install",
warning: {
targetName: "diffs",
targetType: "plugin",
requestMode: "install",
reason: "Review this warning",
},
};
expect(await acknowledge(request)).toBe(true);
expect(await acknowledge(request)).toBe(false);