mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
feat(security): expose install policy warning approval
This commit is contained in:
@@ -1 +1 @@
|
||||
{"contentHash":"a35378b670434316ba33a3ba4e595925be685daee9fb0f3c5989983525b18c50","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
|
||||
{"contentHash":"68fc42bf703b4d4330d5b81c33cda5efcc08513ed2695577c4e721f1a650d9bd","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"ae00c815c607cea46536c879daf88c76490920cc9d5beefa1822f384b15610e1","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
{"contentHash":"0f06f4f4822eadd8f372a44210452712cdbb6f374d9e71d742b81f3358356929","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"d8b7c867b29b4651375455e938ae634e7213e3e8c17444dc2b0ef091e46fda6c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
{"contentHash":"3d7b5d15182a191670d0f1c0b1ee8e07337b9cf7055653d3e85a6c9cf2e72732","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"883bbab77caeff122bf8b7505dc9299484fba601e285a42d2c457697b7590b8e","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
|
||||
{"contentHash":"e8771f0e1539fbdc6902392043b641ea192f566e884895f261ba87fb5038db6d","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"2f85610859f047db1d76efbe52d9e63d8126ce8e944d53d6fdafb4e7f503b5dc","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
{"contentHash":"2ec34a7ea4128251e0b5c694703d9c57b1f6e9bacb21474e164ead33e40f1331","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"6b1f575e77dc71e72c4b2575f366e7e9990d391e85364844e0ecec2146e7f902","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
{"contentHash":"006b5a333d131e09438f838c5200c3da1c2ccb30c96a269b085626b9f18e1eb7","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"72d713de6cb87ad8e6d9c49456477367a2d30c8157695ddb66ac57919558272b","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
|
||||
{"contentHash":"25317f5f757a366c0e8de79ce4e70c6ceae8cf4c55fc1b0cbcd7e573f1de1fa7","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"2ccfe8eb85378f3be3f78f79f2638b00faa943e351ff69464bfed44ed63e09c1","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"a27a7ab5f3a2a166675222eda5755a6991b99c982106548b848616c14e977e18","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"d23cd5df763235dbe35ede1c23d26735f4c183b03dc216ac212aefe75c0c1f96","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
{"contentHash":"af1acb53d110c0ebec021ad878018e57f028c201348bf813f652e13d0db2c674","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"b691fcfb34a5f228938d06c50f9f8a26bb63ce06644e9df25f44876601e4cebb","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
||||
{"contentHash":"5669646ab04c2aaf510924b853e925a6e5e8798fa5c3e3b500678d228131e49b","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"f57a244e027b326090ed91cdc105c0c8c83b24318d99be0ec205fffd33504050","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
|
||||
{"contentHash":"03291a3f9de779b5c69bd06803dc7d759c96e9a19ffa10f56c865c22f63d692f","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"5008615c80d86383e18527d71442f1501e4f02bdb3faa1494a48edf40fd66f84","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
{"contentHash":"5d8f9b45e4338ab4623a15d77fc95c850a6dec6d30e6f38e4c355730229c678d","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"6b73741c889f68cc19a5d1f8703df2eca650fe57803795045cff122d746cbb6a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
{"contentHash":"cc9aeab1ce02f61610eb10aeee25023f729c8b81a4b7d2759235953e016cdf4f","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"15704fc50b81cebceb677a5a69a7bc8c97afd962081c647de58a027bd816f19d","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
{"contentHash":"76570c04f575db917766195be05f7ae0daa457cb3f82915e4478c5890f95694b","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"df8043d0630d95bc4c316da28bf675a713e7f1f0c304959d1b4b9c8b30fcbd0d","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
|
||||
{"contentHash":"b65a8a25a13043e2aaf3a71e6a33a04b293d7d7ff762f91fb8583e424191a84b","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"8b0c71c8abe13163eee28ca65e6afb490d0ae3c16288292143ad021975b96ca6","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
{"contentHash":"054fa8033deea8ae745cd47a80b8e9bffe963464674f73a45160452da1f8fc1a","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"eb0096fd25551a7f4a58dcf6c6b53120f3f1ddc1248b2b962cca60c75f6daa12","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
{"contentHash":"047e294fde7ac6b68feb7b1b490a4ed708b2cabb5f9db51ffc1f7fc1ae39645f","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
|
||||
@@ -560,7 +560,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 }` or a ClawHub package with `{ source: "clawhub", packageName, version?, acknowledgeClawHubRisk? }`. ClawHub installs preserve Gateway trust, integrity, and install-policy checks. 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: "install_policy_warning_acknowledgement_required"`, target metadata, `reason`, optional `findings`, and a server-issued `acknowledgementToken`. After showing those details, a client may retry with that token as `installPolicyWarningAcknowledgement`. Every presentation consumes the token, including an expired or wrong-request attempt; only its first presentation can authorize the same install request and resolved artifact. Policy then re-evaluates the staged source and continues when that fresh evaluation allows the install or repeats the unchanged warning the operator approved. Treat `acknowledgementToken` as a short-lived bearer secret. It is not bound to the Gateway connection, device, or operator that received it: any authenticated `operator.admin` client that has the token and submits the same install request may consume it once for the matching resolved artifact. Keep it only in memory for the immediate reviewed retry; do not log, persist, or forward it. Tokens expire after five minutes and are invalidated by a Gateway restart; after either event, retry without the token to receive the current warning and a fresh token. 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.
|
||||
|
||||
|
||||
+27
-9
@@ -153,17 +153,35 @@ 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-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
|
||||
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`. Every presentation consumes the token,
|
||||
including an expired or wrong-request attempt; only its first presentation can
|
||||
authorize the same install request and resolved artifact. OpenClaw re-evaluates
|
||||
the staged source and continues when that fresh evaluation allows
|
||||
the install or repeats the unchanged warning the operator approved.
|
||||
Treat `acknowledgementToken` as a short-lived bearer secret. It is not bound to
|
||||
the Gateway connection, device, or operator that received it: any authenticated
|
||||
`operator.admin` client that has the token and submits the same install request
|
||||
may consume it once for the matching resolved artifact. Keep it only in memory
|
||||
for the immediate reviewed retry; do not log, persist, or forward it. Tokens
|
||||
expire after five minutes and are invalidated by a Gateway restart.
|
||||
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. 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-security-installpolicy)
|
||||
for the shared `security.installPolicy` exec schema used by both skills and
|
||||
|
||||
@@ -201,6 +201,23 @@ 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 when the
|
||||
Gateway can bind the request to an immutable resolved artifact, and may make
|
||||
one explicit retry with the returned `acknowledgementToken` as
|
||||
`installPolicyWarningAcknowledgement`. Every presentation consumes the token,
|
||||
including an expired or wrong-request attempt; only its first presentation can
|
||||
authorize the same install request and resolved artifact. OpenClaw re-evaluates
|
||||
the staged source and continues when that fresh evaluation allows
|
||||
the install or repeats the unchanged warning the operator approved.
|
||||
Treat `acknowledgementToken` as a short-lived bearer secret. It is not bound to
|
||||
the Gateway connection, device, or operator that received it: any authenticated
|
||||
`operator.admin` client that has the token and submits the same install request
|
||||
may consume it once for the matching resolved artifact. Keep it only in memory
|
||||
for the immediate reviewed retry; do not log, persist, or forward it. Tokens
|
||||
expire after five minutes and are invalidated by a Gateway restart.
|
||||
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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./clawhub-trust-error-details.js";
|
||||
export * from "./install-policy-warning-error-details.js";
|
||||
export * from "./system-agent-error-details.js";
|
||||
export {
|
||||
isMcpAppViewExpiredError,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
asProtocolRecord,
|
||||
normalizeOptionalProtocolString,
|
||||
} from "./protocol-value-normalization.js";
|
||||
|
||||
/** Structured install-policy warning details carried in Gateway error payloads. */
|
||||
export const INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED =
|
||||
"install_policy_warning_acknowledgement_required" as const;
|
||||
|
||||
export type InstallPolicyWarningErrorFinding = {
|
||||
ruleId: string;
|
||||
severity: "info" | "warn" | "critical";
|
||||
message: string;
|
||||
file?: string;
|
||||
line?: number;
|
||||
evidence?: string;
|
||||
};
|
||||
|
||||
export type InstallPolicyWarningErrorDetails = {
|
||||
installPolicyCode: typeof INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED;
|
||||
targetName: string;
|
||||
targetType: "skill" | "plugin";
|
||||
requestMode: "install" | "update";
|
||||
reason: string;
|
||||
acknowledgementToken: string;
|
||||
findings?: InstallPolicyWarningErrorFinding[];
|
||||
};
|
||||
|
||||
function readFinding(value: unknown): InstallPolicyWarningErrorFinding | undefined {
|
||||
const record = asProtocolRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const ruleId = normalizeOptionalProtocolString(record.ruleId);
|
||||
const message = normalizeOptionalProtocolString(record.message);
|
||||
const severity = record.severity;
|
||||
if (
|
||||
!ruleId ||
|
||||
!message ||
|
||||
(severity !== "info" && severity !== "warn" && severity !== "critical")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const file = normalizeOptionalProtocolString(record.file);
|
||||
const evidence = normalizeOptionalProtocolString(record.evidence);
|
||||
const line = record.line;
|
||||
if (
|
||||
(record.file !== undefined && !file) ||
|
||||
(record.evidence !== undefined && !evidence) ||
|
||||
(line !== undefined && (typeof line !== "number" || !Number.isSafeInteger(line) || line <= 0))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
ruleId,
|
||||
severity,
|
||||
message,
|
||||
...(file ? { file } : {}),
|
||||
...(line !== undefined ? { line } : {}),
|
||||
...(evidence ? { evidence } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function readInstallPolicyWarningErrorDetails(
|
||||
value: unknown,
|
||||
): InstallPolicyWarningErrorDetails | undefined {
|
||||
const record = asProtocolRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const targetName = normalizeOptionalProtocolString(record.targetName);
|
||||
const reason = normalizeOptionalProtocolString(record.reason);
|
||||
const acknowledgementToken = normalizeOptionalProtocolString(record.acknowledgementToken);
|
||||
const targetType = record.targetType;
|
||||
const requestMode = record.requestMode;
|
||||
if (
|
||||
record.installPolicyCode !== INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED ||
|
||||
!targetName ||
|
||||
!reason ||
|
||||
!acknowledgementToken ||
|
||||
(targetType !== "skill" && targetType !== "plugin") ||
|
||||
(requestMode !== "install" && requestMode !== "update")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
let findings: InstallPolicyWarningErrorFinding[] | undefined;
|
||||
if (record.findings !== undefined) {
|
||||
if (!Array.isArray(record.findings)) {
|
||||
return undefined;
|
||||
}
|
||||
findings = [];
|
||||
for (const findingValue of record.findings) {
|
||||
const finding = readFinding(findingValue);
|
||||
if (!finding) {
|
||||
return undefined;
|
||||
}
|
||||
findings.push(finding);
|
||||
}
|
||||
}
|
||||
return {
|
||||
installPolicyCode: INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED,
|
||||
targetName,
|
||||
targetType,
|
||||
requestMode,
|
||||
reason,
|
||||
acknowledgementToken,
|
||||
...(findings ? { findings } : {}),
|
||||
};
|
||||
}
|
||||
@@ -31,9 +31,16 @@ describe("plugin lifecycle protocol validators", () => {
|
||||
packageName: "memory-plus",
|
||||
version: "2.1.0",
|
||||
acknowledgeClawHubRisk: true,
|
||||
installPolicyWarningAcknowledgement: "approval-token",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validatePluginsInstallParams({
|
||||
source: "official",
|
||||
pluginId: "workboard",
|
||||
installPolicyWarningAcknowledgement: "approval-token",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(validatePluginsInstallParams({ source: "official", pluginId: "workboard" })).toBe(true);
|
||||
expect(
|
||||
validatePluginsInstallParams({
|
||||
source: "official",
|
||||
@@ -41,6 +48,13 @@ describe("plugin lifecycle protocol validators", () => {
|
||||
packageName: "memory-plus",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
validatePluginsInstallParams({
|
||||
source: "official",
|
||||
pluginId: "workboard",
|
||||
installPolicyWarningAcknowledgement: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("validates uninstall requests", () => {
|
||||
|
||||
@@ -174,10 +174,12 @@ export const PluginsInstallParamsSchema = Type.Union([
|
||||
packageName: NonEmptyString,
|
||||
version: Type.Optional(NonEmptyString),
|
||||
acknowledgeClawHubRisk: Type.Optional(Type.Boolean()),
|
||||
installPolicyWarningAcknowledgement: Type.Optional(NonEmptyString),
|
||||
}),
|
||||
closedObject({
|
||||
source: Type.Literal("official"),
|
||||
pluginId: NonEmptyString,
|
||||
installPolicyWarningAcknowledgement: Type.Optional(NonEmptyString),
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { InstallPolicyWarningAcknowledgementRequest } from "../plugins/install-security-scan.types.js";
|
||||
import { resolveInstallPolicyWarningAcknowledgementCliOptions } from "./install-policy-warning-acknowledgement.ts";
|
||||
|
||||
const promptTextMock = vi.hoisted(() => vi.fn());
|
||||
@@ -47,6 +48,18 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => {
|
||||
targetName: "demo\npkg",
|
||||
targetType: "plugin",
|
||||
requestMode: fixture.requestMode,
|
||||
approvalFingerprint: "review-required",
|
||||
scan: {
|
||||
requestKind: "plugin-npm",
|
||||
originType: "plugin-npm",
|
||||
pluginContentType: "package",
|
||||
},
|
||||
warning: {
|
||||
targetName: "demo\npkg",
|
||||
targetType: "plugin",
|
||||
requestMode: fixture.requestMode,
|
||||
reason: "Review required",
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({ status: "approved" });
|
||||
|
||||
@@ -66,6 +79,18 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => {
|
||||
targetName: "demo",
|
||||
targetType: "skill",
|
||||
requestMode: "install",
|
||||
approvalFingerprint: "review-required",
|
||||
scan: {
|
||||
requestKind: "skill-install",
|
||||
originType: "skill-managed-install",
|
||||
skillInstallId: "demo",
|
||||
},
|
||||
warning: {
|
||||
targetName: "demo",
|
||||
targetType: "skill",
|
||||
requestMode: "install",
|
||||
reason: "Review required",
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({ status: "declined" });
|
||||
});
|
||||
@@ -98,21 +123,31 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => {
|
||||
const options = resolveInstallPolicyWarningAcknowledgementCliOptions({
|
||||
acknowledgeInstallPolicyWarning: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
options.onInstallPolicyWarning?.({
|
||||
const warningRequest: InstallPolicyWarningAcknowledgementRequest = {
|
||||
targetName: "demo",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
approvalFingerprint: "review-required",
|
||||
scan: {
|
||||
requestKind: "plugin-npm",
|
||||
originType: "plugin-npm",
|
||||
pluginContentType: "package",
|
||||
},
|
||||
warning: {
|
||||
targetName: "demo",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
}),
|
||||
).resolves.toEqual({ status: "approved" });
|
||||
await expect(
|
||||
options.onInstallPolicyWarning?.({
|
||||
targetName: "demo-dependency",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
}),
|
||||
).resolves.toEqual({ status: "unavailable", reason: "approval-exhausted" });
|
||||
reason: "Review required",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(options.onInstallPolicyWarning?.(warningRequest)).resolves.toEqual({
|
||||
status: "approved",
|
||||
});
|
||||
await expect(options.onInstallPolicyWarning?.(warningRequest)).resolves.toEqual({
|
||||
status: "unavailable",
|
||||
reason: "approval-exhausted",
|
||||
});
|
||||
expect(promptTextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED,
|
||||
type InstallPolicyWarningErrorDetails,
|
||||
} from "../../packages/gateway-protocol/src/install-policy-warning-error-details.js";
|
||||
import { readInstallPolicyWarningErrorDetails } from "./install-policy-warning-error-details.js";
|
||||
|
||||
describe("install policy warning error details", () => {
|
||||
const completeWarning: Omit<InstallPolicyWarningErrorDetails, "installPolicyCode"> = {
|
||||
targetName: "demo-plugin",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: "Scanner found behavior that needs review",
|
||||
acknowledgementToken: "approval-token",
|
||||
findings: [
|
||||
{
|
||||
ruleId: "dynamic-eval",
|
||||
severity: "warn",
|
||||
message: "Dynamic code execution",
|
||||
file: "index.js",
|
||||
line: 12,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const expectedWarning = {
|
||||
installPolicyCode: INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED,
|
||||
...completeWarning,
|
||||
};
|
||||
|
||||
it("parses a complete warning payload", () => {
|
||||
expect(
|
||||
readInstallPolicyWarningErrorDetails({
|
||||
...expectedWarning,
|
||||
}),
|
||||
).toEqual(expectedWarning);
|
||||
});
|
||||
|
||||
it("accepts the largest wire-safe finding line", () => {
|
||||
expect(
|
||||
readInstallPolicyWarningErrorDetails({
|
||||
...expectedWarning,
|
||||
findings: [{ ...completeWarning.findings?.[0], line: Number.MAX_SAFE_INTEGER }],
|
||||
}),
|
||||
).toMatchObject({
|
||||
findings: [{ line: Number.MAX_SAFE_INTEGER }],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects finding lines outside the wire-safe range", () => {
|
||||
expect(
|
||||
readInstallPolicyWarningErrorDetails({
|
||||
...expectedWarning,
|
||||
findings: [{ ...completeWarning.findings?.[0], line: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
{
|
||||
installPolicyCode: INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED,
|
||||
targetName: "demo-plugin",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: "",
|
||||
},
|
||||
{
|
||||
...expectedWarning,
|
||||
findings: [{ ...completeWarning.findings?.[0], line: 0 }],
|
||||
},
|
||||
{
|
||||
...expectedWarning,
|
||||
findings: [{ ...completeWarning.findings?.[0], severity: "error" }],
|
||||
},
|
||||
])("rejects malformed warning details", (value) => {
|
||||
expect(readInstallPolicyWarningErrorDetails(value)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes protocol strings without changing the published dependency surface", () => {
|
||||
expect(
|
||||
readInstallPolicyWarningErrorDetails({
|
||||
...expectedWarning,
|
||||
targetName: " demo-plugin ",
|
||||
reason: " Review required ",
|
||||
acknowledgementToken: " token ",
|
||||
}),
|
||||
).toMatchObject({
|
||||
targetName: "demo-plugin",
|
||||
reason: "Review required",
|
||||
acknowledgementToken: "token",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { readInstallPolicyWarningErrorDetails } from "../../packages/gateway-protocol/src/install-policy-warning-error-details.js";
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { InstallPolicyWarningOccurrence } from "../plugins/install-security-scan.types.js";
|
||||
import type { ManagedPluginSourceInstallRequest } from "../plugins/management-service.js";
|
||||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
|
||||
type InstallPolicyAcknowledgement = {
|
||||
expiresAt: number;
|
||||
generation: number;
|
||||
requestKey: string;
|
||||
resolvedRequest: ManagedPluginSourceInstallRequest;
|
||||
warnings: InstallPolicyWarningOccurrence[];
|
||||
};
|
||||
|
||||
type InstallPolicyAcknowledgementState = {
|
||||
generation: number;
|
||||
records: Map<string, InstallPolicyAcknowledgement>;
|
||||
};
|
||||
|
||||
function revokeState(state: InstallPolicyAcknowledgementState): void {
|
||||
state.generation += 1;
|
||||
state.records.clear();
|
||||
}
|
||||
|
||||
const state = resolveGlobalSingleton(
|
||||
Symbol.for("openclaw.installPolicyAcknowledgements"),
|
||||
(): InstallPolicyAcknowledgementState => ({ generation: 0, records: new Map() }),
|
||||
revokeState,
|
||||
"close-and-restart",
|
||||
);
|
||||
|
||||
export function getInstallPolicyAcknowledgementState(): InstallPolicyAcknowledgementState {
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Retires every approval before Gateway teardown can publish old-generation work. */
|
||||
export function revokeInstallPolicyAcknowledgements(): void {
|
||||
revokeState(state);
|
||||
}
|
||||
@@ -237,7 +237,7 @@ describe("gateway startup import boundaries", () => {
|
||||
expect(workerStartup.match(/loadWorkerEnvironmentRuntimeModule\(\)/gu)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("fences config reload before gateway teardown and gateway_stop hooks", () => {
|
||||
it("fences install approvals and config reload before gateway teardown", () => {
|
||||
const serverImpl = readServerImplementation();
|
||||
const closeStart = /close:\s*async\s*\([^)]*\)\s*=>/u.exec(serverImpl)?.index ?? -1;
|
||||
const hookStart = serverImpl.indexOf("runGlobalGatewayStopSafely", closeStart);
|
||||
@@ -245,6 +245,7 @@ describe("gateway startup import boundaries", () => {
|
||||
const terminalStopStart = serverImpl.indexOf("terminalSessions.disposeAll();", closeStart);
|
||||
const markHelperStart = serverImpl.indexOf("const markClosePreludeStarted = () => {");
|
||||
const markHelperEnd = serverImpl.indexOf("};", markHelperStart);
|
||||
const markHelperBlock = serverImpl.slice(markHelperStart, markHelperEnd);
|
||||
const beginHelperStart = serverImpl.indexOf("const beginClosePrelude = async () => {");
|
||||
const beginHelperEnd = serverImpl.indexOf("};", beginHelperStart);
|
||||
const postReadyStart = serverImpl.indexOf("scheduleGatewayPostReadyMaintenance({");
|
||||
@@ -256,14 +257,15 @@ describe("gateway startup import boundaries", () => {
|
||||
expect(reloadStopStart).toBeLessThan(terminalStopStart);
|
||||
expect(reloadStopStart).toBeLessThan(hookStart);
|
||||
expect(markHelperStart).toBeGreaterThan(-1);
|
||||
expect(serverImpl.slice(markHelperStart, markHelperEnd)).toContain(
|
||||
"clearPostReadyMaintenanceTimer();",
|
||||
expect(markHelperBlock).toContain("clearPostReadyMaintenanceTimer();");
|
||||
expect(markHelperBlock).toContain("revokeInstallPolicyAcknowledgements();");
|
||||
expect(markHelperBlock).toContain("cronReconciliation.invalidate();");
|
||||
expect(markHelperBlock).toContain("void stopOutboundDeliveryRecoveryForClose();");
|
||||
expect(markHelperBlock.indexOf("revokeInstallPolicyAcknowledgements();")).toBeLessThan(
|
||||
markHelperBlock.indexOf("void stopOutboundDeliveryRecoveryForClose();"),
|
||||
);
|
||||
expect(serverImpl.slice(markHelperStart, markHelperEnd)).toContain(
|
||||
"cronReconciliation.invalidate();",
|
||||
);
|
||||
expect(serverImpl.slice(markHelperStart, markHelperEnd)).toContain(
|
||||
"void stopOutboundDeliveryRecoveryForClose();",
|
||||
expect(markHelperBlock.indexOf("revokeInstallPolicyAcknowledgements();")).toBeLessThan(
|
||||
markHelperBlock.indexOf("void stopMediaCleanupForClose();"),
|
||||
);
|
||||
expect(beginHelperStart).toBeGreaterThan(-1);
|
||||
expect(serverImpl.slice(beginHelperStart, beginHelperEnd)).toContain(
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/c
|
||||
import { createOpenClawTestState } from "../test-utils/openclaw-test-state.js";
|
||||
import { getFreePort } from "../test-utils/ports.js";
|
||||
import { CLI_DEFAULT_OPERATOR_SCOPES } from "./method-scopes.js";
|
||||
import { getInstallPolicyAcknowledgementState } from "./plugin-install-policy-acknowledgement-state.js";
|
||||
import { dispatchGatewayRequestInProcess } from "./server-in-process-dispatch.js";
|
||||
import { createGatewayKernel } from "./server-kernel.js";
|
||||
import { createSyntheticPluginRuntimeClient } from "./server-plugin-runtime-client.js";
|
||||
@@ -183,6 +184,11 @@ describe("createGatewayKernel", () => {
|
||||
"gateway.handlers",
|
||||
"gateway.request-context",
|
||||
]);
|
||||
|
||||
const installPolicyGeneration = getInstallPolicyAcknowledgementState().generation;
|
||||
const closePrelude = kernel.beginClosePrelude();
|
||||
expect(getInstallPolicyAcknowledgementState().generation).toBe(installPolicyGeneration + 1);
|
||||
await closePrelude;
|
||||
} finally {
|
||||
try {
|
||||
await kernel?.closeOnStartupFailure();
|
||||
|
||||
@@ -24,6 +24,7 @@ import { createControlUiSessionPullRequestSubscriptions } from "./control-ui-ses
|
||||
import { STARTUP_UNAVAILABLE_GATEWAY_METHODS } from "./methods/core-descriptors.js";
|
||||
import { disposeNodeConnectionNotifications } from "./node-connection-notifications.js";
|
||||
import { clearNodeWakeState } from "./node-wake-state.js";
|
||||
import { revokeInstallPolicyAcknowledgements } from "./plugin-install-policy-acknowledgement-state.js";
|
||||
import { createLazyGatewayCronState } from "./server-cron-lazy.js";
|
||||
import { createGatewayCronReconciliation } from "./server-cron-reconciled.js";
|
||||
import { applyGatewayLaneConcurrency, resolveGatewayLaneConcurrency } from "./server-lanes.js";
|
||||
@@ -445,6 +446,9 @@ export async function prepareGatewayLifecycle(params: {
|
||||
};
|
||||
const markClosePreludeStarted = () => {
|
||||
lifecycle.closePreludeStarted = true;
|
||||
// Approval-bearing installs can survive close waits. Revoke before the first
|
||||
// await so old-generation work cannot publish during teardown.
|
||||
revokeInstallPolicyAcknowledgements();
|
||||
// Fence background owners before any awaited close step can tear down the
|
||||
// plugin/channel or shared-state runtime they still need.
|
||||
void stopOutboundDeliveryRecoveryForClose();
|
||||
|
||||
@@ -2,54 +2,60 @@
|
||||
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
InstallPolicyWarningDetails,
|
||||
InstallPolicyWarningOccurrence,
|
||||
} from "../../plugins/install-security-scan.types.js";
|
||||
import { parseInstallPolicyResponse } from "../../security/install-policy-response.js";
|
||||
import { drainGlobalSingletonLifecycleState } from "../../shared/global-singleton.js";
|
||||
import { revokeInstallPolicyAcknowledgements } from "../plugin-install-policy-acknowledgement-state.js";
|
||||
|
||||
type InstallPolicyWarningScanIdentity = InstallPolicyWarningOccurrence["scan"];
|
||||
type ManagementServiceModule = typeof import("../../plugins/management-service.js");
|
||||
|
||||
const managementMocks = vi.hoisted(() => {
|
||||
class ManagedPluginLifecycleError extends Error {
|
||||
readonly kind: "invalid-request" | "unavailable";
|
||||
readonly code?: string;
|
||||
readonly version?: string;
|
||||
readonly warning?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
details?: {
|
||||
kind?: "invalid-request" | "unavailable";
|
||||
code?: string;
|
||||
version?: string;
|
||||
warning?: string;
|
||||
},
|
||||
) {
|
||||
super(message);
|
||||
this.kind = details?.kind ?? "invalid-request";
|
||||
this.code = details?.code;
|
||||
this.version = details?.version;
|
||||
this.warning = details?.warning;
|
||||
}
|
||||
}
|
||||
return {
|
||||
ManagedPluginLifecycleError,
|
||||
install: vi.fn(),
|
||||
list: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
install: vi.fn<ManagementServiceModule["installManagedPlugin"]>(),
|
||||
list: vi.fn<ManagementServiceModule["listManagedPlugins"]>(),
|
||||
setEnabled: vi.fn<ManagementServiceModule["setManagedPluginEnabled"]>(),
|
||||
uninstall: vi.fn<ManagementServiceModule["uninstallManagedPlugin"]>(),
|
||||
};
|
||||
});
|
||||
const searchMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../plugins/management-service.js", () => ({
|
||||
ManagedPluginLifecycleError: managementMocks.ManagedPluginLifecycleError,
|
||||
installManagedPlugin: (...args: unknown[]) => managementMocks.install(...args),
|
||||
listManagedPlugins: (...args: unknown[]) => managementMocks.list(...args),
|
||||
setManagedPluginEnabled: (...args: unknown[]) => managementMocks.setEnabled(...args),
|
||||
uninstallManagedPlugin: (...args: unknown[]) => managementMocks.uninstall(...args),
|
||||
}));
|
||||
vi.mock("../../plugins/management-service.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../plugins/management-service.js")>(
|
||||
"../../plugins/management-service.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
installManagedPlugin: managementMocks.install,
|
||||
listManagedPlugins: managementMocks.list,
|
||||
setManagedPluginEnabled: managementMocks.setEnabled,
|
||||
uninstallManagedPlugin: managementMocks.uninstall,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../plugins/catalog-search.js", () => ({
|
||||
searchInstallablePluginPackages: (...args: unknown[]) => searchMock(...args),
|
||||
}));
|
||||
|
||||
const { ManagedPluginLifecycleError } = await import("../../plugins/management-service.js");
|
||||
const { pluginsHandlers } = await import("./plugins.js");
|
||||
|
||||
const packageScan: InstallPolicyWarningScanIdentity = {
|
||||
requestKind: "plugin-archive",
|
||||
originType: "plugin-package",
|
||||
pluginContentType: "package",
|
||||
};
|
||||
|
||||
function warningOccurrence(
|
||||
warning: InstallPolicyWarningDetails,
|
||||
scan: InstallPolicyWarningScanIdentity = packageScan,
|
||||
): InstallPolicyWarningOccurrence {
|
||||
return { scan, warning, approvalFingerprint: `fingerprint:${warning.reason}` };
|
||||
}
|
||||
|
||||
async function callHandler(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
@@ -124,6 +130,59 @@ describe("plugin management Gateway handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for lifecycle mutations before returning reconnect catalog state", async () => {
|
||||
let finishInstall!: (value: { plugin: typeof workboard }) => void;
|
||||
const pendingInstall = new Promise<{ plugin: typeof workboard }>((resolve) => {
|
||||
finishInstall = resolve;
|
||||
});
|
||||
managementMocks.install.mockReturnValue(pendingInstall);
|
||||
managementMocks.list.mockResolvedValue({
|
||||
plugins: [workboard],
|
||||
diagnostics: [],
|
||||
mutationAllowed: true,
|
||||
});
|
||||
|
||||
const install = callHandler("plugins.install", { source: "official", pluginId: "workboard" });
|
||||
await vi.waitFor(() => expect(managementMocks.install).toHaveBeenCalledOnce());
|
||||
await drainGlobalSingletonLifecycleState("restart");
|
||||
const list = callHandler("plugins.list", {});
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
expect(managementMocks.list).not.toHaveBeenCalled();
|
||||
|
||||
finishInstall({ plugin: workboard });
|
||||
await install;
|
||||
expect((await list).response).toMatchObject({ plugins: [workboard] });
|
||||
expect(managementMocks.list).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("releases reconnect catalog reads after a lifecycle mutation fails", async () => {
|
||||
let failInstall!: (error: Error) => void;
|
||||
const pendingInstall = new Promise<never>((_resolve, reject) => {
|
||||
failInstall = reject;
|
||||
});
|
||||
managementMocks.install.mockReturnValue(pendingInstall);
|
||||
managementMocks.list.mockResolvedValue({
|
||||
plugins: [workboard],
|
||||
diagnostics: [],
|
||||
mutationAllowed: true,
|
||||
});
|
||||
|
||||
const install = callHandler("plugins.install", { source: "official", pluginId: "workboard" });
|
||||
await vi.waitFor(() => expect(managementMocks.install).toHaveBeenCalledOnce());
|
||||
const list = callHandler("plugins.list", {});
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
expect(managementMocks.list).not.toHaveBeenCalled();
|
||||
|
||||
failInstall(new Error("install failed"));
|
||||
expect((await install).error).toMatchObject({ code: "UNAVAILABLE" });
|
||||
expect((await list).response).toMatchObject({ plugins: [workboard] });
|
||||
expect(managementMocks.list).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("maps plugin-only ClawHub search results to the public DTO", async () => {
|
||||
searchMock.mockResolvedValue([
|
||||
{
|
||||
@@ -248,7 +307,7 @@ describe("plugin management Gateway handlers", () => {
|
||||
|
||||
it("classifies known enablement policy failures as invalid requests", async () => {
|
||||
managementMocks.setEnabled.mockRejectedValue(
|
||||
new managementMocks.ManagedPluginLifecycleError("Plugin is blocked"),
|
||||
new ManagedPluginLifecycleError("Plugin is blocked"),
|
||||
);
|
||||
|
||||
const result = await callHandler("plugins.setEnabled", {
|
||||
@@ -298,9 +357,23 @@ describe("plugin management Gateway handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an install-policy acknowledgement that the Gateway did not issue", async () => {
|
||||
const result = await callHandler("plugins.install", {
|
||||
source: "official",
|
||||
pluginId: "diffs",
|
||||
installPolicyWarningAcknowledgement: "not-issued",
|
||||
});
|
||||
|
||||
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 () => {
|
||||
managementMocks.install.mockRejectedValue(
|
||||
new managementMocks.ManagedPluginLifecycleError("Review required", {
|
||||
new ManagedPluginLifecycleError("Review required", {
|
||||
kind: "invalid-request",
|
||||
code: "clawhub_risk_acknowledgement_required",
|
||||
version: "1.2.3",
|
||||
@@ -325,9 +398,617 @@ describe("plugin management Gateway handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns structured install-policy warning details", async () => {
|
||||
managementMocks.install.mockRejectedValue(
|
||||
new ManagedPluginLifecycleError("Install requires approval", {
|
||||
installPolicyResolvedRequest: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:community/plugin@1.0.0",
|
||||
},
|
||||
installPolicyWarning: warningOccurrence({
|
||||
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 result = await callHandler("plugins.install", {
|
||||
source: "clawhub",
|
||||
packageName: "community/plugin",
|
||||
});
|
||||
|
||||
expect(result.error).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "Install requires approval",
|
||||
details: {
|
||||
installPolicyCode: "install_policy_warning_acknowledgement_required",
|
||||
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 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 ManagedPluginLifecycleError("Warning changed", {
|
||||
installPolicyResolvedRequest: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:community/plugin@1.0.0",
|
||||
},
|
||||
installPolicyWarning: warningOccurrence({
|
||||
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: {
|
||||
publicationAuthority: {
|
||||
assertCurrent: expect.any(Function),
|
||||
commit: expect.any(Function),
|
||||
},
|
||||
resolvedRequest: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:community/plugin@1.0.0",
|
||||
},
|
||||
warnings: [
|
||||
{
|
||||
scan: packageScan,
|
||||
approvalFingerprint: "fingerprint:Scanner found behavior that needs review",
|
||||
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: {
|
||||
publicationAuthority: {
|
||||
assertCurrent: expect.any(Function),
|
||||
commit: expect.any(Function),
|
||||
},
|
||||
resolvedRequest: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:community/plugin@1.0.0",
|
||||
},
|
||||
warnings: [
|
||||
{
|
||||
scan: packageScan,
|
||||
approvalFingerprint: "fingerprint:Scanner found a different issue",
|
||||
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 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 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"),
|
||||
});
|
||||
|
||||
const retryAfterMismatch = await callHandler("plugins.install", {
|
||||
source: "official",
|
||||
pluginId: "diffs",
|
||||
installPolicyWarningAcknowledgement: acknowledgementToken,
|
||||
});
|
||||
|
||||
expect(retryAfterMismatch.error).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: expect.stringContaining("expired or does not match this plugin"),
|
||||
});
|
||||
expect(managementMocks.install).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("revokes install-policy acknowledgements when the Gateway restarts", 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",
|
||||
);
|
||||
|
||||
await drainGlobalSingletonLifecycleState("restart");
|
||||
const retry = await callHandler("plugins.install", {
|
||||
source: "official",
|
||||
pluginId: "diffs",
|
||||
installPolicyWarningAcknowledgement: acknowledgementToken,
|
||||
});
|
||||
|
||||
expect(retry.error).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: expect.stringContaining("expired or does not match this plugin"),
|
||||
});
|
||||
expect(managementMocks.install).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not issue an install-policy acknowledgement after the Gateway restarts", async () => {
|
||||
const warningError = () =>
|
||||
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",
|
||||
}),
|
||||
});
|
||||
let rejectInstall!: (error: Error) => void;
|
||||
const pendingInstall = new Promise<never>((_resolve, reject) => {
|
||||
rejectInstall = reject;
|
||||
});
|
||||
managementMocks.install
|
||||
.mockReturnValueOnce(pendingInstall)
|
||||
.mockRejectedValueOnce(warningError());
|
||||
|
||||
const pending = callHandler("plugins.install", {
|
||||
source: "official",
|
||||
pluginId: "diffs",
|
||||
});
|
||||
await vi.waitFor(() => expect(managementMocks.install).toHaveBeenCalledOnce());
|
||||
await drainGlobalSingletonLifecycleState("restart");
|
||||
rejectInstall(warningError());
|
||||
|
||||
const stale = await pending;
|
||||
expect(stale.error).toMatchObject({
|
||||
code: "UNAVAILABLE",
|
||||
message: expect.stringContaining("Gateway restarted"),
|
||||
});
|
||||
expect(stale.error).not.toHaveProperty("details.acknowledgementToken");
|
||||
|
||||
const fresh = await callHandler("plugins.install", {
|
||||
source: "official",
|
||||
pluginId: "diffs",
|
||||
});
|
||||
expect(
|
||||
(fresh.error as { details?: { acknowledgementToken?: unknown } }).details
|
||||
?.acknowledgementToken,
|
||||
).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("rejects a queued install-policy retry when its Gateway generation restarts", 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",
|
||||
);
|
||||
|
||||
const retry = callHandler("plugins.install", {
|
||||
source: "official",
|
||||
pluginId: "diffs",
|
||||
installPolicyWarningAcknowledgement: acknowledgementToken,
|
||||
});
|
||||
await drainGlobalSingletonLifecycleState("restart");
|
||||
|
||||
expect((await retry).error).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: expect.stringContaining("expired or does not match this plugin"),
|
||||
});
|
||||
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));
|
||||
revokeInstallPolicyAcknowledgements();
|
||||
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",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: "Review this behavior",
|
||||
};
|
||||
const firstWarning = warningOccurrence(warning);
|
||||
const secondWarning = warningOccurrence(warning, {
|
||||
requestKind: "plugin-archive",
|
||||
originType: "plugin-dependency-tree",
|
||||
pluginContentType: "dependency-tree",
|
||||
});
|
||||
const publicWarningDetails = {
|
||||
installPolicyCode: "install_policy_warning_acknowledgement_required",
|
||||
targetName: warning.targetName,
|
||||
targetType: warning.targetType,
|
||||
requestMode: warning.requestMode,
|
||||
reason: warning.reason,
|
||||
};
|
||||
const resolvedRequest = {
|
||||
source: "clawhub" as const,
|
||||
spec: "clawhub:community/plugin@1.0.0",
|
||||
};
|
||||
managementMocks.install.mockRejectedValueOnce(
|
||||
new ManagedPluginLifecycleError("First warning", {
|
||||
installPolicyResolvedRequest: resolvedRequest,
|
||||
installPolicyWarning: firstWarning,
|
||||
}),
|
||||
);
|
||||
const first = await callHandler("plugins.install", {
|
||||
source: "clawhub",
|
||||
packageName: "community/plugin",
|
||||
});
|
||||
const firstToken = expectDefined(
|
||||
(first.error as { details?: { acknowledgementToken?: string } }).details
|
||||
?.acknowledgementToken,
|
||||
"first acknowledgement token",
|
||||
);
|
||||
expect(first.error).toMatchObject({ details: publicWarningDetails });
|
||||
expect((first.error as { details?: Record<string, unknown> }).details).not.toHaveProperty(
|
||||
"scan",
|
||||
);
|
||||
expect((first.error as { details?: Record<string, unknown> }).details).not.toHaveProperty(
|
||||
"approvalFingerprint",
|
||||
);
|
||||
|
||||
managementMocks.install.mockRejectedValueOnce(
|
||||
new ManagedPluginLifecycleError("Second warning", {
|
||||
installPolicyResolvedRequest: resolvedRequest,
|
||||
installPolicyWarning: secondWarning,
|
||||
installPolicyAcknowledgedWarnings: [firstWarning],
|
||||
}),
|
||||
);
|
||||
const second = await callHandler("plugins.install", {
|
||||
source: "clawhub",
|
||||
packageName: "community/plugin",
|
||||
installPolicyWarningAcknowledgement: firstToken,
|
||||
});
|
||||
const secondToken = expectDefined(
|
||||
(second.error as { details?: { acknowledgementToken?: string } }).details
|
||||
?.acknowledgementToken,
|
||||
"second acknowledgement token",
|
||||
);
|
||||
expect(second.error).toMatchObject({ details: publicWarningDetails });
|
||||
expect((second.error as { details?: Record<string, unknown> }).details).not.toHaveProperty(
|
||||
"scan",
|
||||
);
|
||||
expect((second.error as { details?: Record<string, unknown> }).details).not.toHaveProperty(
|
||||
"approvalFingerprint",
|
||||
);
|
||||
|
||||
managementMocks.install.mockResolvedValueOnce({
|
||||
plugin: { ...workboard, id: "diffs", name: "Diffs", enabled: true, state: "enabled" },
|
||||
});
|
||||
await callHandler("plugins.install", {
|
||||
source: "clawhub",
|
||||
packageName: "community/plugin",
|
||||
installPolicyWarningAcknowledgement: secondToken,
|
||||
});
|
||||
|
||||
expect(managementMocks.install).toHaveBeenLastCalledWith({
|
||||
request: {
|
||||
source: "clawhub",
|
||||
packageName: "community/plugin",
|
||||
installPolicyWarningAcknowledgement: {
|
||||
publicationAuthority: {
|
||||
assertCurrent: expect.any(Function),
|
||||
commit: expect.any(Function),
|
||||
},
|
||||
resolvedRequest,
|
||||
warnings: [firstWarning, secondWarning],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves normalized finding lines in structured warning details", async () => {
|
||||
const policyResult = parseInstallPolicyResponse(
|
||||
JSON.stringify({
|
||||
protocolVersion: 1,
|
||||
decision: "warn",
|
||||
reason: "Review line normalization",
|
||||
findings: [
|
||||
{
|
||||
ruleId: "large-line",
|
||||
severity: "warn",
|
||||
message: "Review line",
|
||||
line: 1e100,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ sourcePath: "/tmp/staged-plugin" },
|
||||
);
|
||||
const warning = expectDefined(policyResult.warning, "expected parsed install-policy warning");
|
||||
|
||||
managementMocks.install.mockRejectedValue(
|
||||
new ManagedPluginLifecycleError("Install requires approval", {
|
||||
installPolicyResolvedRequest: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:community/plugin@1.0.0",
|
||||
},
|
||||
installPolicyWarning: {
|
||||
scan: packageScan,
|
||||
warning: {
|
||||
targetName: "demo-plugin",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: warning.reason,
|
||||
findings: policyResult.findings,
|
||||
},
|
||||
approvalFingerprint: warning.approvalFingerprint,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await callHandler("plugins.install", {
|
||||
source: "clawhub",
|
||||
packageName: "community/plugin",
|
||||
});
|
||||
|
||||
expect(result.error).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
details: {
|
||||
findings: [{ line: Number.MAX_SAFE_INTEGER }],
|
||||
acknowledgementToken: expect.any(String),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies ClawHub security outages as unavailable", async () => {
|
||||
managementMocks.install.mockRejectedValue(
|
||||
new managementMocks.ManagedPluginLifecycleError("Security service unavailable", {
|
||||
new ManagedPluginLifecycleError("Security service unavailable", {
|
||||
kind: "unavailable",
|
||||
code: "clawhub_security_unavailable",
|
||||
}),
|
||||
@@ -383,7 +1064,7 @@ describe("plugin management Gateway handlers", () => {
|
||||
|
||||
it("classifies bundled uninstall refusals as invalid requests", async () => {
|
||||
managementMocks.uninstall.mockRejectedValue(
|
||||
new managementMocks.ManagedPluginLifecycleError(
|
||||
new ManagedPluginLifecycleError(
|
||||
"bundled plugin cannot be uninstalled: workboard; disable it instead",
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Gateway control-plane handlers for cold plugin catalog and lifecycle operations.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
buildClawHubTrustErrorDetails,
|
||||
INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED,
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
isClawHubTrustErrorCode,
|
||||
@@ -10,22 +12,183 @@ import {
|
||||
validatePluginsSearchParams,
|
||||
validatePluginsSetEnabledParams,
|
||||
validatePluginsUninstallParams,
|
||||
type PluginsInstallParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { searchInstallablePluginPackages } from "../../plugins/catalog-search.js";
|
||||
import type { InstallPolicyWarningOccurrence } from "../../plugins/install-security-scan.types.js";
|
||||
import {
|
||||
installManagedPlugin,
|
||||
listManagedPlugins,
|
||||
ManagedPluginLifecycleError,
|
||||
setManagedPluginEnabled,
|
||||
uninstallManagedPlugin,
|
||||
type ManagedPluginInstallRequest,
|
||||
type ManagedPluginInstallPolicyAcknowledgement,
|
||||
type ManagedPluginSourceInstallRequest,
|
||||
} from "../../plugins/management-service.js";
|
||||
import { resolveGlobalSet } from "../../shared/global-singleton.js";
|
||||
import { buildGatewayReloadPlan } from "../config-reload-plan.js";
|
||||
import { resolveGatewayReloadSettings } from "../config-reload-settings.js";
|
||||
import { readInstallPolicyWarningErrorDetails } from "../install-policy-warning-error-details.js";
|
||||
import { getInstallPolicyAcknowledgementState } from "../plugin-install-policy-acknowledgement-state.js";
|
||||
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;
|
||||
|
||||
const installPolicyAcknowledgementState = getInstallPolicyAcknowledgementState();
|
||||
const installPolicyAcknowledgements = installPolicyAcknowledgementState.records;
|
||||
const pendingPluginLifecycleOperations = resolveGlobalSet<Promise<unknown>>(
|
||||
Symbol.for("openclaw.pendingPluginLifecycleOperations"),
|
||||
"close-only",
|
||||
);
|
||||
|
||||
async function runTrackedPluginLifecycleOperation<T>(run: () => Promise<T>): Promise<T> {
|
||||
const operation = Promise.resolve().then(run);
|
||||
pendingPluginLifecycleOperations.add(operation);
|
||||
try {
|
||||
return await operation;
|
||||
} finally {
|
||||
pendingPluginLifecycleOperations.delete(operation);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPendingPluginLifecycleOperations(): Promise<void> {
|
||||
await Promise.allSettled(pendingPluginLifecycleOperations);
|
||||
}
|
||||
|
||||
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: {
|
||||
generation: number;
|
||||
request: PluginsInstallParams;
|
||||
resolvedRequest: ManagedPluginSourceInstallRequest;
|
||||
warning: InstallPolicyWarningOccurrence;
|
||||
acknowledgedWarnings?: InstallPolicyWarningOccurrence[];
|
||||
}): string {
|
||||
const now = Date.now();
|
||||
pruneInstallPolicyAcknowledgements(now);
|
||||
const token = randomUUID();
|
||||
installPolicyAcknowledgements.set(token, {
|
||||
expiresAt: now + INSTALL_POLICY_ACKNOWLEDGEMENT_TTL_MS,
|
||||
generation: params.generation,
|
||||
requestKey: installPolicyRequestKey(params.request),
|
||||
resolvedRequest: params.resolvedRequest,
|
||||
warnings: [...(params.acknowledgedWarnings ?? []), params.warning],
|
||||
});
|
||||
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,
|
||||
): ManagedPluginInstallPolicyAcknowledgement | undefined {
|
||||
const token = request.installPolicyWarningAcknowledgement;
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
const acknowledgement = installPolicyAcknowledgements.get(token);
|
||||
installPolicyAcknowledgements.delete(token);
|
||||
if (
|
||||
!acknowledgement ||
|
||||
generation !== installPolicyAcknowledgementState.generation ||
|
||||
acknowledgement.generation !== generation ||
|
||||
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,
|
||||
warnings: acknowledgement.warnings,
|
||||
publicationAuthority: createInstallPolicyPublicationAuthority(generation),
|
||||
};
|
||||
}
|
||||
|
||||
function managedInstallRequest(
|
||||
params: PluginsInstallParams,
|
||||
generation: number,
|
||||
): ManagedPluginInstallRequest {
|
||||
const installPolicyWarningAcknowledgement = consumeInstallPolicyAcknowledgement(
|
||||
params,
|
||||
generation,
|
||||
);
|
||||
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[];
|
||||
@@ -49,7 +212,11 @@ export const pluginsHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
respond(true, await listManagedPlugins({ config: context.getRuntimeConfig() }), undefined);
|
||||
// Handler operations can outlive a disconnected client, including while queued for the
|
||||
// lifecycle lease. Reconnect reads wait for every earlier handler to reach a terminal state.
|
||||
await waitForPendingPluginLifecycleOperations();
|
||||
const result = await listManagedPlugins({ config: context.getRuntimeConfig() });
|
||||
respond(true, result, undefined);
|
||||
} catch (error) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
|
||||
}
|
||||
@@ -109,8 +276,13 @@ export const pluginsHandlers: GatewayRequestHandlers = {
|
||||
if (!assertValidParams(params, validatePluginsInstallParams, "plugins.install", respond)) {
|
||||
return;
|
||||
}
|
||||
const acknowledgementGeneration = installPolicyAcknowledgementState.generation;
|
||||
try {
|
||||
const result = await installManagedPlugin({ request: params });
|
||||
const result = await runTrackedPluginLifecycleOperation(() =>
|
||||
installManagedPlugin({
|
||||
request: managedInstallRequest(params, acknowledgementGeneration),
|
||||
}),
|
||||
);
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
@@ -123,17 +295,47 @@ export const pluginsHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
} catch (error) {
|
||||
const lifecycleError = error instanceof ManagedPluginLifecycleError ? error : undefined;
|
||||
if (
|
||||
lifecycleError?.installPolicyWarning &&
|
||||
lifecycleError.installPolicyResolvedRequest &&
|
||||
acknowledgementGeneration !== installPolicyAcknowledgementState.generation
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
"Gateway restarted before the install warning could be returned. Retry the install to review the current warning.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const trustCode =
|
||||
lifecycleError?.code && isClawHubTrustErrorCode(lifecycleError.code)
|
||||
? lifecycleError.code
|
||||
: undefined;
|
||||
const details = lifecycleError
|
||||
const trustDetails = lifecycleError
|
||||
? buildClawHubTrustErrorDetails({
|
||||
...(trustCode ? { code: trustCode } : {}),
|
||||
...(lifecycleError.version ? { version: lifecycleError.version } : {}),
|
||||
...(lifecycleError.warning ? { warning: lifecycleError.warning } : {}),
|
||||
})
|
||||
: undefined;
|
||||
const installPolicyDetails =
|
||||
lifecycleError?.installPolicyWarning && lifecycleError.installPolicyResolvedRequest
|
||||
? readInstallPolicyWarningErrorDetails({
|
||||
installPolicyCode: INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED,
|
||||
...lifecycleError.installPolicyWarning.warning,
|
||||
acknowledgementToken: issueInstallPolicyAcknowledgement({
|
||||
generation: acknowledgementGeneration,
|
||||
request: params,
|
||||
resolvedRequest: lifecycleError.installPolicyResolvedRequest,
|
||||
warning: lifecycleError.installPolicyWarning,
|
||||
acknowledgedWarnings: lifecycleError.installPolicyAcknowledgedWarnings,
|
||||
}),
|
||||
})
|
||||
: undefined;
|
||||
const details = installPolicyDetails ?? trustDetails;
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
@@ -152,7 +354,9 @@ export const pluginsHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await uninstallManagedPlugin({ pluginId: params.pluginId });
|
||||
const result = await runTrackedPluginLifecycleOperation(() =>
|
||||
uninstallManagedPlugin({ pluginId: params.pluginId }),
|
||||
);
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
@@ -185,10 +389,12 @@ export const pluginsHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await setManagedPluginEnabled({
|
||||
pluginId: params.pluginId,
|
||||
enabled: params.enabled,
|
||||
});
|
||||
const result = await runTrackedPluginLifecycleOperation(() =>
|
||||
setManagedPluginEnabled({
|
||||
pluginId: params.pluginId,
|
||||
enabled: params.enabled,
|
||||
}),
|
||||
);
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
|
||||
@@ -281,6 +281,133 @@ 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("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");
|
||||
|
||||
@@ -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);
|
||||
@@ -215,7 +218,9 @@ export function resolvePackageDirInstallTransaction(
|
||||
* 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";
|
||||
@@ -226,10 +231,12 @@ 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>;
|
||||
publicationAuthority?: {
|
||||
assertCurrent: () => void;
|
||||
commit: () => void;
|
||||
};
|
||||
}): Promise<InstallPackageDirSuccess | InstallPackageDirFailure | TAfterInstallFailure> {
|
||||
const deferCommit = isPackageDirInstallCommitDeferred(params);
|
||||
params.logger?.info?.(`Installing to ${params.targetDir}…`);
|
||||
const installBaseDir = path.dirname(params.targetDir);
|
||||
@@ -279,10 +286,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;
|
||||
@@ -354,7 +357,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);
|
||||
@@ -374,6 +380,7 @@ export async function installPackageDir(params: {
|
||||
installBaseDir,
|
||||
expectedRealPath: installBaseRealPath,
|
||||
});
|
||||
params.publicationAuthority?.assertCurrent();
|
||||
await movePathWithCopyFallback({
|
||||
from: canonicalTargetDir,
|
||||
sourceHardlinks,
|
||||
@@ -389,6 +396,7 @@ export async function installPackageDir(params: {
|
||||
installBaseDir,
|
||||
expectedRealPath: installBaseRealPath,
|
||||
});
|
||||
params.publicationAuthority?.commit();
|
||||
await movePathWithCopyFallback({
|
||||
from: stageDir,
|
||||
sourceHardlinks,
|
||||
@@ -458,7 +466,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";
|
||||
@@ -468,12 +478,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",
|
||||
|
||||
@@ -57,4 +57,28 @@ describe("resolveNpmIntegrityDrift", () => {
|
||||
"Integrity drift detected for @openclaw/test@1.0.0: expected sha512-old, got sha512-new",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when a pinned resolution omits fresh integrity metadata", async () => {
|
||||
const warn = vi.fn();
|
||||
const onIntegrityDrift = vi.fn(async () => true);
|
||||
|
||||
const result = await resolveNpmIntegrityDriftWithDefaultMessage({
|
||||
spec: "@openclaw/test@1.0.0",
|
||||
expectedIntegrity: "sha512-reviewed",
|
||||
resolution: {
|
||||
resolvedSpec: "@openclaw/test@1.0.0",
|
||||
resolvedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
onIntegrityDrift,
|
||||
warn,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: "aborted: npm package integrity metadata missing for @openclaw/test@1.0.0",
|
||||
});
|
||||
expect(onIntegrityDrift).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"Integrity metadata missing for @openclaw/test@1.0.0: expected sha512-reviewed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,6 +86,14 @@ type ResolveNpmIntegrityDriftWithDefaultMessageParams = {
|
||||
export async function resolveNpmIntegrityDriftWithDefaultMessage(
|
||||
params: ResolveNpmIntegrityDriftWithDefaultMessageParams,
|
||||
): Promise<{ integrityDrift?: NpmIntegrityDrift; error?: string }> {
|
||||
const expectedIntegrity = normalizeIntegrity(params.expectedIntegrity);
|
||||
const resolvedLabel = params.resolution.resolvedSpec ?? params.spec;
|
||||
if (expectedIntegrity && !normalizeIntegrity(params.resolution.integrity)) {
|
||||
params.warn?.(`Integrity metadata missing for ${resolvedLabel}: expected ${expectedIntegrity}`);
|
||||
return {
|
||||
error: `aborted: npm package integrity metadata missing for ${resolvedLabel}`,
|
||||
};
|
||||
}
|
||||
const driftResult = await resolveNpmIntegrityDrift<NpmIntegrityDriftPayload>({
|
||||
spec: params.spec,
|
||||
expectedIntegrity: params.expectedIntegrity,
|
||||
|
||||
@@ -279,6 +279,7 @@ type ArchiveInstallCall = {
|
||||
dangerouslyForceUnsafeInstall?: boolean;
|
||||
expectedPluginId?: string;
|
||||
onInstallPolicyWarning?: unknown;
|
||||
publicationAuthority?: unknown;
|
||||
installPolicyRequest?: {
|
||||
kind?: string;
|
||||
requestedSpecifier?: string;
|
||||
@@ -299,6 +300,7 @@ type InstallSuccess = {
|
||||
type InstallFailure = {
|
||||
code?: string;
|
||||
error: string;
|
||||
integrity?: string;
|
||||
ok: false;
|
||||
version?: string;
|
||||
warning?: string;
|
||||
@@ -1783,6 +1785,29 @@ describe("installPluginFromClawHub", () => {
|
||||
expect(archiveInstallCall().onInstallPolicyWarning).toBe(onInstallPolicyWarning);
|
||||
});
|
||||
|
||||
it("passes install publication authority through to archive installs", async () => {
|
||||
const publicationAuthority = {
|
||||
assertCurrent: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
};
|
||||
|
||||
await installPluginFromClawHub({
|
||||
spec: "clawhub:demo",
|
||||
publicationAuthority,
|
||||
});
|
||||
|
||||
expect(archiveInstallCall().publicationAuthority).toBe(publicationAuthority);
|
||||
});
|
||||
|
||||
it("preserves the reviewed policy spec when retrying a pinned ClawHub version", async () => {
|
||||
await installPluginFromClawHub({
|
||||
spec: "clawhub:demo@2026.3.22",
|
||||
installPolicyRequestedSpecifier: "clawhub:demo",
|
||||
});
|
||||
|
||||
expect(archiveInstallCall().installPolicyRequest?.requestedSpecifier).toBe("clawhub:demo");
|
||||
});
|
||||
|
||||
it("cleans up the downloaded archive even when archive install fails", async () => {
|
||||
installPluginFromArchiveMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
@@ -1794,7 +1819,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);
|
||||
});
|
||||
|
||||
|
||||
+26
-17
@@ -53,6 +53,7 @@ import { CLAWHUB_INSTALL_ERROR_CODE, type ClawHubInstallErrorCode } from "./claw
|
||||
import type { ClawHubPluginInstallRecordFields } from "./clawhub-install-records.js";
|
||||
import type { InstallSafetyOverrides } from "./install-security-scan.js";
|
||||
import { copyPluginInstallTransactionRequest } from "./install-transaction.js";
|
||||
import type { InstallPublicationOptions } from "./install-types.js";
|
||||
import {
|
||||
installPluginFromArchive,
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
@@ -74,6 +75,7 @@ type ClawHubInstallFailure = {
|
||||
ok: false;
|
||||
error: string;
|
||||
code?: ClawHubInstallErrorCode;
|
||||
integrity?: string;
|
||||
warning?: string;
|
||||
version?: string;
|
||||
};
|
||||
@@ -1207,21 +1209,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;
|
||||
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 +1449,7 @@ export async function installPluginFromClawHub(
|
||||
const installResult = await installPluginFromArchive(
|
||||
copyPluginInstallTransactionRequest(params, {
|
||||
archivePath: archive.archivePath,
|
||||
publicationAuthority: params.publicationAuthority,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
onInstallPolicyWarning: params.onInstallPolicyWarning,
|
||||
trustedSourceLinkedOfficialInstall:
|
||||
@@ -1458,7 +1463,7 @@ export async function installPluginFromClawHub(
|
||||
expectedPluginId: runtimeIdResolution.expectedPluginId,
|
||||
installPolicyRequest: {
|
||||
kind: "plugin-archive",
|
||||
requestedSpecifier: params.spec,
|
||||
requestedSpecifier: params.installPolicyRequestedSpecifier ?? params.spec,
|
||||
source: {
|
||||
kind: "clawhub",
|
||||
authority: officialClawHubPackage ? "official" : clawhubAuthority,
|
||||
@@ -1469,7 +1474,11 @@ export async function installPluginFromClawHub(
|
||||
}),
|
||||
);
|
||||
if (!installResult.ok) {
|
||||
return installResult;
|
||||
return {
|
||||
...installResult,
|
||||
integrity: archive.integrity,
|
||||
version: versionState.version,
|
||||
};
|
||||
}
|
||||
|
||||
const pkg = detail.package!;
|
||||
|
||||
+13
-10
@@ -32,6 +32,7 @@ import {
|
||||
isPluginInstallCommitDeferred,
|
||||
type PluginInstallTransaction,
|
||||
} from "./install-transaction.js";
|
||||
import type { InstallPublicationOptions } from "./install-types.js";
|
||||
import {
|
||||
installPluginFromInstalledPackageDir,
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
@@ -377,16 +378,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) {
|
||||
@@ -525,6 +527,7 @@ export async function installPluginFromGitSpec(
|
||||
}
|
||||
let transaction: PluginInstallTransaction | undefined;
|
||||
if (!params.dryRun) {
|
||||
params.publicationAuthority?.commit();
|
||||
const replaceResult = await replaceManagedGitRepo({
|
||||
stagedRepoDir: repoDir,
|
||||
persistentRepoDir,
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
isPluginInstallCommitDeferred,
|
||||
} from "./install-transaction.js";
|
||||
import type {
|
||||
InstallPublicationOptions,
|
||||
InstallPluginResult,
|
||||
PluginInstallLogger,
|
||||
PluginInstallPolicyRequest,
|
||||
@@ -75,27 +76,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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type ResolveNpmSpecMetadata =
|
||||
typeof import("../infra/install-source-utils.js").resolveNpmSpecMetadata;
|
||||
type PreflightPluginNpmInstallPolicy =
|
||||
typeof import("./install-security-scan.js").preflightPluginNpmInstallPolicy;
|
||||
|
||||
const resolveNpmSpecMetadataMock = vi.fn<ResolveNpmSpecMetadata>();
|
||||
const preflightPluginNpmInstallPolicyMock = vi.fn<PreflightPluginNpmInstallPolicy>();
|
||||
|
||||
vi.mock("../infra/install-source-utils.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../infra/install-source-utils.js")>();
|
||||
return { ...actual, resolveNpmSpecMetadata: resolveNpmSpecMetadataMock };
|
||||
});
|
||||
|
||||
vi.mock("./install-security-scan.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./install-security-scan.js")>();
|
||||
return {
|
||||
...actual,
|
||||
preflightPluginNpmInstallPolicy: preflightPluginNpmInstallPolicyMock,
|
||||
};
|
||||
});
|
||||
|
||||
const { installPluginFromNpmSpec } = await import("./install-npm.js");
|
||||
|
||||
describe("npm install policy metadata", () => {
|
||||
beforeEach(() => {
|
||||
resolveNpmSpecMetadataMock.mockReset();
|
||||
preflightPluginNpmInstallPolicyMock.mockReset();
|
||||
resolveNpmSpecMetadataMock.mockResolvedValue({
|
||||
ok: true,
|
||||
metadata: {
|
||||
name: "@openclaw/demo",
|
||||
version: "1.2.3",
|
||||
resolvedSpec: "@openclaw/demo@1.2.3",
|
||||
integrity: "sha512-reviewed",
|
||||
shasum: "reviewed",
|
||||
packageOpenClaw: { extensions: ["./index.js"] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps policy metadata stable when a retry pins the resolved npm spec", async () => {
|
||||
const policyInputs: Array<{
|
||||
requestedSpecifier: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}> = [];
|
||||
preflightPluginNpmInstallPolicyMock.mockImplementation(async (params) => {
|
||||
policyInputs.push({
|
||||
requestedSpecifier: params.requestedSpecifier ?? "",
|
||||
metadata: JSON.parse(await fs.readFile(params.sourcePath, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
return { blocked: { code: "security_scan_blocked", reason: "test stop" } };
|
||||
});
|
||||
|
||||
const requestedSpecifier = "@openclaw/demo@latest";
|
||||
const first = await installPluginFromNpmSpec({ spec: requestedSpecifier });
|
||||
const retry = await installPluginFromNpmSpec({
|
||||
spec: "@openclaw/demo@1.2.3",
|
||||
installPolicyRequestedSpecifier: requestedSpecifier,
|
||||
expectedIntegrity: "sha512-reviewed",
|
||||
});
|
||||
|
||||
expect(first.ok).toBe(false);
|
||||
expect(retry.ok).toBe(false);
|
||||
expect(policyInputs).toHaveLength(2);
|
||||
expect(policyInputs[0]).toMatchObject({
|
||||
requestedSpecifier,
|
||||
metadata: {
|
||||
packageName: "@openclaw/demo",
|
||||
requestedSpecifier,
|
||||
resolution: {
|
||||
resolvedSpec: "@openclaw/demo@1.2.3",
|
||||
integrity: "sha512-reviewed",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(policyInputs[1]).toEqual(policyInputs[0]);
|
||||
expect(policyInputs[0]).not.toHaveProperty("metadata.resolution.resolvedAt");
|
||||
});
|
||||
});
|
||||
+27
-20
@@ -38,6 +38,7 @@ import {
|
||||
import { copyPluginInstallTransactionRequest } from "./install-transaction.js";
|
||||
import {
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
type InstallPublicationOptions,
|
||||
type InstallPluginResult,
|
||||
type PluginInstallLogger,
|
||||
type PluginNpmIntegrityDriftParams,
|
||||
@@ -45,20 +46,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;
|
||||
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(
|
||||
@@ -67,6 +70,7 @@ export async function installPluginFromNpmSpec(
|
||||
);
|
||||
const expectedPluginId = params.expectedPluginId;
|
||||
const spec = params.spec.trim();
|
||||
const installPolicyRequestedSpecifier = params.installPolicyRequestedSpecifier?.trim() || spec;
|
||||
const specError = runtime.validateRegistryNpmSpec(spec);
|
||||
if (specError) {
|
||||
return {
|
||||
@@ -212,14 +216,16 @@ export async function installPluginFromNpmSpec(
|
||||
|
||||
const policyTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-npm-policy-"));
|
||||
try {
|
||||
const policyNpmResolution = { ...npmResolution };
|
||||
delete policyNpmResolution.resolvedAt;
|
||||
const policyMetadataPath = path.join(policyTempDir, "npm-package-metadata.json");
|
||||
await fs.writeFile(
|
||||
policyMetadataPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
packageName: parsedSpec.name,
|
||||
requestedSpecifier: spec,
|
||||
resolution: npmResolution,
|
||||
requestedSpecifier: installPolicyRequestedSpecifier,
|
||||
resolution: policyNpmResolution,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -240,14 +246,14 @@ export async function installPluginFromNpmSpec(
|
||||
mode: policyMode,
|
||||
packageName: parsedSpec.name,
|
||||
...(expectedPluginId ? { pluginId: expectedPluginId } : {}),
|
||||
requestedSpecifier: spec,
|
||||
requestedSpecifier: installPolicyRequestedSpecifier,
|
||||
source: npmInstallPolicySource,
|
||||
sourcePath: policyMetadataPath,
|
||||
sourcePathKind: "file",
|
||||
}),
|
||||
});
|
||||
if (preflightPolicyResult) {
|
||||
return preflightPolicyResult;
|
||||
return { ...preflightPolicyResult, npmResolution };
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(policyTempDir, { recursive: true, force: true });
|
||||
@@ -255,6 +261,7 @@ export async function installPluginFromNpmSpec(
|
||||
|
||||
const result = await installPluginFromManagedNpmRoot(
|
||||
copyPluginInstallTransactionRequest(params, {
|
||||
publicationAuthority: params.publicationAuthority,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
onInstallPolicyWarning: params.onInstallPolicyWarning,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
@@ -267,7 +274,7 @@ export async function installPluginFromNpmSpec(
|
||||
displaySpec: spec,
|
||||
installPolicyRequest: {
|
||||
kind: "plugin-npm",
|
||||
requestedSpecifier: spec,
|
||||
requestedSpecifier: installPolicyRequestedSpecifier,
|
||||
source: npmInstallPolicySource,
|
||||
},
|
||||
extensionsDir: params.extensionsDir,
|
||||
@@ -290,5 +297,5 @@ export async function installPluginFromNpmSpec(
|
||||
sourceFamily: "npm",
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
});
|
||||
return result;
|
||||
return result.ok ? result : { ...result, npmResolution };
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ function pickPackageInstallCommonParams(
|
||||
config: params.config,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
onInstallPolicyWarning: params.onInstallPolicyWarning,
|
||||
publicationAuthority: params.publicationAuthority,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
extensionsDir: params.extensionsDir,
|
||||
npmDir: params.npmDir,
|
||||
@@ -195,6 +196,7 @@ async function installBundleFromSourceDir(
|
||||
copyErrorPrefix: "failed to copy plugin bundle",
|
||||
hasDeps: false,
|
||||
depsLogMessage: "",
|
||||
publicationAuthority: params.publicationAuthority,
|
||||
}),
|
||||
);
|
||||
return installed.ok
|
||||
@@ -334,6 +336,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({
|
||||
@@ -389,6 +392,7 @@ export async function installPluginFromArchive(
|
||||
sourceDir,
|
||||
...pickPackageInstallCommonParams(
|
||||
copyPluginInstallTransactionRequest(params, {
|
||||
publicationAuthority: params.publicationAuthority,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
onInstallPolicyWarning: params.onInstallPolicyWarning,
|
||||
extensionsDir: params.extensionsDir,
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { discoverOpenClawPlugins } from "./discovery.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,
|
||||
@@ -484,6 +484,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.
|
||||
@@ -628,6 +629,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" },
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
replaceConfigFile: vi.fn(),
|
||||
writeInstallRecords: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
replaceConfigFile: mocks.replaceConfigFile,
|
||||
resolveConfigWriteAfterWrite: (value?: unknown) => value ?? { mode: "auto" },
|
||||
}));
|
||||
|
||||
vi.mock("./installed-plugin-index-records.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./installed-plugin-index-records.js")>()),
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease: mocks.writeInstallRecords,
|
||||
}));
|
||||
|
||||
vi.mock("./plugin-lifecycle-lease.js", () => ({
|
||||
withPluginLifecycleLease: async (
|
||||
_options: unknown,
|
||||
run: (lease: { databasePath: string }) => Promise<unknown>,
|
||||
) => await run({ databasePath: "/tmp/openclaw-plugin-index.sqlite" }),
|
||||
}));
|
||||
|
||||
const { commitPluginInstallRecordsWithConfig } = await import("./install-record-commit.js");
|
||||
|
||||
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.writeInstallRecords).not.toHaveBeenCalled();
|
||||
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -395,6 +395,7 @@ async function commitPluginInstallRecordsWithWriter(params: {
|
||||
nextConfig: OpenClawConfig;
|
||||
writeOptions?: ConfigWriteOptions;
|
||||
commit: ConfigCommit;
|
||||
commitPublication?: () => void;
|
||||
}): Promise<{
|
||||
committed: ConfigReplaceResult | void;
|
||||
nextInstallRecords: Record<string, PluginInstallRecord>;
|
||||
@@ -406,6 +407,7 @@ async function commitPluginInstallRecordsWithWriter(params: {
|
||||
try {
|
||||
const storeOptions = { filePath: lease.databasePath };
|
||||
const prepared = await params.prepareInstallRecords(storeOptions);
|
||||
params.commitPublication?.();
|
||||
tentativeWrite = await writePersistedInstalledPluginIndexInstallRecordsWithLease(
|
||||
prepared.nextInstallRecords,
|
||||
{
|
||||
@@ -475,6 +477,7 @@ export async function commitPluginInstallRecordsWithConfig(params: {
|
||||
nextConfig: OpenClawConfig;
|
||||
baseHash?: string;
|
||||
writeOptions?: ConfigWriteOptions;
|
||||
commitPublication?: () => void;
|
||||
}): Promise<void> {
|
||||
await commitPluginInstallRecordsWithWriter({
|
||||
prepareInstallRecords: async (storeOptions) => ({
|
||||
@@ -484,6 +487,7 @@ export async function commitPluginInstallRecordsWithConfig(params: {
|
||||
nextInstallRecords: params.nextInstallRecords,
|
||||
}),
|
||||
nextConfig: params.nextConfig,
|
||||
...(params.commitPublication ? { commitPublication: params.commitPublication } : {}),
|
||||
...(params.writeOptions ? { writeOptions: params.writeOptions } : {}),
|
||||
commit: async (nextConfig, writeOptions) => {
|
||||
return await replaceConfigFile({
|
||||
|
||||
@@ -3,14 +3,16 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
|
||||
const runInstallPolicyMock = vi.fn();
|
||||
type RunInstallPolicy = typeof import("../security/install-policy.js").runInstallPolicy;
|
||||
|
||||
const runInstallPolicyMock = vi.fn<RunInstallPolicy>();
|
||||
const getGlobalHookRunnerMock = vi.fn();
|
||||
|
||||
vi.mock("../security/install-policy.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../security/install-policy.js")>();
|
||||
return {
|
||||
...actual,
|
||||
runInstallPolicy: (...args: unknown[]) => runInstallPolicyMock(...args),
|
||||
runInstallPolicy: runInstallPolicyMock,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -326,10 +328,18 @@ describe("legacy file install scan compatibility", () => {
|
||||
const onInstallPolicyWarning = vi.fn().mockResolvedValue({ status: "approved" });
|
||||
runInstallPolicyMock
|
||||
.mockResolvedValueOnce({
|
||||
warning: { reason: "review this plugin", fingerprint: "warning-a" },
|
||||
warning: {
|
||||
reason: "review this plugin",
|
||||
fingerprint: "warning-a",
|
||||
approvalFingerprint: "approval-a",
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
warning: { reason: "review this plugin", fingerprint: "warning-a" },
|
||||
warning: {
|
||||
reason: "review this plugin",
|
||||
fingerprint: "warning-a",
|
||||
approvalFingerprint: "approval-a",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await scanFileInstallSourceRuntime({
|
||||
@@ -344,7 +354,20 @@ describe("legacy file install scan compatibility", () => {
|
||||
targetName: "payload",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
approvalFingerprint: "approval-a",
|
||||
scan: {
|
||||
requestKind: "plugin-file",
|
||||
originType: "plugin-file",
|
||||
pluginContentType: "file",
|
||||
},
|
||||
warning: {
|
||||
targetName: "payload",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: "review this plugin",
|
||||
},
|
||||
});
|
||||
expect(onInstallPolicyWarning).toHaveBeenCalledTimes(1);
|
||||
expect(runInstallPolicyMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -352,10 +375,18 @@ describe("legacy file install scan compatibility", () => {
|
||||
const onInstallPolicyWarning = vi.fn().mockResolvedValue({ status: "approved" });
|
||||
runInstallPolicyMock
|
||||
.mockResolvedValueOnce({
|
||||
warning: { reason: "review this plugin", fingerprint: "warning-a" },
|
||||
warning: {
|
||||
reason: "review this plugin",
|
||||
fingerprint: "warning-a",
|
||||
approvalFingerprint: "approval-a",
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
warning: { reason: "review the new finding", fingerprint: "warning-b" },
|
||||
warning: {
|
||||
reason: "review the new finding",
|
||||
fingerprint: "warning-b",
|
||||
approvalFingerprint: "approval-a",
|
||||
},
|
||||
findings: [
|
||||
{
|
||||
ruleId: "changed-warning",
|
||||
@@ -374,6 +405,27 @@ describe("legacy file install scan compatibility", () => {
|
||||
|
||||
expect(result?.blocked).toMatchObject({
|
||||
code: "security_scan_blocked",
|
||||
installPolicyWarning: {
|
||||
scan: {
|
||||
requestKind: "plugin-file",
|
||||
originType: "plugin-file",
|
||||
pluginContentType: "file",
|
||||
},
|
||||
warning: {
|
||||
targetName: "payload",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: "review the new finding",
|
||||
findings: [
|
||||
{
|
||||
ruleId: "changed-warning",
|
||||
severity: "warn",
|
||||
message: "new finding",
|
||||
},
|
||||
],
|
||||
},
|
||||
approvalFingerprint: "approval-a",
|
||||
},
|
||||
});
|
||||
expect(result?.blocked?.reason).toContain("Reason: review the new finding");
|
||||
expect(result?.blocked?.reason).toContain("new finding");
|
||||
@@ -385,7 +437,11 @@ describe("legacy file install scan compatibility", () => {
|
||||
it("renders metadata changes that require approval again", async () => {
|
||||
const onInstallPolicyWarning = vi.fn().mockResolvedValue({ status: "approved" });
|
||||
const initialWarning = {
|
||||
warning: { reason: "same bounded reason", fingerprint: "full-warning-a" },
|
||||
warning: {
|
||||
reason: "same bounded reason",
|
||||
fingerprint: "full-warning-a",
|
||||
approvalFingerprint: "approval-a",
|
||||
},
|
||||
findings: [
|
||||
{
|
||||
ruleId: "initial-rule",
|
||||
@@ -396,7 +452,11 @@ describe("legacy file install scan compatibility", () => {
|
||||
],
|
||||
};
|
||||
runInstallPolicyMock.mockResolvedValueOnce(initialWarning).mockResolvedValueOnce({
|
||||
warning: { ...initialWarning.warning, fingerprint: "full-warning-b" },
|
||||
warning: {
|
||||
...initialWarning.warning,
|
||||
fingerprint: "full-warning-b",
|
||||
approvalFingerprint: "approval-b",
|
||||
},
|
||||
findings: [
|
||||
{
|
||||
ruleId: "refreshed-rule",
|
||||
@@ -421,6 +481,7 @@ describe("legacy file install scan compatibility", () => {
|
||||
);
|
||||
expect(result?.blocked?.reason).not.toContain("initial-rule");
|
||||
expect(result?.blocked?.reason).not.toContain("initial evidence");
|
||||
expect(result?.blocked?.installPolicyWarning?.approvalFingerprint).toBe("approval-b");
|
||||
expect(onInstallPolicyWarning).toHaveBeenCalledTimes(1);
|
||||
expect(runInstallPolicyMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -428,7 +489,11 @@ describe("legacy file install scan compatibility", () => {
|
||||
it("keeps a block from policy re-evaluation terminal", async () => {
|
||||
runInstallPolicyMock
|
||||
.mockResolvedValueOnce({
|
||||
warning: { reason: "review this plugin", fingerprint: "warning-a" },
|
||||
warning: {
|
||||
reason: "review this plugin",
|
||||
fingerprint: "warning-a",
|
||||
approvalFingerprint: "approval-a",
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
blocked: { code: "security_scan_blocked", reason: "now blocked" },
|
||||
@@ -446,7 +511,11 @@ describe("legacy file install scan compatibility", () => {
|
||||
|
||||
it("keeps the deprecated unsafe flag inert when policy warns", async () => {
|
||||
runInstallPolicyMock.mockResolvedValue({
|
||||
warning: { reason: "review this plugin", fingerprint: "warning-a" },
|
||||
warning: {
|
||||
reason: "review this plugin",
|
||||
fingerprint: "warning-a",
|
||||
approvalFingerprint: "approval-a",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await scanFileInstallSourceRuntime({
|
||||
@@ -458,6 +527,20 @@ describe("legacy file install scan compatibility", () => {
|
||||
|
||||
expect(result?.blocked).toEqual({
|
||||
code: "security_scan_blocked",
|
||||
installPolicyWarning: {
|
||||
scan: {
|
||||
requestKind: "plugin-file",
|
||||
originType: "plugin-file",
|
||||
pluginContentType: "file",
|
||||
},
|
||||
warning: {
|
||||
targetName: "payload",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: "review this plugin",
|
||||
},
|
||||
approvalFingerprint: "approval-a",
|
||||
},
|
||||
reason: expectedInstallPolicyNotice({
|
||||
decision: "warn",
|
||||
guidance: [
|
||||
@@ -479,7 +562,11 @@ describe("legacy file install scan compatibility", () => {
|
||||
it("keeps a block from acknowledged policy re-evaluation terminal", async () => {
|
||||
runInstallPolicyMock
|
||||
.mockResolvedValueOnce({
|
||||
warning: { reason: "review this plugin", fingerprint: "warning-a" },
|
||||
warning: {
|
||||
reason: "review this plugin",
|
||||
fingerprint: "warning-a",
|
||||
approvalFingerprint: "approval-a",
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
blocked: { code: "security_scan_blocked", reason: "now blocked" },
|
||||
@@ -498,7 +585,11 @@ describe("legacy file install scan compatibility", () => {
|
||||
|
||||
it("distinguishes an exhausted noninteractive approval from cancellation", async () => {
|
||||
runInstallPolicyMock.mockResolvedValue({
|
||||
warning: { reason: "review the dependency warning", fingerprint: "warning-a" },
|
||||
warning: {
|
||||
reason: "review the dependency warning",
|
||||
fingerprint: "warning-a",
|
||||
approvalFingerprint: "approval-a",
|
||||
},
|
||||
findings: [
|
||||
{
|
||||
ruleId: "dependency-warning",
|
||||
@@ -523,23 +614,56 @@ describe("legacy file install scan compatibility", () => {
|
||||
expect(result?.blocked?.reason).toContain("The noninteractive approval was already used.");
|
||||
expect(result?.blocked?.reason).toContain("Review this warning and rerun interactively.");
|
||||
expect(result?.blocked?.reason).not.toContain("Install cancelled");
|
||||
expect(result?.blocked?.installPolicyWarning).toMatchObject({
|
||||
scan: {
|
||||
requestKind: "plugin-file",
|
||||
originType: "plugin-file",
|
||||
pluginContentType: "file",
|
||||
},
|
||||
warning: {
|
||||
targetName: "payload",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: "review the dependency warning",
|
||||
},
|
||||
approvalFingerprint: "approval-a",
|
||||
});
|
||||
expect(runInstallPolicyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders warning details as one readable review notice", async () => {
|
||||
const warnings: string[] = [];
|
||||
runInstallPolicyMock.mockResolvedValue({
|
||||
warning: { reason: "review this plugin", fingerprint: "warning-a" },
|
||||
warning: {
|
||||
reason: "review this plugin",
|
||||
fingerprint: "warning-a",
|
||||
approvalFingerprint: "approval-a",
|
||||
},
|
||||
findings: [{ ruleId: "context", severity: "info", message: "Informational context." }],
|
||||
});
|
||||
|
||||
await scanFileInstallSourceRuntime({
|
||||
const result = await scanFileInstallSourceRuntime({
|
||||
filePath: "/tmp/payload.js",
|
||||
logger: { warn: (message) => warnings.push(message) },
|
||||
onInstallPolicyWarning: vi.fn().mockResolvedValue({ status: "declined" }),
|
||||
pluginId: "payload",
|
||||
});
|
||||
|
||||
expect(result?.blocked?.installPolicyWarning).toEqual({
|
||||
scan: {
|
||||
requestKind: "plugin-file",
|
||||
originType: "plugin-file",
|
||||
pluginContentType: "file",
|
||||
},
|
||||
warning: {
|
||||
targetName: "payload",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: "review this plugin",
|
||||
findings: [{ ruleId: "context", severity: "info", message: "Informational context." }],
|
||||
},
|
||||
approvalFingerprint: "approval-a",
|
||||
});
|
||||
expect(warnings).toEqual([
|
||||
`${expectedInstallPolicyNotice({
|
||||
decision: "warn",
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
import { isPathInside } from "../security/scan-paths.js";
|
||||
import { getGlobalHookRunner } from "./hook-runner-global.js";
|
||||
import { createBeforeInstallHookPayload } from "./install-policy-context.js";
|
||||
import type { InstallSafetyOverrides } from "./install-security-scan.types.js";
|
||||
import type {
|
||||
InstallPolicyWarningDetails,
|
||||
InstallPolicyWarningOccurrence,
|
||||
InstallSafetyOverrides,
|
||||
} from "./install-security-scan.types.js";
|
||||
|
||||
type InstallScanLogger = {
|
||||
warn?: (message: string) => void;
|
||||
@@ -109,6 +113,7 @@ export type InstallSecurityScanResult = {
|
||||
blocked?: {
|
||||
code?: "security_scan_blocked" | "security_scan_failed";
|
||||
reason: string;
|
||||
installPolicyWarning?: InstallPolicyWarningOccurrence;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -821,10 +826,28 @@ async function runOperatorInstallPolicy(params: {
|
||||
logPolicyResult(result);
|
||||
return undefined;
|
||||
}
|
||||
const installPolicyWarning: InstallPolicyWarningDetails = {
|
||||
targetName: params.targetName,
|
||||
targetType: params.targetType,
|
||||
requestMode: params.requestMode,
|
||||
reason: result.warning.reason,
|
||||
...(result.findings?.length ? { findings: result.findings } : {}),
|
||||
};
|
||||
const warningOccurrence: InstallPolicyWarningOccurrence = {
|
||||
scan: {
|
||||
requestKind: params.requestKind,
|
||||
originType: params.origin.type,
|
||||
...(params.plugin ? { pluginContentType: params.plugin.contentType } : {}),
|
||||
...(params.skill ? { skillInstallId: params.skill.installId } : {}),
|
||||
},
|
||||
warning: installPolicyWarning,
|
||||
approvalFingerprint: result.warning.approvalFingerprint,
|
||||
};
|
||||
if (!params.onInstallPolicyWarning) {
|
||||
return {
|
||||
blocked: {
|
||||
code: "security_scan_blocked",
|
||||
installPolicyWarning: warningOccurrence,
|
||||
reason: formatInstallPolicyNotice({
|
||||
decision: "warn",
|
||||
findings: result.findings,
|
||||
@@ -841,6 +864,7 @@ async function runOperatorInstallPolicy(params: {
|
||||
targetName: params.targetName,
|
||||
targetType: params.targetType,
|
||||
requestMode: params.requestMode,
|
||||
...warningOccurrence,
|
||||
});
|
||||
if (acknowledgement.status === "approved") {
|
||||
const reevaluated = await evaluatePolicy();
|
||||
@@ -866,6 +890,17 @@ async function runOperatorInstallPolicy(params: {
|
||||
return {
|
||||
blocked: {
|
||||
code: "security_scan_blocked",
|
||||
installPolicyWarning: {
|
||||
scan: warningOccurrence.scan,
|
||||
warning: {
|
||||
targetName: params.targetName,
|
||||
targetType: params.targetType,
|
||||
requestMode: params.requestMode,
|
||||
reason: reevaluated.warning.reason,
|
||||
...(reevaluated.findings?.length ? { findings: reevaluated.findings } : {}),
|
||||
},
|
||||
approvalFingerprint: reevaluated.warning.approvalFingerprint,
|
||||
},
|
||||
reason: formatInstallPolicyNotice({
|
||||
decision: "warn",
|
||||
findings: reevaluated.findings,
|
||||
@@ -883,19 +918,25 @@ async function runOperatorInstallPolicy(params: {
|
||||
} else {
|
||||
logPolicyResult(reevaluated);
|
||||
}
|
||||
// Approval covers only an unchanged warning after a fresh evaluation.
|
||||
// One-shot callers still reject later or changed warnings.
|
||||
return undefined;
|
||||
}
|
||||
if (acknowledgement.status === "unavailable") {
|
||||
return {
|
||||
blocked: {
|
||||
code: "security_scan_blocked",
|
||||
installPolicyWarning: warningOccurrence,
|
||||
reason: formatInstallPolicyNotice({
|
||||
decision: "warn",
|
||||
findings: result.findings,
|
||||
guidance: [
|
||||
"The noninteractive approval was already used.",
|
||||
"Review this warning and rerun interactively.",
|
||||
],
|
||||
guidance:
|
||||
acknowledgement.reason === "approval-exhausted"
|
||||
? [
|
||||
"The noninteractive approval was already used.",
|
||||
"Review this warning and rerun interactively.",
|
||||
]
|
||||
: ["This warning has not been approved.", "Review it and try again."],
|
||||
reason: result.warning.reason,
|
||||
targetName: params.targetName,
|
||||
targetType: params.targetType,
|
||||
@@ -906,6 +947,7 @@ async function runOperatorInstallPolicy(params: {
|
||||
return {
|
||||
blocked: {
|
||||
code: "security_scan_blocked",
|
||||
installPolicyWarning: warningOccurrence,
|
||||
reason: "Install cancelled: the install policy warning was not approved.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
InstallPolicySource,
|
||||
} from "../security/install-policy.js";
|
||||
export type { InstallSafetyOverrides } from "./install-security-scan.types.js";
|
||||
import type { InstallSafetyOverrides } from "./install-security-scan.types.js";
|
||||
import type {
|
||||
InstallPolicyWarningOccurrence,
|
||||
InstallSafetyOverrides,
|
||||
} from "./install-security-scan.types.js";
|
||||
|
||||
type InstallScanLogger = {
|
||||
warn?: (message: string) => void;
|
||||
@@ -17,6 +20,7 @@ export type InstallSecurityScanResult = {
|
||||
blocked?: {
|
||||
code?: "security_scan_blocked" | "security_scan_failed";
|
||||
reason: string;
|
||||
installPolicyWarning?: InstallPolicyWarningOccurrence;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
// Defines plugin install security scan result types.
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { InstallPolicyFinding, InstallPolicyRequestKind } from "../security/install-policy.js";
|
||||
|
||||
export type InstallPolicyWarningAcknowledgementRequest = {
|
||||
export type InstallPolicyWarningDetails = {
|
||||
targetName: string;
|
||||
targetType: "skill" | "plugin";
|
||||
requestMode: "install" | "update";
|
||||
reason: string;
|
||||
findings?: InstallPolicyFinding[];
|
||||
};
|
||||
|
||||
/** Stable policy-stage facts that scope an approval without exposing scan internals to clients. */
|
||||
type InstallPolicyWarningScanIdentity = {
|
||||
requestKind: InstallPolicyRequestKind;
|
||||
originType: string;
|
||||
pluginContentType?: "bundle" | "package" | "file" | "dependency-tree";
|
||||
skillInstallId?: string;
|
||||
};
|
||||
|
||||
export type InstallPolicyWarningOccurrence = {
|
||||
scan: InstallPolicyWarningScanIdentity;
|
||||
warning: InstallPolicyWarningDetails;
|
||||
approvalFingerprint: string;
|
||||
};
|
||||
|
||||
export type InstallPolicyWarningAcknowledgementRequest = InstallPolicyWarningOccurrence & {
|
||||
targetName: string;
|
||||
targetType: "skill" | "plugin";
|
||||
requestMode: "install" | "update";
|
||||
@@ -12,7 +35,7 @@ type InstallPolicyWarningAcknowledgementResult =
|
||||
| { status: "declined" }
|
||||
| {
|
||||
status: "unavailable";
|
||||
reason: "approval-exhausted";
|
||||
reason: "approval-exhausted" | "warning-not-approved";
|
||||
};
|
||||
|
||||
/** Overrides that intentionally loosen install safety policy for trusted/operator paths. */
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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 { createSyncSuiteTempRootTracker } from "./test-helpers/fs-fixtures.js";
|
||||
|
||||
describe("installPluginDirectoryIntoExtensions", () => {
|
||||
const tempRoots = createSyncSuiteTempRootTracker("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 = {
|
||||
approvalFingerprint: "review-installed-dependency-tree",
|
||||
scan: {
|
||||
requestKind: "plugin-archive" as const,
|
||||
originType: "plugin-dependency-tree",
|
||||
pluginContentType: "dependency-tree" as const,
|
||||
},
|
||||
warning: {
|
||||
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.warning.reason,
|
||||
code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED,
|
||||
installPolicyWarning,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: installPolicyWarning.warning.reason,
|
||||
code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED,
|
||||
installPolicyWarning,
|
||||
});
|
||||
expect(fs.existsSync(targetDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "./install-transaction.js";
|
||||
import {
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
type InstallPublicationAuthority,
|
||||
type InstallPluginResult,
|
||||
type PackageManifest,
|
||||
type PluginInstallErrorCode,
|
||||
@@ -226,6 +227,9 @@ function buildBlockedInstallResult(params: {
|
||||
return {
|
||||
ok: false,
|
||||
error: params.blocked.reason,
|
||||
...(params.blocked.installPolicyWarning
|
||||
? { installPolicyWarning: params.blocked.installPolicyWarning }
|
||||
: {}),
|
||||
...(params.blocked.code === "security_scan_failed"
|
||||
? { code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED }
|
||||
: params.blocked.code === "security_scan_blocked"
|
||||
@@ -385,6 +389,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();
|
||||
@@ -437,12 +442,9 @@ 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;
|
||||
},
|
||||
publicationAuthority: params.publicationAuthority,
|
||||
};
|
||||
const installRes = await runtime.installPackageDir(
|
||||
isPluginInstallCommitDeferred(params)
|
||||
@@ -450,11 +452,7 @@ export async function installPluginDirectoryIntoExtensions(params: {
|
||||
: packageInstallParams,
|
||||
);
|
||||
if (!installRes.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: installRes.error,
|
||||
...(installRes.code ? { code: installRes.code as PluginInstallErrorCode } : {}),
|
||||
};
|
||||
return installRes;
|
||||
}
|
||||
|
||||
const result = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NpmIntegrityDrift, NpmSpecResolution } from "../infra/install-sour
|
||||
import type { InstallPolicySource } from "../security/install-policy.js";
|
||||
import type { PluginInstallArtifactInspection } from "./install-artifact-inspection.js";
|
||||
import type { InstallSafetyOverrides } from "./install-security-scan.js";
|
||||
import type { InstallPolicyWarningOccurrence } from "./install-security-scan.types.js";
|
||||
import type { PackageManifest as PluginPackageManifest, PluginManifestSetup } from "./manifest.js";
|
||||
|
||||
export type PluginInstallLogger = {
|
||||
@@ -50,7 +51,13 @@ export type InstallPluginResult =
|
||||
npmResolution?: NpmSpecResolution;
|
||||
integrityDrift?: NpmIntegrityDrift;
|
||||
}
|
||||
| { ok: false; error: string; code?: PluginInstallErrorCode };
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
code?: PluginInstallErrorCode;
|
||||
installPolicyWarning?: InstallPolicyWarningOccurrence;
|
||||
npmResolution?: NpmSpecResolution;
|
||||
};
|
||||
|
||||
export type PluginInstallFailureResult = Extract<InstallPluginResult, { ok: false }>;
|
||||
|
||||
@@ -67,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;
|
||||
};
|
||||
|
||||
@@ -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?.warning.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?.warning.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>;
|
||||
|
||||
@@ -3612,6 +3612,28 @@ describe("installPluginFromNpmSpec", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a pinned npm retry cannot resolve fresh integrity metadata", async () => {
|
||||
mockNpmViewMetadataResult(runCommandWithTimeoutMock, {
|
||||
name: "@openclaw/voice-call",
|
||||
version: "0.0.1",
|
||||
shasum: "reviewedshasum",
|
||||
});
|
||||
|
||||
const onIntegrityDrift = vi.fn(async () => true);
|
||||
const result = await installPluginFromNpmSpec({
|
||||
spec: "@openclaw/voice-call@0.0.1",
|
||||
expectedIntegrity: "sha512-reviewed",
|
||||
onIntegrityDrift,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: "aborted: npm package integrity metadata missing for @openclaw/voice-call@0.0.1",
|
||||
});
|
||||
expect(onIntegrityDrift).not.toHaveBeenCalled();
|
||||
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("classifies npm package-not-found errors with a stable error code", async () => {
|
||||
runCommandWithTimeoutMock.mockResolvedValue({
|
||||
code: 1,
|
||||
|
||||
@@ -1040,6 +1040,92 @@ describe("installPluginFromArchive", () => {
|
||||
expect(updatedVersion).toBe("0.0.2");
|
||||
});
|
||||
|
||||
it("does not publish a new archive install after its approval expires", async () => {
|
||||
const stateDir = suiteTempRootTracker.makeTempDir();
|
||||
const extensionsDir = path.join(stateDir, "extensions");
|
||||
const archivePath = await ensureDynamicArchiveTemplate({
|
||||
outName: "archive-publication-authority.tgz",
|
||||
packageJson: {
|
||||
name: "archive-publication-authority",
|
||||
version: "1.0.0",
|
||||
openclaw: { extensions: ["./dist/index.js"] },
|
||||
},
|
||||
withDistIndex: true,
|
||||
});
|
||||
const commit = vi.fn(() => {
|
||||
throw new Error("approval expired");
|
||||
});
|
||||
|
||||
const result = await installPluginFromArchive({
|
||||
archivePath,
|
||||
extensionsDir,
|
||||
publicationAuthority: { assertCurrent: vi.fn(), commit },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: "failed to copy plugin: Error: approval expired",
|
||||
});
|
||||
expect(commit).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
fs.existsSync(resolvePluginInstallDir("archive-publication-authority", extensionsDir)),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("restores an archive update when its approval expires during publication", async () => {
|
||||
const stateDir = suiteTempRootTracker.makeTempDir();
|
||||
const extensionsDir = path.join(stateDir, "extensions");
|
||||
const archiveV1 = await ensureDynamicArchiveTemplate({
|
||||
outName: "archive-publication-update-1.tgz",
|
||||
packageJson: {
|
||||
name: "archive-publication-update",
|
||||
version: "1.0.0",
|
||||
openclaw: { extensions: ["./dist/index.js"] },
|
||||
},
|
||||
withDistIndex: true,
|
||||
});
|
||||
const archiveV2 = await ensureDynamicArchiveTemplate({
|
||||
outName: "archive-publication-update-2.tgz",
|
||||
packageJson: {
|
||||
name: "archive-publication-update",
|
||||
version: "2.0.0",
|
||||
openclaw: { extensions: ["./dist/index.js"] },
|
||||
},
|
||||
withDistIndex: true,
|
||||
});
|
||||
const first = await installPluginFromArchive({ archivePath: archiveV1, extensionsDir });
|
||||
expect(first.ok).toBe(true);
|
||||
if (!first.ok) {
|
||||
return;
|
||||
}
|
||||
let approvalCurrent = true;
|
||||
const assertCurrent = vi.fn(() => {
|
||||
approvalCurrent = false;
|
||||
});
|
||||
const commit = vi.fn(() => {
|
||||
if (!approvalCurrent) {
|
||||
throw new Error("approval expired");
|
||||
}
|
||||
});
|
||||
|
||||
const update = await installPluginFromArchive({
|
||||
archivePath: archiveV2,
|
||||
extensionsDir,
|
||||
mode: "update",
|
||||
publicationAuthority: { assertCurrent, commit },
|
||||
});
|
||||
|
||||
expect(update).toEqual({
|
||||
ok: false,
|
||||
error: "failed to copy plugin: Error: approval expired",
|
||||
});
|
||||
expect(assertCurrent).toHaveBeenCalledOnce();
|
||||
expect(commit).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
JSON.parse(fs.readFileSync(path.join(first.targetDir, "package.json"), "utf8")),
|
||||
).toMatchObject({ version: "1.0.0" });
|
||||
});
|
||||
|
||||
it("emits effective install mode when requested archive update creates a new target", async () => {
|
||||
const stateDir = suiteTempRootTracker.makeTempDir();
|
||||
const extensionsDir = path.join(stateDir, "extensions");
|
||||
|
||||
@@ -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,502 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
InstallSafetyOverrides,
|
||||
InstallPolicyWarningAcknowledgementRequest,
|
||||
InstallPolicyWarningOccurrence,
|
||||
} 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")}`,
|
||||
publicationAuthority:
|
||||
officialDiffsWarningRequest.installPolicyWarningAcknowledgement.publicationAuthority,
|
||||
}),
|
||||
);
|
||||
expect(mocks.persistInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
publicationAuthority:
|
||||
officialDiffsWarningRequest.installPolicyWarningAcknowledgement.publicationAuthority,
|
||||
}),
|
||||
);
|
||||
await expectOneShotInstallPolicyWarningAcknowledgement(mocks.clawhubInstall);
|
||||
});
|
||||
|
||||
it("acknowledges the same warning only for the same target and scan stage 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 packageWarning: InstallPolicyWarningOccurrence = expectDefined(
|
||||
officialDiffsWarningRequest.installPolicyWarningAcknowledgement.warnings[0],
|
||||
"first approved warning",
|
||||
);
|
||||
const dependencyWarning: InstallPolicyWarningOccurrence = {
|
||||
approvalFingerprint: packageWarning.approvalFingerprint,
|
||||
scan: {
|
||||
...packageWarning.scan,
|
||||
originType: "plugin-dependency-tree",
|
||||
pluginContentType: "dependency-tree",
|
||||
},
|
||||
warning: packageWarning.warning,
|
||||
};
|
||||
const changedFingerprintWarning: InstallPolicyWarningOccurrence = {
|
||||
...packageWarning,
|
||||
approvalFingerprint: "changed-warning-approval",
|
||||
};
|
||||
const wrongTargetWarning: InstallPolicyWarningOccurrence = {
|
||||
...packageWarning,
|
||||
warning: { ...packageWarning.warning, targetName: "other-plugin" },
|
||||
};
|
||||
const restagedPackageWarning: InstallPolicyWarningOccurrence = {
|
||||
...packageWarning,
|
||||
warning: {
|
||||
...packageWarning.warning,
|
||||
reason: "Review this warning in /tmp/openclaw-stage-b",
|
||||
},
|
||||
};
|
||||
|
||||
await installManagedPlugin({
|
||||
request: {
|
||||
...officialDiffsWarningRequest,
|
||||
installPolicyWarningAcknowledgement: {
|
||||
...officialDiffsWarningRequest.installPolicyWarningAcknowledgement,
|
||||
warnings: [packageWarning],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
const call = expectDefined(mocks.clawhubInstall.mock.calls[0], "clawhub install call");
|
||||
const acknowledge = expectDefined(
|
||||
(
|
||||
call[0] as {
|
||||
onInstallPolicyWarning?: InstallSafetyOverrides["onInstallPolicyWarning"];
|
||||
}
|
||||
).onInstallPolicyWarning,
|
||||
"install-policy acknowledgement callback",
|
||||
);
|
||||
expect(
|
||||
await acknowledge({
|
||||
targetName: changedFingerprintWarning.warning.targetName,
|
||||
targetType: changedFingerprintWarning.warning.targetType,
|
||||
requestMode: changedFingerprintWarning.warning.requestMode,
|
||||
...changedFingerprintWarning,
|
||||
}),
|
||||
).toEqual({ status: "unavailable", reason: "warning-not-approved" });
|
||||
expect(
|
||||
await acknowledge({
|
||||
targetName: dependencyWarning.warning.targetName,
|
||||
targetType: dependencyWarning.warning.targetType,
|
||||
requestMode: dependencyWarning.warning.requestMode,
|
||||
...dependencyWarning,
|
||||
}),
|
||||
).toEqual({ status: "unavailable", reason: "warning-not-approved" });
|
||||
expect(
|
||||
await acknowledge({
|
||||
targetName: wrongTargetWarning.warning.targetName,
|
||||
targetType: wrongTargetWarning.warning.targetType,
|
||||
requestMode: wrongTargetWarning.warning.requestMode,
|
||||
...wrongTargetWarning,
|
||||
}),
|
||||
).toEqual({ status: "unavailable", reason: "warning-not-approved" });
|
||||
const packageRequest: InstallPolicyWarningAcknowledgementRequest = {
|
||||
targetName: restagedPackageWarning.warning.targetName,
|
||||
targetType: restagedPackageWarning.warning.targetType,
|
||||
requestMode: restagedPackageWarning.warning.requestMode,
|
||||
...restagedPackageWarning,
|
||||
};
|
||||
expect(await acknowledge(packageRequest)).toEqual({ status: "approved" });
|
||||
expect(await acknowledge(packageRequest)).toEqual({
|
||||
status: "unavailable",
|
||||
reason: "warning-not-approved",
|
||||
});
|
||||
});
|
||||
|
||||
it("pins reviewed npm warnings to the first resolved version and integrity", async () => {
|
||||
const warning: InstallPolicyWarningOccurrence = {
|
||||
approvalFingerprint: "review-npm-package",
|
||||
scan: {
|
||||
requestKind: "plugin-npm",
|
||||
originType: "plugin-npm",
|
||||
pluginContentType: "package",
|
||||
},
|
||||
warning: {
|
||||
targetName: "npm-demo",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
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.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,
|
||||
installPolicyRequestedSpecifier: "@openclaw/npm-demo",
|
||||
expectedIntegrity: npmResolution.integrity,
|
||||
});
|
||||
|
||||
await expect(
|
||||
installManagedPlugin({
|
||||
request: {
|
||||
source: "official",
|
||||
pluginId: "npm-demo",
|
||||
installPolicyWarningAcknowledgement: {
|
||||
publicationAuthority: {
|
||||
assertCurrent: () => {},
|
||||
commit: () => {},
|
||||
},
|
||||
warnings: [warning],
|
||||
resolvedRequest,
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
}),
|
||||
).rejects.toThrow("stop after inspecting the pinned retry");
|
||||
expect(mocks.npmInstall).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: npmResolution.resolvedSpec,
|
||||
installPolicyRequestedSpecifier: "@openclaw/npm-demo",
|
||||
expectedIntegrity: npmResolution.integrity,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps npm warnings terminal when immutable resolution metadata is incomplete", async () => {
|
||||
const warning: InstallPolicyWarningOccurrence = {
|
||||
approvalFingerprint: "review-npm-package",
|
||||
scan: {
|
||||
requestKind: "plugin-npm",
|
||||
originType: "plugin-npm",
|
||||
pluginContentType: "package",
|
||||
},
|
||||
warning: {
|
||||
targetName: "npm-demo",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
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.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: InstallPolicyWarningOccurrence = {
|
||||
approvalFingerprint: "review-clawhub-package",
|
||||
scan: {
|
||||
requestKind: "plugin-archive",
|
||||
originType: "plugin-package",
|
||||
pluginContentType: "package",
|
||||
},
|
||||
warning: {
|
||||
targetName: "demo",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: "Review this ClawHub package",
|
||||
},
|
||||
};
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mocks.clawhubInstall.mockResolvedValue({
|
||||
ok: false,
|
||||
error: warning.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",
|
||||
installPolicyRequestedSpecifier: "clawhub:community/demo",
|
||||
expectedIntegrity: "sha256-reviewed",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
emptyMetadataSnapshot,
|
||||
metadataSnapshot,
|
||||
} from "./test-helpers/management-service-fixtures.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
applyUninstall: vi.fn(),
|
||||
@@ -124,66 +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 origin = params.origin ?? "bundled";
|
||||
const installRecord =
|
||||
params.installRecord ??
|
||||
(origin === "global" ? { source: "path", installPath: `/tmp/${id}` } : undefined);
|
||||
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,
|
||||
rootDir: `/tmp/${id}`,
|
||||
source: `/tmp/${id}/index.ts`,
|
||||
manifestPath: `/tmp/${id}/openclaw.plugin.json`,
|
||||
};
|
||||
return {
|
||||
index: {
|
||||
plugins: [
|
||||
{
|
||||
pluginId: id,
|
||||
...(origin === "global" ? { installOwner: id } : {}),
|
||||
packageName: `@openclaw/${id}`,
|
||||
origin,
|
||||
enabled: params.enabled,
|
||||
rootDir: `/tmp/${id}`,
|
||||
},
|
||||
],
|
||||
installRecords: installRecord ? { [id]: 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",
|
||||
@@ -292,6 +236,7 @@ describe("plugin management service", () => {
|
||||
expect.objectContaining({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
packageName: "@openclaw/diffs",
|
||||
installed: false,
|
||||
featured: true,
|
||||
order: 40,
|
||||
@@ -779,29 +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: { source: "official", pluginId: "diffs" },
|
||||
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")}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -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";
|
||||
@@ -42,7 +43,8 @@ import {
|
||||
} from "./install-persistence.js";
|
||||
import { commitPluginInstallRecordsWithConfig } from "./install-record-commit.js";
|
||||
import type { InstallSafetyOverrides } from "./install-security-scan.js";
|
||||
import type { PluginInstallLogger } from "./install-types.js";
|
||||
import type { InstallPolicyWarningOccurrence } from "./install-security-scan.types.js";
|
||||
import type { InstallPublicationAuthority, PluginInstallLogger } from "./install-types.js";
|
||||
import {
|
||||
installPluginFromNpmPackArchive,
|
||||
installPluginFromNpmSpec,
|
||||
@@ -123,14 +125,25 @@ type ManagedPluginCatalog = {
|
||||
mutationAllowed: boolean;
|
||||
};
|
||||
|
||||
type ManagedPluginInstallRequest =
|
||||
export type ManagedPluginInstallPolicyAcknowledgement = {
|
||||
warnings: InstallPolicyWarningOccurrence[];
|
||||
resolvedRequest: ManagedPluginSourceInstallRequest;
|
||||
publicationAuthority: InstallPublicationAuthority;
|
||||
};
|
||||
|
||||
export type ManagedPluginInstallRequest =
|
||||
| {
|
||||
source: "clawhub";
|
||||
packageName: string;
|
||||
version?: string;
|
||||
acknowledgeClawHubRisk?: boolean;
|
||||
installPolicyWarningAcknowledgement?: ManagedPluginInstallPolicyAcknowledgement;
|
||||
}
|
||||
| { source: "official"; pluginId: string };
|
||||
| {
|
||||
source: "official";
|
||||
pluginId: string;
|
||||
installPolicyWarningAcknowledgement?: ManagedPluginInstallPolicyAcknowledgement;
|
||||
};
|
||||
|
||||
export type ManagedPluginSourceInstallRequest =
|
||||
| {
|
||||
@@ -150,6 +163,7 @@ export type ManagedPluginSourceInstallRequest =
|
||||
| {
|
||||
source: "clawhub";
|
||||
spec: string;
|
||||
installPolicyRequestedSpecifier?: string;
|
||||
mode?: "install" | "update";
|
||||
expectedPluginId?: string;
|
||||
expectedIntegrity?: string;
|
||||
@@ -165,6 +179,7 @@ export type ManagedPluginSourceInstallRequest =
|
||||
| {
|
||||
source: "official";
|
||||
spec: string;
|
||||
installPolicyRequestedSpecifier?: string;
|
||||
pluginId: string;
|
||||
expectedIntegrity?: string;
|
||||
mode: "install" | "update";
|
||||
@@ -173,6 +188,7 @@ export type ManagedPluginSourceInstallRequest =
|
||||
| {
|
||||
source: "npm";
|
||||
spec: string;
|
||||
installPolicyRequestedSpecifier?: string;
|
||||
mode: "install" | "update";
|
||||
pin?: boolean;
|
||||
expectedPluginId?: string;
|
||||
@@ -192,10 +208,28 @@ type ManagedPluginSourceInstallResult =
|
||||
npmResolution?: NpmSpecResolution;
|
||||
clawhub?: ClawHubPluginInstallRecordFields;
|
||||
}
|
||||
| { ok: false; error: string; code?: string; version?: string; warning?: string };
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
code?: string;
|
||||
integrity?: string;
|
||||
version?: string;
|
||||
warning?: string;
|
||||
installPolicyWarning?: InstallPolicyWarningOccurrence;
|
||||
npmResolution?: NpmSpecResolution;
|
||||
};
|
||||
|
||||
type SourceInstallerResult =
|
||||
| { ok: false; error: string; code?: string; version?: string; warning?: string }
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
code?: string;
|
||||
integrity?: string;
|
||||
version?: string;
|
||||
warning?: string;
|
||||
installPolicyWarning?: InstallPolicyWarningOccurrence;
|
||||
npmResolution?: NpmSpecResolution;
|
||||
}
|
||||
| {
|
||||
ok: true;
|
||||
pluginId: string;
|
||||
@@ -209,6 +243,9 @@ export class ManagedPluginLifecycleError extends Error {
|
||||
readonly code?: string;
|
||||
readonly version?: string;
|
||||
readonly warning?: string;
|
||||
readonly installPolicyWarning?: InstallPolicyWarningOccurrence;
|
||||
readonly installPolicyAcknowledgedWarnings?: InstallPolicyWarningOccurrence[];
|
||||
readonly installPolicyResolvedRequest?: ManagedPluginSourceInstallRequest;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -217,6 +254,9 @@ export class ManagedPluginLifecycleError extends Error {
|
||||
code?: string;
|
||||
version?: string;
|
||||
warning?: string;
|
||||
installPolicyWarning?: InstallPolicyWarningOccurrence;
|
||||
installPolicyAcknowledgedWarnings?: InstallPolicyWarningOccurrence[];
|
||||
installPolicyResolvedRequest?: ManagedPluginSourceInstallRequest;
|
||||
cause?: unknown;
|
||||
},
|
||||
) {
|
||||
@@ -226,6 +266,9 @@ export class ManagedPluginLifecycleError extends Error {
|
||||
this.code = details?.code;
|
||||
this.version = details?.version;
|
||||
this.warning = details?.warning;
|
||||
this.installPolicyWarning = details?.installPolicyWarning;
|
||||
this.installPolicyAcknowledgedWarnings = details?.installPolicyAcknowledgedWarnings;
|
||||
this.installPolicyResolvedRequest = details?.installPolicyResolvedRequest;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -844,6 +887,9 @@ export async function listManagedPlugins(params: {
|
||||
}
|
||||
const kind = normalizeKinds(entry.kind);
|
||||
const install = resolveCatalogInstallAction({ entry, pluginId });
|
||||
const clawhubPackageName = resolveCatalogPackageSourceIdentities(entry).find(
|
||||
(identity) => identity.source === "clawhub",
|
||||
)?.packageName;
|
||||
const description = normalizeOptionalString(entry.description);
|
||||
const version = normalizeOptionalString(entry.version);
|
||||
const featuredAt =
|
||||
@@ -851,6 +897,7 @@ export async function listManagedPlugins(params: {
|
||||
plugins.push({
|
||||
id: pluginId,
|
||||
name: resolveOfficialExternalPluginLabel(entry),
|
||||
...(clawhubPackageName ? { packageName: clawhubPackageName } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(version ? { version } : {}),
|
||||
...(kind ? { kind } : {}),
|
||||
@@ -981,24 +1028,84 @@ function buildClawHubSpec(packageName: string, version?: string): string {
|
||||
return `clawhub:${packageName}${version ? `@${version}` : ""}`;
|
||||
}
|
||||
|
||||
function throwInstallFailure(result: {
|
||||
error: string;
|
||||
code?: string;
|
||||
version?: string;
|
||||
warning?: string;
|
||||
}): never {
|
||||
function pinInstallPolicyResolvedRequest(
|
||||
request: ManagedPluginSourceInstallRequest,
|
||||
version: string | undefined,
|
||||
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,
|
||||
installPolicyRequestedSpecifier: request.installPolicyRequestedSpecifier ?? request.spec,
|
||||
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),
|
||||
installPolicyRequestedSpecifier: request.installPolicyRequestedSpecifier ?? request.spec,
|
||||
expectedIntegrity: integrity,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function throwInstallFailure(
|
||||
result: {
|
||||
error: string;
|
||||
code?: string;
|
||||
integrity?: string;
|
||||
version?: string;
|
||||
warning?: string;
|
||||
installPolicyWarning?: InstallPolicyWarningOccurrence;
|
||||
npmResolution?: NpmSpecResolution;
|
||||
},
|
||||
resolvedRequest?: ManagedPluginSourceInstallRequest,
|
||||
installPolicyAcknowledgedWarnings?: InstallPolicyWarningOccurrence[],
|
||||
): 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,
|
||||
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(
|
||||
@@ -1078,6 +1185,9 @@ function throwPersistenceFailureWithCleanupWarnings(error: unknown, warnings: st
|
||||
code: error.code,
|
||||
version: error.version,
|
||||
warning: [error.warning, cleanupWarning].filter(Boolean).join("\n"),
|
||||
installPolicyWarning: error.installPolicyWarning,
|
||||
installPolicyAcknowledgedWarnings: error.installPolicyAcknowledgedWarnings,
|
||||
installPolicyResolvedRequest: error.installPolicyResolvedRequest,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
@@ -1100,6 +1210,7 @@ async function persistManagedSourceInstall(params: {
|
||||
persistenceLogger?: PluginInstallLogger;
|
||||
successMessage?: string;
|
||||
cleanupOnPersistenceFailure?: boolean;
|
||||
publicationAuthority?: InstallPublicationAuthority;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const persist = () =>
|
||||
persistPluginInstall({
|
||||
@@ -1110,6 +1221,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();
|
||||
@@ -1134,6 +1246,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;
|
||||
@@ -1159,6 +1272,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>,
|
||||
@@ -1296,6 +1410,9 @@ export async function installManagedPluginSource(params: {
|
||||
installPluginFromClawHub({
|
||||
...common,
|
||||
spec: request.spec,
|
||||
...(request.installPolicyRequestedSpecifier
|
||||
? { installPolicyRequestedSpecifier: request.installPolicyRequestedSpecifier }
|
||||
: {}),
|
||||
mode: request.mode,
|
||||
...(request.expectedPluginId ? { expectedPluginId: request.expectedPluginId } : {}),
|
||||
...(request.expectedIntegrity ? { expectedIntegrity: request.expectedIntegrity } : {}),
|
||||
@@ -1319,6 +1436,9 @@ export async function installManagedPluginSource(params: {
|
||||
installPluginFromNpmSpec({
|
||||
...common,
|
||||
spec: request.spec,
|
||||
...(request.installPolicyRequestedSpecifier
|
||||
? { installPolicyRequestedSpecifier: request.installPolicyRequestedSpecifier }
|
||||
: {}),
|
||||
mode: request.mode,
|
||||
...(request.source === "official" || request.trustedSourceLinkedOfficialInstall
|
||||
? { trustedSourceLinkedOfficialInstall: true }
|
||||
@@ -1427,8 +1547,13 @@ export async function installManagedPlugin(params: {
|
||||
const officialCatalog = await loadOfficialCatalog();
|
||||
const warnings: string[] = [];
|
||||
const installLogger = createInstallLogger(warnings);
|
||||
const remainingInstallPolicyWarnings = [
|
||||
...(params.request.installPolicyWarningAcknowledgement?.warnings ?? []),
|
||||
];
|
||||
const acknowledgedInstallPolicyWarnings: InstallPolicyWarningOccurrence[] = [];
|
||||
const request =
|
||||
params.request.source === "clawhub"
|
||||
params.request.installPolicyWarningAcknowledgement?.resolvedRequest ??
|
||||
(params.request.source === "clawhub"
|
||||
? resolveManagedClawHubInstallRequest({
|
||||
request: params.request,
|
||||
officialEntries: officialCatalog.entries,
|
||||
@@ -1436,19 +1561,51 @@ export async function installManagedPlugin(params: {
|
||||
: resolveManagedOfficialInstallRequest({
|
||||
request: params.request,
|
||||
officialEntries: officialCatalog.entries,
|
||||
});
|
||||
}));
|
||||
const installed = await installManagedPluginSource({
|
||||
request,
|
||||
snapshot,
|
||||
env,
|
||||
logger: installLogger,
|
||||
persistenceLogger: installLogger,
|
||||
publicationAuthority:
|
||||
params.request.installPolicyWarningAcknowledgement?.publicationAuthority,
|
||||
...(params.request.installPolicyWarningAcknowledgement
|
||||
? {
|
||||
safetyOverrides: {
|
||||
onInstallPolicyWarning: async ({
|
||||
scan,
|
||||
approvalFingerprint,
|
||||
targetName,
|
||||
targetType,
|
||||
requestMode,
|
||||
}) => {
|
||||
const warningIndex = remainingInstallPolicyWarnings.findIndex(
|
||||
(approved) =>
|
||||
approved.approvalFingerprint === approvalFingerprint &&
|
||||
isDeepStrictEqual(approved.scan, scan) &&
|
||||
approved.warning.targetName === targetName &&
|
||||
approved.warning.targetType === targetType &&
|
||||
approved.warning.requestMode === requestMode,
|
||||
);
|
||||
if (warningIndex < 0) {
|
||||
return { status: "unavailable", reason: "warning-not-approved" };
|
||||
}
|
||||
const [approved] = remainingInstallPolicyWarnings.splice(warningIndex, 1);
|
||||
if (approved) {
|
||||
acknowledgedInstallPolicyWarnings.push(approved);
|
||||
}
|
||||
return { status: "approved" };
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
cleanupOnPersistenceFailure: true,
|
||||
invalidateRuntimeCache: false,
|
||||
runtime: createSilentRuntime(),
|
||||
});
|
||||
if (!installed.ok) {
|
||||
return throwInstallFailure(installed);
|
||||
return throwInstallFailure(installed, request, acknowledgedInstallPolicyWarnings);
|
||||
}
|
||||
const catalog = await listManagedPlugins({
|
||||
config: installed.config,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { expect } from "vitest";
|
||||
import type {
|
||||
InstallSafetyOverrides,
|
||||
InstallPolicyWarningAcknowledgementRequest,
|
||||
InstallPolicyWarningOccurrence,
|
||||
} from "../install-security-scan.types.js";
|
||||
|
||||
const officialDiffsWarningOccurrence: InstallPolicyWarningOccurrence = {
|
||||
approvalFingerprint: "review-diffs-warning",
|
||||
scan: {
|
||||
requestKind: "plugin-archive",
|
||||
originType: "plugin-package",
|
||||
pluginContentType: "package",
|
||||
},
|
||||
warning: {
|
||||
targetName: "diffs",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
reason: "Review this warning",
|
||||
},
|
||||
};
|
||||
|
||||
type InstallPolicyWarningCall = {
|
||||
onInstallPolicyWarning?: InstallSafetyOverrides["onInstallPolicyWarning"];
|
||||
};
|
||||
|
||||
export const officialDiffsWarningRequest = {
|
||||
source: "official",
|
||||
pluginId: "diffs",
|
||||
installPolicyWarningAcknowledgement: {
|
||||
publicationAuthority: {
|
||||
assertCurrent: () => {},
|
||||
commit: () => {},
|
||||
},
|
||||
resolvedRequest: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:@openclaw/diffs@2026.6.11",
|
||||
expectedPluginId: "diffs",
|
||||
expectedIntegrity: `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`,
|
||||
},
|
||||
warnings: [officialDiffsWarningOccurrence],
|
||||
},
|
||||
} as const;
|
||||
|
||||
export async function expectOneShotInstallPolicyWarningAcknowledgement(mock: {
|
||||
mock: { calls: unknown[][] };
|
||||
}): Promise<void> {
|
||||
const call = expectDefined(mock.mock.calls[0], "clawhub install call test invariant");
|
||||
const params = call[0] as InstallPolicyWarningCall;
|
||||
const acknowledge = expectDefined(
|
||||
params.onInstallPolicyWarning,
|
||||
"expected install-policy acknowledgement callback",
|
||||
);
|
||||
const request: InstallPolicyWarningAcknowledgementRequest = {
|
||||
targetName: "diffs",
|
||||
targetType: "plugin",
|
||||
requestMode: "install",
|
||||
...officialDiffsWarningOccurrence,
|
||||
};
|
||||
expect(await acknowledge(request)).toEqual({ status: "approved" });
|
||||
expect(await acknowledge(request)).toEqual({
|
||||
status: "unavailable",
|
||||
reason: "warning-not-approved",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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 origin = params.origin ?? "bundled";
|
||||
const packageName =
|
||||
params.packageName === null ? undefined : (params.packageName ?? `@openclaw/${id}`);
|
||||
const installRecord =
|
||||
params.installRecord ??
|
||||
(origin === "global" ? { source: "path", installPath: `/tmp/${id}` } : undefined);
|
||||
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,
|
||||
rootDir: `/tmp/${id}`,
|
||||
source: `/tmp/${id}/index.ts`,
|
||||
manifestPath: `/tmp/${id}/openclaw.plugin.json`,
|
||||
};
|
||||
return {
|
||||
index: {
|
||||
plugins: [
|
||||
{
|
||||
pluginId: id,
|
||||
...(origin === "global" ? { installOwner: id } : {}),
|
||||
...(packageName ? { packageName } : {}),
|
||||
origin,
|
||||
enabled: params.enabled ?? true,
|
||||
rootDir: `/tmp/${id}`,
|
||||
},
|
||||
],
|
||||
installRecords: installRecord ? { [id]: 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user