From ec737ee74d9be52101992f0cd5d17f5d3953344b Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:33:19 +1000 Subject: [PATCH] fix: rebase clawhub install trust (#81364) --- docs/clawhub/cli.md | 14 + docs/cli/plugins.md | 11 + docs/cli/skills.md | 10 + docs/cli/update.md | 11 + .../src/clawhub-trust-error-details.ts | 66 + packages/gateway-protocol/src/index.ts | 8 + .../src/schema/agents-models-skills.test.ts | 32 + .../src/schema/agents-models-skills.ts | 7 + scripts/e2e/lib/clawhub-fixture-server.cjs | 21 + .../reply/commands-plugins.install.test.ts | 147 +++ src/auto-reply/reply/commands-plugins.test.ts | 3 + src/auto-reply/reply/commands-plugins.ts | 44 +- src/cli/clawhub-risk-acknowledgement.test.ts | 211 ++++ src/cli/clawhub-risk-acknowledgement.ts | 39 + src/cli/plugins-cli-test-helpers.ts | 8 + src/cli/plugins-cli.install.test.ts | 101 ++ src/cli/plugins-cli.runtime.ts | 1 + src/cli/plugins-cli.ts | 34 +- src/cli/plugins-cli.update.test.ts | 196 +++ src/cli/plugins-command-helpers.ts | 2 +- src/cli/plugins-install-command.ts | 24 +- src/cli/plugins-update-command.ts | 15 +- src/cli/plugins-update-outcomes.ts | 5 + src/cli/prompt.ts | 7 + src/cli/skills-cli.commands.test.ts | 92 ++ src/cli/skills-cli.ts | 69 +- src/cli/update-cli.option-collisions.test.ts | 36 +- src/cli/update-cli.test.ts | 625 +++++++++- src/cli/update-cli.ts | 46 +- .../post-core-plugin-convergence.test.ts | 56 + .../post-core-plugin-convergence.ts | 14 +- src/cli/update-cli/shared.ts | 2 + src/cli/update-cli/update-command.ts | 170 ++- .../codex-runtime-plugin-install.test.ts | 62 + src/commands/doctor/repair-sequencing.test.ts | 63 + src/commands/doctor/repair-sequencing.ts | 4 + .../missing-configured-plugin-install.test.ts | 436 ++++++- .../missing-configured-plugin-install.ts | 125 +- ...release-configured-plugin-installs.test.ts | 32 + .../release-configured-plugin-installs.ts | 6 +- .../onboarding-plugin-install.test.ts | 71 ++ src/commands/onboarding-plugin-install.ts | 66 + src/commands/runtime-plugin-install.ts | 2 +- src/config/types.installs.ts | 8 + src/config/zod-schema.installs.ts | 15 + .../server-methods/skills.clawhub.test.ts | 146 ++- src/gateway/server-methods/skills.ts | 29 +- src/infra/clawhub-install-trust.ts | 1098 +++++++++++++++++ src/infra/clawhub.test.ts | 80 ++ src/infra/clawhub.ts | 204 +++ src/plugins/clawhub-error-codes.ts | 3 + src/plugins/clawhub-install-records.ts | 38 + src/plugins/clawhub.test.ts | 857 ++++++++++++- src/plugins/clawhub.ts | 196 ++- .../install-security-scan.runtime.test.ts | 176 +++ src/plugins/install-security-scan.runtime.ts | 217 ++-- src/plugins/install-security-scan.ts | 1 + src/plugins/install.ts | 10 +- .../installed-plugin-index-install-records.ts | 41 + .../installed-plugin-index-records.test.ts | 12 + src/plugins/installed-plugin-index-types.ts | 8 + src/plugins/installs.test.ts | 46 + src/plugins/installs.ts | 25 +- src/plugins/update.test.ts | 406 +++++- src/plugins/update.ts | 162 ++- src/skills/lifecycle/clawhub.test.ts | 776 +++++++++++- src/skills/lifecycle/clawhub.ts | 242 +++- ui/src/ui/app-render.ts | 3 +- ui/src/ui/app-view-state.ts | 10 +- ui/src/ui/app.ts | 15 +- ui/src/ui/controllers/skills.test.ts | 97 ++ ui/src/ui/controllers/skills.ts | 89 +- ui/src/ui/views/skills.test.ts | 32 + ui/src/ui/views/skills.ts | 34 +- 74 files changed, 7727 insertions(+), 343 deletions(-) create mode 100644 packages/gateway-protocol/src/clawhub-trust-error-details.ts create mode 100644 src/cli/clawhub-risk-acknowledgement.test.ts create mode 100644 src/cli/clawhub-risk-acknowledgement.ts create mode 100644 src/commands/codex-runtime-plugin-install.test.ts create mode 100644 src/infra/clawhub-install-trust.ts create mode 100644 src/plugins/install-security-scan.runtime.test.ts diff --git a/docs/clawhub/cli.md b/docs/clawhub/cli.md index 7f2bcf359531..d6180ba5f0c6 100644 --- a/docs/clawhub/cli.md +++ b/docs/clawhub/cli.md @@ -24,17 +24,31 @@ OpenClaw agent or Gateway. ```bash openclaw skills search "calendar" openclaw skills install @owner/ +openclaw skills install @owner/ --acknowledge-clawhub-risk openclaw skills update @owner/ +openclaw skills update @owner/ --acknowledge-clawhub-risk openclaw skills verify @owner/ openclaw plugins search "calendar" openclaw plugins install clawhub: +openclaw plugins install clawhub: --acknowledge-clawhub-risk openclaw plugins update ``` Skill installs target the active workspace `skills/` directory by default. Add `--global` to install into the shared managed skills directory. +OpenClaw checks the selected community ClawHub skill or plugin trust state +before downloading it. Versioned community skill and plugin releases use +exact-release trust metadata; resolver-backed GitHub skills rely on ClawHub's +install resolver to enforce scan and force-install policy before it returns a +pinned commit. Malicious or blocked community releases are refused. Risky +community releases require review and `--acknowledge-clawhub-risk` when a +non-interactive command should continue after that review. + +Official ClawHub publishers/packages and bundled OpenClaw sources bypass this +release-trust prompt and security-verdict fetch during install and update. + Plugin installs use the `clawhub:` prefix when you want ClawHub resolution instead of npm or another install source. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 608f1af5f831..e8bddba90a89 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -111,6 +111,7 @@ openclaw plugins install git:github.com// # git repo openclaw plugins install git:github.com//@ openclaw plugins install --force # overwrite existing install openclaw plugins install --pin # pin version +openclaw plugins install clawhub: --acknowledge-clawhub-risk openclaw plugins install --dangerously-force-unsafe-install openclaw plugins install # local path openclaw plugins install @ # marketplace @@ -163,6 +164,12 @@ is available, then fall back to `latest`. If a plugin you published on ClawHub is hidden or blocked by a registry scan, use the publisher steps in [ClawHub publishing](/clawhub/publishing). `--dangerously-force-unsafe-install` does not ask ClawHub to rescan the plugin or make a blocked release public. + + + Community ClawHub installs check the selected release trust record before downloading the package. If ClawHub disables download for the release, reports malicious scan findings, or puts the release in a blocking moderation state such as quarantine, OpenClaw refuses the release. For non-blocking risky scan statuses, risky moderation states, or registry reasons, OpenClaw shows the trust details and asks for confirmation before continuing. + + Use `--acknowledge-clawhub-risk` only after reviewing the ClawHub warning and deciding to continue without an interactive prompt. Pending or stale clean trust records warn but do not require acknowledgement. Official ClawHub packages and bundled OpenClaw plugin sources bypass this release-trust prompt. + `plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation. @@ -390,6 +397,7 @@ openclaw plugins update openclaw plugins update --all openclaw plugins update --dry-run openclaw plugins update @openclaw/voice-call +openclaw plugins update openclaw-codex-app-server --acknowledge-clawhub-risk openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install ``` @@ -421,6 +429,9 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked `--dangerously-force-unsafe-install` is also accepted on `plugins update` for compatibility, but it is deprecated and no longer changes plugin update behavior. Operator `security.installPolicy` can still block updates; plugin `before_install` hooks only apply in processes where plugin hooks are loaded. + + Community ClawHub-backed plugin updates run the same exact-release trust check as installs before downloading the replacement package. Use `--acknowledge-clawhub-risk` for reviewed automation that should continue when the selected ClawHub release has a risky trust warning. Official ClawHub packages and bundled OpenClaw plugin sources bypass this release-trust prompt. + ### Inspect diff --git a/docs/cli/skills.md b/docs/cli/skills.md index bdbb39932b93..148014adbe90 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -31,9 +31,11 @@ openclaw skills install git:owner/repo openclaw skills install git:owner/repo@main openclaw skills install ./path/to/skill --as custom-name openclaw skills install @owner/ --force +openclaw skills install @owner/ --acknowledge-clawhub-risk openclaw skills install @owner/ --agent openclaw skills install @owner/ --global openclaw skills update @owner/ +openclaw skills update @owner/ --acknowledge-clawhub-risk openclaw skills update @owner/ --global openclaw skills update --all openclaw skills update --all --agent @@ -97,6 +99,14 @@ Notes: - `install --version ` applies only to ClawHub skill refs. - `install --force` overwrites an existing workspace skill folder for the same slug. +- Community ClawHub skill installs and updates check trust before downloading. + Versioned community archive releases use exact-release trust metadata. + Resolver-backed GitHub skills rely on ClawHub's install resolver to enforce + scan and force-install policy before it returns a pinned commit. Malicious or + blocked community releases are refused. Risky community releases require + review and `--acknowledge-clawhub-risk` when a non-interactive command should + continue after that review. Official ClawHub skill publishers and bundled + OpenClaw skill sources bypass this release-trust prompt. - `--global` targets the shared managed skills directory and cannot be combined with `--agent `. - `--agent ` targets one configured agent workspace and overrides current diff --git a/docs/cli/update.md b/docs/cli/update.md index fb2ce17f2df5..459d42ee0fe4 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -28,6 +28,7 @@ openclaw update --tag main openclaw update --dry-run openclaw update --no-restart openclaw update --yes +openclaw update --acknowledge-clawhub-risk openclaw update --json openclaw --update ``` @@ -45,6 +46,11 @@ openclaw --update when npm plugin artifact drift is detected during post-update plugin sync. - `--timeout `: per-step timeout (default is 1800s). - `--yes`: skip confirmation prompts (for example downgrade confirmation). +- `--acknowledge-clawhub-risk`: after reviewing community ClawHub trust + warnings, allow post-update plugin sync to continue without an interactive + prompt. Without this, risky community ClawHub plugin releases are skipped and + left unchanged when OpenClaw cannot prompt. Official ClawHub packages and + bundled OpenClaw plugin sources bypass this release-trust prompt. `openclaw update` does not have a `--verbose` flag. Use `--dry-run` to preview the planned channel/tag/install/restart actions, `--json` for machine-readable @@ -88,6 +94,7 @@ converge. ```bash openclaw update repair openclaw update repair --channel beta +openclaw update repair --acknowledge-clawhub-risk openclaw update repair --json ``` @@ -98,6 +105,10 @@ Options: - `--json`: print machine-readable finalization JSON. - `--timeout `: timeout for repair steps (default `1800`). - `--yes`: skip confirmation prompts. +- `--acknowledge-clawhub-risk`: after reviewing community ClawHub trust + warnings, allow repair-time plugin convergence to continue without an + interactive prompt. Official ClawHub packages and bundled OpenClaw plugin + sources bypass this release-trust prompt. - `--no-restart`: accepted for update command parity; repair never restarts the Gateway. diff --git a/packages/gateway-protocol/src/clawhub-trust-error-details.ts b/packages/gateway-protocol/src/clawhub-trust-error-details.ts new file mode 100644 index 000000000000..353ac71210c4 --- /dev/null +++ b/packages/gateway-protocol/src/clawhub-trust-error-details.ts @@ -0,0 +1,66 @@ +/** Structured ClawHub trust details carried in gateway error payloads. */ +export const ClawHubTrustErrorCodes = { + SECURITY_UNAVAILABLE: "clawhub_security_unavailable", + RISK_ACKNOWLEDGEMENT_REQUIRED: "clawhub_risk_acknowledgement_required", + DOWNLOAD_BLOCKED: "clawhub_download_blocked", +} as const; + +export type ClawHubTrustErrorCode = + (typeof ClawHubTrustErrorCodes)[keyof typeof ClawHubTrustErrorCodes]; + +export type ClawHubTrustErrorDetails = { + clawhubTrustCode?: ClawHubTrustErrorCode; + version?: string; + warning?: string; +}; + +function normalizeNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +export function isClawHubTrustErrorCode(value: unknown): value is ClawHubTrustErrorCode { + return ( + value === ClawHubTrustErrorCodes.SECURITY_UNAVAILABLE || + value === ClawHubTrustErrorCodes.RISK_ACKNOWLEDGEMENT_REQUIRED || + value === ClawHubTrustErrorCodes.DOWNLOAD_BLOCKED + ); +} + +export function buildClawHubTrustErrorDetails(params: { + code?: ClawHubTrustErrorCode; + version?: string; + warning?: string; +}): ClawHubTrustErrorDetails | undefined { + if (!params.code && !params.version && !params.warning) { + return undefined; + } + return { + ...(params.code ? { clawhubTrustCode: params.code } : {}), + ...(params.version ? { version: params.version } : {}), + ...(params.warning ? { warning: params.warning } : {}), + }; +} + +export function readClawHubTrustErrorDetails( + details: unknown, +): ClawHubTrustErrorDetails | undefined { + if (!details || typeof details !== "object" || Array.isArray(details)) { + return undefined; + } + const raw = details as { + clawhubTrustCode?: unknown; + version?: unknown; + warning?: unknown; + }; + const code = isClawHubTrustErrorCode(raw.clawhubTrustCode) ? raw.clawhubTrustCode : undefined; + const version = normalizeNonEmptyString(raw.version); + const warning = normalizeNonEmptyString(raw.warning); + if (!code && !version && !warning) { + return undefined; + } + return { + ...(code ? { clawhubTrustCode: code } : {}), + ...(version ? { version } : {}), + ...(warning ? { warning } : {}), + }; +} diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 2e98ebac84cf..540fbae9949c 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -1,5 +1,13 @@ // Public gateway protocol entrypoint. Keep this barrel aligned with schema.ts // so clients can import wire types, JSON schemas, and validators from one place. +export { + buildClawHubTrustErrorDetails, + ClawHubTrustErrorCodes, + isClawHubTrustErrorCode, + readClawHubTrustErrorDetails, + type ClawHubTrustErrorCode, + type ClawHubTrustErrorDetails, +} from "./clawhub-trust-error-details.js"; import { Compile, type Validator as TypeBoxValidator } from "typebox/compile"; import { type AgentEvent, diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts index e783efb82812..0b5e2b726887 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts @@ -3,6 +3,7 @@ import { Value } from "typebox/value"; import { describe, expect, it } from "vitest"; import { AgentsListResultSchema, + SkillsDetailResultSchema, SkillsProposalInspectResultSchema, SkillsProposalRequestRevisionResultSchema, ToolsEffectiveResultSchema, @@ -173,3 +174,34 @@ describe("SkillsProposalRequestRevisionResultSchema", () => { ).toBe(false); }); }); + +describe("SkillsDetailResultSchema", () => { + it("accepts official ClawHub skill publisher metadata", () => { + const result = { + skill: { + slug: "tao-setup-nvidia-gpu-host", + displayName: "TAO Setup NVIDIA GPU Host", + summary: "Prepare an NVIDIA GPU host for TAO workflows.", + tags: { gpu: "GPU" }, + channel: "official", + isOfficial: true, + createdAt: 1_700_000_000, + updatedAt: 1_700_010_000, + }, + latestVersion: { + version: "1.0.0", + createdAt: 1_700_010_000, + }, + owner: { + handle: "nvidia", + displayName: "NVIDIA", + image: "https://example.test/nvidia.png", + official: true, + channel: "official", + isOfficial: true, + }, + }; + + expect(Value.Check(SkillsDetailResultSchema, result)).toBe(true); + }); +}); diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index 8132bf6d575f..83d77949e4c7 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -344,6 +344,7 @@ export const SkillsInstallParamsSchema = Type.Union([ slug: NonEmptyString, version: Type.Optional(NonEmptyString), force: Type.Optional(Type.Boolean()), + acknowledgeClawHubRisk: Type.Optional(Type.Boolean()), timeoutMs: Type.Optional(Type.Integer({ minimum: 1000 })), }, { additionalProperties: false }, @@ -379,6 +380,7 @@ export const SkillsUpdateParamsSchema = Type.Union([ source: Type.Literal("clawhub"), slug: Type.Optional(NonEmptyString), all: Type.Optional(Type.Boolean()), + acknowledgeClawHubRisk: Type.Optional(Type.Boolean()), }, { additionalProperties: false }, ), @@ -439,6 +441,8 @@ export const SkillsDetailResultSchema = Type.Object( displayName: NonEmptyString, summary: Type.Optional(Type.String()), tags: Type.Optional(Type.Record(NonEmptyString, Type.String())), + channel: Type.Optional(Type.Union([Type.String(), Type.Null()])), + isOfficial: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])), createdAt: Type.Integer(), updatedAt: Type.Integer(), }, @@ -478,6 +482,9 @@ export const SkillsDetailResultSchema = Type.Object( handle: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), displayName: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), image: Type.Optional(Type.Union([Type.String(), Type.Null()])), + official: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])), + channel: Type.Optional(Type.Union([Type.String(), Type.Null()])), + isOfficial: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])), }, { additionalProperties: false }, ), diff --git a/scripts/e2e/lib/clawhub-fixture-server.cjs b/scripts/e2e/lib/clawhub-fixture-server.cjs index 8b4adb359c60..bb2d08791660 100644 --- a/scripts/e2e/lib/clawhub-fixture-server.cjs +++ b/scripts/e2e/lib/clawhub-fixture-server.cjs @@ -403,6 +403,20 @@ async function main() { npmShasum: clawpack.npmShasum, }, }; + const securityDetail = { + package: artifactResolverDetail.package, + release: { + version: fixture.version, + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }; const server = http.createServer((request, response) => { const url = new URL(request.url, "http://127.0.0.1"); @@ -429,6 +443,13 @@ async function main() { json(response, artifactResolverDetail); return; } + if ( + url.pathname === + `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}/security` + ) { + json(response, securityDetail); + return; + } if ( betaStatus !== undefined && url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/beta` diff --git a/src/auto-reply/reply/commands-plugins.install.test.ts b/src/auto-reply/reply/commands-plugins.install.test.ts index 0e3445117810..f712b990c72e 100644 --- a/src/auto-reply/reply/commands-plugins.install.test.ts +++ b/src/auto-reply/reply/commands-plugins.install.test.ts @@ -290,6 +290,16 @@ describe("handleCommands /plugins install", () => { version: "1.2.3", integrity: "sha512-demo", resolvedAt: "2026-03-22T12:00:00.000Z", + artifactKind: "npm-pack", + artifactFormat: "tgz", + npmIntegrity: "sha512-npm-pack", + npmShasum: "a".repeat(40), + npmTarballName: "clawhub-demo-1.2.3.tgz", + clawhubTrustDisposition: "review-recommended", + clawhubTrustScanStatus: "pending", + clawhubTrustReasons: ["scan:pending"], + clawhubTrustPending: true, + clawhubTrustCheckedAt: "2026-03-22T11:59:59.000Z", }, }); persistPluginInstallMock.mockResolvedValue({}); @@ -316,10 +326,147 @@ describe("handleCommands /plugins install", () => { integrity: "sha512-demo", clawhubPackage: "@openclaw/clawhub-demo", clawhubChannel: "official", + artifactKind: "npm-pack", + artifactFormat: "tgz", + npmIntegrity: "sha512-npm-pack", + npmShasum: "a".repeat(40), + npmTarballName: "clawhub-demo-1.2.3.tgz", + clawhubTrustDisposition: "review-recommended", + clawhubTrustScanStatus: "pending", + clawhubTrustReasons: ["scan:pending"], + clawhubTrustPending: true, + clawhubTrustCheckedAt: "2026-03-22T11:59:59.000Z", }); }); }); + it("includes non-blocking ClawHub warnings in successful chat install replies", async () => { + const warning = + 'ClawHub trust warning for "@openclaw/clawhub-demo@1.2.3": scan=pending; reasons=pending.'; + const richWarning = `\u001b[33m${warning}\u001b[39m`; + installPluginFromClawHubMock.mockImplementation(async (params: unknown) => { + if (!params || typeof params !== "object" || !("logger" in params)) { + throw new Error("expected ClawHub install logger"); + } + const logger = params.logger; + if ( + !logger || + typeof logger !== "object" || + !("warn" in logger) || + typeof logger.warn !== "function" + ) { + throw new Error("expected ClawHub install warn logger"); + } + logger.warn(richWarning); + return { + ok: true, + pluginId: "clawhub-demo", + targetDir: "/tmp/clawhub-demo", + version: "1.2.3", + extensions: ["index.js"], + packageName: "@openclaw/clawhub-demo", + clawhub: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "@openclaw/clawhub-demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + version: "1.2.3", + integrity: "sha512-demo", + resolvedAt: "2026-03-22T12:00:00.000Z", + }, + }; + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams( + "/plugins install clawhub:@openclaw/clawhub-demo@1.2.3", + workspaceDir, + ); + const result = await handlePluginsCommand(params, true); + if (result === null) { + throw new Error("expected plugin install result"); + } + expect(result.reply?.text).toContain('Installed plugin "clawhub-demo"'); + expect(result.reply?.text).toContain(warning); + expect(result.reply?.text).not.toContain("\u001b"); + expect(mockFirstObjectArg(installPluginFromClawHubMock).logger).toEqual( + expect.objectContaining({ terminalLinks: false }), + ); + expectPersistedInstall("clawhub-demo", { + source: "clawhub", + spec: "clawhub:@openclaw/clawhub-demo@1.2.3", + installPath: "/tmp/clawhub-demo", + }); + }); + }); + + it("reports risky ClawHub install failures without persisting install metadata", async () => { + const warning = + 'ClawHub trust warning for "@openclaw/risky-demo@1.2.3": scan=suspicious; moderation=none; blockedFromDownload=false; pending=false; stale=false; reasons=payload_string. Risk signals: scan status suspicious, payload_string.'; + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: + 'ClawHub release "@openclaw/risky-demo@1.2.3" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue.', + warning, + }); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams( + "/plugins install clawhub:@openclaw/risky-demo@1.2.3", + workspaceDir, + ); + const result = await handlePluginsCommand(params, true); + if (result === null) { + throw new Error("expected plugin install result"); + } + + expect(result.reply?.text).toContain("has trust warnings"); + expect(result.reply?.text).toContain("scan=suspicious"); + expect(result.reply?.text).toContain("payload_string"); + expect(result.reply?.text).toContain("--acknowledge-clawhub-risk"); + expect(result.reply?.text).toContain("local openclaw plugins install command"); + expect(result.reply?.text).toContain("trusted shell"); + expect(mockFirstObjectArg(installPluginFromClawHubMock).spec).toBe( + "clawhub:@openclaw/risky-demo@1.2.3", + ); + expect(persistPluginInstallMock).not.toHaveBeenCalled(); + }); + }); + + it("includes ClawHub trust details for blocked chat install failures", async () => { + const warning = + 'ClawHub trust warning for "@openclaw/blocked-demo@1.2.3": scan=suspicious; moderation=blocked; blockedFromDownload=true; pending=false; stale=false; reasons=payload_string. Risk signals: blocked from download, scan status suspicious, moderation state blocked, payload_string.'; + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_download_blocked", + error: 'ClawHub release "@openclaw/blocked-demo@1.2.3" is blocked from download by ClawHub.', + warning, + }); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams( + "/plugins install clawhub:@openclaw/blocked-demo@1.2.3", + workspaceDir, + ); + const result = await handlePluginsCommand(params, true); + if (result === null) { + throw new Error("expected plugin install result"); + } + + expect(result.reply?.text).toContain("blocked from download"); + expect(result.reply?.text).toContain("scan=suspicious"); + expect(result.reply?.text).toContain("moderation=blocked"); + expect(result.reply?.text).toContain("payload_string"); + expect(persistPluginInstallMock).not.toHaveBeenCalled(); + }); + }); + it("refuses plugin installs in Nix mode before package installer side effects", async () => { const previousNixMode = process.env.OPENCLAW_NIX_MODE; process.env.OPENCLAW_NIX_MODE = "1"; diff --git a/src/auto-reply/reply/commands-plugins.test.ts b/src/auto-reply/reply/commands-plugins.test.ts index 57579f104a6a..b93c20c2dac0 100644 --- a/src/auto-reply/reply/commands-plugins.test.ts +++ b/src/auto-reply/reply/commands-plugins.test.ts @@ -79,6 +79,9 @@ vi.mock("../../infra/clawhub.js", () => ({ })); vi.mock("../../plugins/clawhub.js", () => ({ + CLAWHUB_INSTALL_ERROR_CODE: { + CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED: "clawhub_risk_acknowledgement_required", + }, installPluginFromClawHub: vi.fn(), })); diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 3db55b533f78..c569d1cb6fd1 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -1,6 +1,7 @@ // Implements plugin command listing, install, and configuration helpers. import fs from "node:fs"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js"; import { buildNpmInstallRecordFields } from "../../cli/npm-resolution.js"; import { resolveOfficialExternalNpmPackageTrust } from "../../cli/plugin-install-plan.js"; import { @@ -21,7 +22,8 @@ import type { PluginInstallRecord } from "../../config/types.plugins.js"; import { resolveArchiveKind } from "../../infra/archive.js"; import { parseClawHubPluginSpec } from "../../infra/clawhub.js"; import { formatErrorMessage } from "../../infra/errors.js"; -import { installPluginFromClawHub } from "../../plugins/clawhub.js"; +import { buildClawHubPluginInstallRecordFields } from "../../plugins/clawhub-install-records.js"; +import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../../plugins/clawhub.js"; import { installPluginFromGitSpec, parseGitPluginSpec } from "../../plugins/git-install.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../../plugins/install.js"; import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js"; @@ -217,7 +219,9 @@ async function installPluginFromPluginsCommand(params: { raw: string; config: OpenClawConfig; snapshot: ConfigSnapshotForInstallPersist; -}): Promise<{ ok: true; pluginId: string } | { ok: false; error: string }> { +}): Promise< + { ok: true; pluginId: string; warnings?: readonly string[] } | { ok: false; error: string } +> { const fileSpec = resolveFileNpmSpecToLocalPath(params.raw); if (fileSpec && !fileSpec.ok) { return { ok: false, error: fileSpec.error }; @@ -285,31 +289,42 @@ async function installPluginFromPluginsCommand(params: { const clawhubSpec = parseClawHubPluginSpec(params.raw); if (clawhubSpec) { + const warnings: string[] = []; + const logger = createPluginInstallLogger(); const result = await installPluginFromClawHub({ spec: params.raw, config: params.config, - logger: createPluginInstallLogger(), + logger: { + info: logger.info, + warn: (message) => { + warnings.push(stripAnsi(message)); + logger.warn(message); + }, + terminalLinks: false, + }, }); if (!result.ok) { - return { ok: false, error: result.error }; + const warning = "warning" in result ? result.warning : warnings.join("\n"); + const warningPrefix = warning ? `${warning} ` : ""; + if (result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED) { + return { + ok: false, + error: `${warningPrefix}${result.error} The /plugins chat command cannot acknowledge ClawHub risk; run the local openclaw plugins install command with --acknowledge-clawhub-risk from a trusted shell after reviewing the warning.`, + }; + } + return { ok: false, error: `${warningPrefix}${result.error}` }; } await persistPluginInstall({ snapshot: params.snapshot, pluginId: result.pluginId, install: { - source: "clawhub", + ...buildClawHubPluginInstallRecordFields(result.clawhub), spec: params.raw, installPath: result.targetDir, version: result.version, - integrity: result.clawhub.integrity, - resolvedAt: result.clawhub.resolvedAt, - clawhubUrl: result.clawhub.clawhubUrl, - clawhubPackage: result.clawhub.clawhubPackage, - clawhubFamily: result.clawhub.clawhubFamily, - clawhubChannel: result.clawhub.clawhubChannel, }, }); - return { ok: true, pluginId: result.pluginId }; + return { ok: true, pluginId: result.pluginId, warnings }; } const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({ @@ -486,7 +501,10 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm return { shouldContinue: false, reply: { - text: `🔌 Installed plugin "${installed.pluginId}". Gateway restart will load the new plugin source.`, + text: [ + `🔌 Installed plugin "${installed.pluginId}". Gateway restart will load the new plugin source.`, + ...(installed.warnings ?? []).map((warning) => `⚠️ ${warning}`), + ].join("\n"), }, }; } diff --git a/src/cli/clawhub-risk-acknowledgement.test.ts b/src/cli/clawhub-risk-acknowledgement.test.ts new file mode 100644 index 000000000000..218b41c2dab5 --- /dev/null +++ b/src/cli/clawhub-risk-acknowledgement.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js"; + +const promptYesNoMock = vi.hoisted(() => vi.fn()); +const promptTextMock = vi.hoisted(() => vi.fn()); + +vi.mock("./prompt.js", () => ({ + promptYesNo: promptYesNoMock, + promptText: promptTextMock, +})); + +const ORIGINAL_STDIN_TTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); +const ORIGINAL_STDOUT_TTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + +function setTty(value: boolean): void { + Object.defineProperty(process.stdin, "isTTY", { + value, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value, + configurable: true, + }); +} + +function restoreTty(): void { + if (ORIGINAL_STDIN_TTY) { + Object.defineProperty(process.stdin, "isTTY", ORIGINAL_STDIN_TTY); + } else { + Reflect.deleteProperty(process.stdin, "isTTY"); + } + if (ORIGINAL_STDOUT_TTY) { + Object.defineProperty(process.stdout, "isTTY", ORIGINAL_STDOUT_TTY); + } else { + Reflect.deleteProperty(process.stdout, "isTTY"); + } +} + +describe("resolveClawHubRiskAcknowledgementCliOptions", () => { + afterEach(() => { + promptYesNoMock.mockReset(); + promptTextMock.mockReset(); + restoreTty(); + }); + + it("does not create a prompt handler when ClawHub risk is already acknowledged", () => { + setTty(true); + + const options = resolveClawHubRiskAcknowledgementCliOptions({ + acknowledgeClawHubRisk: true, + action: "installing", + }); + + expect(options.acknowledgeClawHubRisk).toBe(true); + expect(options.onClawHubRisk).toBeUndefined(); + }); + + it("does not create a prompt handler outside an interactive terminal", () => { + setTty(false); + + const options = resolveClawHubRiskAcknowledgementCliOptions({ + action: "updating", + }); + + expect(options.acknowledgeClawHubRisk).toBeUndefined(); + expect(options.onClawHubRisk).toBeUndefined(); + }); + + it("does not create a prompt handler when prompting is disabled", () => { + setTty(true); + + const options = resolveClawHubRiskAcknowledgementCliOptions({ + action: "updating", + allowPrompt: false, + }); + + expect(options.acknowledgeClawHubRisk).toBeUndefined(); + expect(options.onClawHubRisk).toBeUndefined(); + }); + + it("sanitizes ClawHub package labels before prompting", async () => { + promptTextMock.mockResolvedValueOnce("demo\\npkg"); + setTty(true); + + const options = resolveClawHubRiskAcknowledgementCliOptions({ + action: "installing", + }); + + if (!options.onClawHubRisk) { + throw new Error("expected ClawHub risk prompt handler"); + } + await options.onClawHubRisk({ + packageName: "demo\npkg", + version: "1.2.3\u001b[2K", + trust: { + scanStatus: "suspicious", + moderationState: null, + blockedFromDownload: false, + reasons: ["payload_strings"], + pending: false, + stale: false, + }, + acknowledgementKind: "type-package", + warning: "warning", + }); + + const prompt = promptTextMock.mock.calls[0]?.[0]; + expect(prompt).toContain("type: 'demo\\npkg' to install anyway"); + expect(prompt).not.toContain("demo\npkg"); + expect(prompt).not.toContain("\u001b"); + }); + + it("requires typing the package name for review-required releases", async () => { + promptTextMock.mockResolvedValueOnce("demo"); + setTty(true); + + const options = resolveClawHubRiskAcknowledgementCliOptions({ + action: "installing", + }); + + if (!options.onClawHubRisk) { + throw new Error("expected ClawHub risk prompt handler"); + } + await expect( + options.onClawHubRisk({ + packageName: "demo", + version: "1.2.3", + trust: { + scanStatus: "suspicious", + moderationState: null, + blockedFromDownload: false, + reasons: ["payload_strings"], + pending: false, + stale: false, + }, + acknowledgementKind: "type-package", + warning: "warning", + }), + ).resolves.toBe(true); + + expect(promptTextMock).toHaveBeenCalledWith( + expect.stringContaining("type: 'demo' to install anyway"), + ); + expect(promptYesNoMock).not.toHaveBeenCalled(); + }); + + it("uses yes/no confirmation for review-recommended releases", async () => { + promptYesNoMock.mockResolvedValueOnce(true); + setTty(true); + + const options = resolveClawHubRiskAcknowledgementCliOptions({ + action: "installing", + }); + + if (!options.onClawHubRisk) { + throw new Error("expected ClawHub risk prompt handler"); + } + await expect( + options.onClawHubRisk({ + packageName: "demo", + version: "1.2.3", + trust: { + scanStatus: "pending", + moderationState: null, + blockedFromDownload: false, + reasons: ["scan:pending"], + pending: true, + stale: false, + }, + acknowledgementKind: "confirm", + warning: "warning", + }), + ).resolves.toBe(true); + + expect(promptYesNoMock).toHaveBeenCalledWith( + 'Install ClawHub package "demo@1.2.3" after reviewing the warning above?', + ); + expect(promptTextMock).not.toHaveBeenCalled(); + }); + + it("uses update wording for update confirmations", async () => { + promptYesNoMock.mockResolvedValueOnce(true); + setTty(true); + + const options = resolveClawHubRiskAcknowledgementCliOptions({ + action: "updating", + }); + + if (!options.onClawHubRisk) { + throw new Error("expected ClawHub risk prompt handler"); + } + await options.onClawHubRisk({ + packageName: "demo", + version: "1.2.3", + trust: { + scanStatus: "pending", + moderationState: null, + blockedFromDownload: false, + reasons: ["scan:pending"], + pending: true, + stale: false, + }, + acknowledgementKind: "confirm", + warning: "warning", + }); + + expect(promptYesNoMock).toHaveBeenCalledWith( + 'Update ClawHub package "demo@1.2.3" after reviewing the warning above?', + ); + }); +}); diff --git a/src/cli/clawhub-risk-acknowledgement.ts b/src/cli/clawhub-risk-acknowledgement.ts new file mode 100644 index 000000000000..403e81d1cfb9 --- /dev/null +++ b/src/cli/clawhub-risk-acknowledgement.ts @@ -0,0 +1,39 @@ +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import type { ClawHubRiskAcknowledgementRequest } from "../infra/clawhub-install-trust.js"; +import { promptText, promptYesNo } from "./prompt.js"; + +export type ClawHubRiskAcknowledgementCliOptions = { + acknowledgeClawHubRisk?: boolean; +}; + +function canPromptForClawHubRisk(): boolean { + return process.stdin.isTTY && process.stdout.isTTY; +} + +export function resolveClawHubRiskAcknowledgementCliOptions(params: { + acknowledgeClawHubRisk?: boolean; + action: "installing" | "updating"; + allowPrompt?: boolean; +}): ClawHubRiskAcknowledgementCliOptions & { + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => Promise; +} { + return { + acknowledgeClawHubRisk: params.acknowledgeClawHubRisk, + onClawHubRisk: + params.acknowledgeClawHubRisk || params.allowPrompt === false || !canPromptForClawHubRisk() + ? undefined + : async (request) => { + const packageName = sanitizeTerminalText(request.packageName); + const releaseLabel = `${packageName}@${sanitizeTerminalText(request.version)}`; + if (request.acknowledgementKind === "type-package") { + const answer = await promptText( + `type: '${packageName}' to ${params.action === "installing" ? "install" : "update"} anyway\n> `, + ); + return answer.trim() === packageName; + } + return await promptYesNo( + `${params.action === "installing" ? "Install" : "Update"} ClawHub package "${releaseLabel}" after reviewing the warning above?`, + ); + }, + }; +} diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index a2d512932917..2167374c097e 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -81,6 +81,7 @@ const uninstallPlugin: AsyncUnknownMock = vi.fn(); export const updateNpmInstalledPlugins: Mock = vi.fn(); export const updateNpmInstalledHookPacks: Mock = vi.fn(); export const promptYesNo: AsyncUnknownMock = vi.fn(); +export const promptText: AsyncUnknownMock = vi.fn(); export class PromptInputClosedError extends Error { constructor() { super("Prompt input closed before an answer was received."); @@ -528,6 +529,11 @@ vi.mock("../hooks/update.js", () => ({ vi.mock("./prompt.js", () => ({ PromptInputClosedError, + promptText: ((...args: Parameters<(typeof import("./prompt.js"))["promptText"]>) => + invokeMock< + Parameters<(typeof import("./prompt.js"))["promptText"]>, + ReturnType<(typeof import("./prompt.js"))["promptText"]> + >(promptText, ...args)) as (typeof import("./prompt.js"))["promptText"], promptYesNo: ((...args: Parameters<(typeof import("./prompt.js"))["promptYesNo"]>) => invokeMock< Parameters<(typeof import("./prompt.js"))["promptYesNo"]>, @@ -725,6 +731,7 @@ export function resetPluginsCliTestState() { uninstallPlugin.mockReset(); updateNpmInstalledPlugins.mockReset(); updateNpmInstalledHookPacks.mockReset(); + promptText.mockReset(); promptYesNo.mockReset(); installPluginFromGitSpec.mockReset(); parseGitPluginSpec.mockReset(); @@ -866,6 +873,7 @@ export function resetPluginsCliTestState() { config: {} as OpenClawConfig, }); promptYesNo.mockResolvedValue(true); + promptText.mockResolvedValue("demo"); installPluginFromPath.mockResolvedValue({ ok: false, error: "path install disabled in test" }); installPluginFromGitSpec.mockResolvedValue({ ok: false, diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 5845b6b98ee4..5720b90087de 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -93,6 +93,16 @@ function createClawHubInstallResult(params: { packageName: string; version: string; channel: string; + trust?: { + disposition: "clean" | "review-recommended" | "review-required"; + scanStatus?: string; + moderationState?: string; + reasons?: string[]; + pending?: boolean; + stale?: boolean; + checkedAt?: string; + acknowledgedAt?: string; + }; }): Awaited> { return { ok: true, @@ -113,6 +123,22 @@ function createClawHubInstallResult(params: { clawpackSpecVersion: 1, clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", clawpackSize: 4096, + ...(params.trust + ? { + clawhubTrustDisposition: params.trust.disposition, + ...(params.trust.scanStatus ? { clawhubTrustScanStatus: params.trust.scanStatus } : {}), + ...(params.trust.moderationState + ? { clawhubTrustModerationState: params.trust.moderationState } + : {}), + ...(params.trust.reasons ? { clawhubTrustReasons: params.trust.reasons } : {}), + ...(params.trust.pending ? { clawhubTrustPending: true } : {}), + ...(params.trust.stale ? { clawhubTrustStale: true } : {}), + ...(params.trust.checkedAt ? { clawhubTrustCheckedAt: params.trust.checkedAt } : {}), + ...(params.trust.acknowledgedAt + ? { clawhubTrustAcknowledgedAt: params.trust.acknowledgedAt } + : {}), + } + : {}), }, }; } @@ -1269,6 +1295,81 @@ describe("plugins cli install", () => { expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); }); + it("passes ClawHub risk acknowledgement to explicit ClawHub installs", async () => { + loadConfig.mockReturnValue(createEmptyPluginConfig()); + parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); + installPluginFromClawHub.mockResolvedValue( + createClawHubInstallResult({ + pluginId: "demo", + packageName: "demo", + version: "1.2.3", + channel: "official", + trust: { + disposition: "review-required", + scanStatus: "suspicious", + reasons: ["payload_strings"], + checkedAt: "2026-05-14T18:00:00.000Z", + acknowledgedAt: "2026-05-14T18:00:03.000Z", + }, + }), + ); + enablePluginInConfig.mockReturnValue({ config: createEnabledPluginConfig("demo") }); + applyExclusiveSlotSelection.mockReturnValue({ + config: createEnabledPluginConfig("demo"), + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "clawhub:demo", "--acknowledge-clawhub-risk"]); + + expect(installPluginFromClawHub).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo", + acknowledgeClawHubRisk: true, + }), + ); + const record = persistedInstallRecord("demo"); + expect(record.clawhubTrustDisposition).toBe("review-required"); + expect(record.clawhubTrustScanStatus).toBe("suspicious"); + expect(record.clawhubTrustReasons).toEqual(["payload_strings"]); + expect(record.clawhubTrustCheckedAt).toBe("2026-05-14T18:00:00.000Z"); + expect(record.clawhubTrustAcknowledgedAt).toBe("2026-05-14T18:00:03.000Z"); + }); + + it("prints acknowledgement guidance for unacknowledged ClawHub plugin installs", async () => { + loadConfig.mockReturnValue(createEmptyPluginConfig()); + parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); + installPluginFromClawHub.mockResolvedValue({ + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: + "Install cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.", + warning: "WARNING - ClawHub found security risks in this release", + }); + + await expect(runPluginsCommand(["plugins", "install", "clawhub:demo"])).rejects.toThrow( + "__exit__:1", + ); + + expect(runtimeErrors.at(-1)).toContain("--acknowledge-clawhub-risk"); + }); + + it("prints blocked ClawHub download failures when no trust warning was emitted", async () => { + loadConfig.mockReturnValue(createEmptyPluginConfig()); + parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); + installPluginFromClawHub.mockResolvedValue({ + ok: false, + code: "clawhub_download_blocked", + error: + 'ClawHub blocked artifact download for "demo@1.2.3"; install was not started. ClawHub /api/v1/packages/demo/versions/1.2.3/artifact/download failed (403): blocked.', + }); + + await expect(runPluginsCommand(["plugins", "install", "clawhub:demo"])).rejects.toThrow( + "__exit__:1", + ); + + expect(runtimeErrors.at(-1)).toContain("ClawHub blocked artifact download"); + }); + it("passes the active profile extensions dir to ClawHub installs", async () => { const extensionsDir = useProfileExtensionsDir(); const cfg = createEmptyPluginConfig(); diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index 69406ff91eb0..0266a072e34b 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -20,6 +20,7 @@ import { formatMissingPluginMessage } from "./error-format.js"; import type { PluginMarketplaceListOptions, PluginRegistryOptions } from "./plugins-cli.js"; type PluginInstallActionOptions = { + acknowledgeClawHubRisk?: boolean; dangerouslyForceUnsafeInstall?: boolean; force?: boolean; link?: boolean; diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index cc0457d37488..f7d18763bfd9 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -9,10 +9,19 @@ import { applyParentDefaultHelpAction } from "./program/parent-default-help.js"; export type PluginUpdateOptions = { all?: boolean; + acknowledgeClawhubRisk?: boolean; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean; }; +type CommanderClawHubRiskOptions = Record & { + acknowledgeClawhubRisk?: boolean; +}; + +function normalizeCommanderClawHubRiskOption(opts: CommanderClawHubRiskOptions): boolean { + return opts.acknowledgeClawhubRisk === true || opts.acknowledgeClawHubRisk === true; +} + export type PluginMarketplaceListOptions = { json?: boolean; }; @@ -156,6 +165,11 @@ export function registerPluginsCli(program: Command) { "Deprecated no-op; security.installPolicy may still block", false, ) + .option( + "--acknowledge-clawhub-risk", + "Acknowledge ClawHub release trust warnings without prompting", + false, + ) .option( "--marketplace ", "Install a Claude marketplace plugin from a local repo/path or git/GitHub source", @@ -163,7 +177,7 @@ export function registerPluginsCli(program: Command) { .action( async ( raw: string, - opts: { + opts: CommanderClawHubRiskOptions & { dangerouslyForceUnsafeInstall?: boolean; force?: boolean; link?: boolean; @@ -172,7 +186,10 @@ export function registerPluginsCli(program: Command) { }, ) => { const { runPluginsInstallAction } = await loadPluginsRuntime(); - await runPluginsInstallAction(raw, opts); + await runPluginsInstallAction(raw, { + ...opts, + acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts), + }); }, ); @@ -187,9 +204,20 @@ export function registerPluginsCli(program: Command) { "Deprecated no-op; security.installPolicy may still block", false, ) + .option( + "--acknowledge-clawhub-risk", + "Acknowledge ClawHub release trust warnings without prompting", + false, + ) .action(async (id: string | undefined, opts: PluginUpdateOptions) => { const { runPluginUpdateCommand } = await import("./plugins-update-command.js"); - await runPluginUpdateCommand({ id, opts }); + await runPluginUpdateCommand({ + id, + opts: { + ...opts, + acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts), + }, + }); }); plugins diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index 310785c06929..c36c843cdb50 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -6,6 +6,7 @@ import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { hashConfigIncludeRaw } from "../config/includes.js"; +import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub-error-codes.js"; import { loadConfig, readConfigFileSnapshotForWrite, @@ -24,6 +25,32 @@ import { } from "./plugins-cli-test-helpers.js"; const ORIGINAL_OPENCLAW_NIX_MODE = process.env.OPENCLAW_NIX_MODE; +const ORIGINAL_STDIN_TTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); +const ORIGINAL_STDOUT_TTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + +function setTty(value: boolean): void { + Object.defineProperty(process.stdin, "isTTY", { + value, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value, + configurable: true, + }); +} + +function restoreTty(): void { + if (ORIGINAL_STDIN_TTY) { + Object.defineProperty(process.stdin, "isTTY", ORIGINAL_STDIN_TTY); + } else { + Reflect.deleteProperty(process.stdin, "isTTY"); + } + if (ORIGINAL_STDOUT_TTY) { + Object.defineProperty(process.stdout, "isTTY", ORIGINAL_STDOUT_TTY); + } else { + Reflect.deleteProperty(process.stdout, "isTTY"); + } +} function createTrackedPluginConfig(params: { pluginId: string; @@ -124,6 +151,7 @@ describe("plugins cli update", () => { }); afterEach(() => { + restoreTty(); if (ORIGINAL_OPENCLAW_NIX_MODE === undefined) { delete process.env.OPENCLAW_NIX_MODE; } else { @@ -1006,6 +1034,57 @@ describe("plugins cli update", () => { expect(updateParams.updateChannel).toBeUndefined(); }); + it("passes ClawHub risk acknowledgement to plugin updates", async () => { + const config = createTrackedPluginConfig({ + pluginId: "openclaw-codex-app-server", + spec: "openclaw-codex-app-server@beta", + }); + loadConfig.mockReturnValue(config); + setInstalledPluginIndexInstallRecords(config.plugins?.installs ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runPluginsCommand([ + "plugins", + "update", + "openclaw-codex-app-server", + "--acknowledge-clawhub-risk", + ]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + pluginIds: ["openclaw-codex-app-server"], + acknowledgeClawHubRisk: true, + }), + ); + }); + + it("does not pass an interactive ClawHub risk prompt to dry-run plugin updates", async () => { + setTty(true); + const config = createTrackedPluginConfig({ + pluginId: "openclaw-codex-app-server", + spec: "clawhub:openclaw-codex-app-server", + }); + loadConfig.mockReturnValue(config); + setInstalledPluginIndexInstallRecords(config.plugins?.installs ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runPluginsCommand(["plugins", "update", "openclaw-codex-app-server", "--dry-run"]); + + const updateParams = expectSingleCallParams(updateNpmInstalledPlugins); + expect(updateParams.dryRun).toBe(true); + expect(updateParams.acknowledgeClawHubRisk).not.toBe(true); + expect(updateParams.onClawHubRisk).toBeUndefined(); + }); + it("writes updated config when updater reports changes", async () => { const cfg = { plugins: { @@ -1142,6 +1221,123 @@ describe("plugins cli update", () => { expect(runtimeLogs).toContain("Failed to update beta: registry timeout"); }); + it("exits non-zero when a ClawHub update is skipped for missing risk acknowledgement", async () => { + const cfg = { + plugins: { + installs: { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo@1.0.0", + clawhubPackage: "@openclaw/plugin-demo", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(cfg); + setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED, + message: + "Skipped demo ClawHub update: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. Existing installed plugin left unchanged.", + }, + ], + changed: false, + config: cfg, + }); + updateNpmInstalledHookPacks.mockResolvedValue({ + outcomes: [], + changed: false, + config: cfg, + }); + + await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1"); + + expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); + expect(runtimeLogs.at(-1)).toContain("--acknowledge-clawhub-risk"); + }); + + it("exits non-zero when a ClawHub update is skipped because the target release is blocked", async () => { + const cfg = { + plugins: { + installs: { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo", + clawhubPackage: "@openclaw/plugin-demo", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(cfg); + setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_download_blocked", + message: + "Skipped demo ClawHub update: ClawHub blocked this release; update was not started. Existing installed plugin left unchanged.", + }, + ], + changed: false, + config: cfg, + }); + updateNpmInstalledHookPacks.mockResolvedValue({ + outcomes: [], + changed: false, + config: cfg, + }); + + await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1"); + + expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); + expect(runtimeLogs.at(-1)).toContain("ClawHub blocked this release"); + }); + + it("exits non-zero when a ClawHub update is skipped because security data is unavailable", async () => { + const cfg = { + plugins: { + installs: { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo", + clawhubPackage: "@openclaw/plugin-demo", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(cfg); + setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_security_unavailable", + message: + 'Skipped demo ClawHub update: ClawHub security data for "@openclaw/plugin-demo@1.1.0" is unavailable, so OpenClaw left the existing installed plugin unchanged. Try again later or choose a different version.', + }, + ], + changed: false, + config: cfg, + }); + updateNpmInstalledHookPacks.mockResolvedValue({ + outcomes: [], + changed: false, + config: cfg, + }); + + await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1"); + + expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); + expect(runtimeLogs.at(-1)).toContain("security data"); + }); + it("exits non-zero when a hook pack update reports an error", async () => { const cfg = { hooks: { diff --git a/src/cli/plugins-command-helpers.ts b/src/cli/plugins-command-helpers.ts index 2c86e762804c..b2bac96e7717 100644 --- a/src/cli/plugins-command-helpers.ts +++ b/src/cli/plugins-command-helpers.ts @@ -127,7 +127,7 @@ export function createPluginInstallLogger(runtime: RuntimeEnv = defaultRuntime): } { return { info: (msg) => runtime.log(msg), - warn: (msg) => runtime.log(theme.warn(msg)), + warn: (msg) => runtime.log(msg.includes("╭─") ? msg : theme.warn(msg)), }; } diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index b29c4c693f7e..06f7afab55ee 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -18,7 +18,7 @@ import { parseClawHubPluginSpec } from "../infra/clawhub.js"; import { formatErrorMessage } from "../infra/errors.js"; import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js"; import { buildClawHubPluginInstallRecordFields } from "../plugins/clawhub-install-records.js"; -import { installPluginFromClawHub } from "../plugins/clawhub.js"; +import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../plugins/clawhub.js"; import { installPluginFromGitSpec, parseGitPluginSpec } from "../plugins/git-install.js"; import { resolveDefaultPluginExtensionsDir } from "../plugins/install-paths.js"; import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js"; @@ -43,6 +43,7 @@ import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trac import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { resolveUserPath, shortenHomePath } from "../utils.js"; +import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js"; import { formatCliCommand } from "./command-format.js"; import { looksLikeLocalInstallSpec } from "./install-spec.js"; import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js"; @@ -80,6 +81,14 @@ type ConfigSnapshotForInstallExecution = ConfigSnapshotForInstallPersist & { pluginMutation: ConfigMutationPreflight; }; +function isClawHubBlockedCliFailure(result: { code?: string; warning?: string }): boolean { + return ( + result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED && + typeof result.warning === "string" && + result.warning.trim().length > 0 + ); +} + function resolveInstallMode(force?: boolean): "install" | "update" { return force ? "update" : "install"; } @@ -854,6 +863,7 @@ export async function loadConfigForInstall( export async function runPluginInstallCommand(params: { raw: string; opts: InstallSafetyOverrides & { + acknowledgeClawHubRisk?: boolean; force?: boolean; link?: boolean; pin?: boolean; @@ -994,7 +1004,9 @@ export async function runPluginInstallCommand(params: { logger: createPluginInstallLogger(runtime), }); if (!result.ok) { - runtime.error(result.error); + if (!isClawHubBlockedCliFailure(result)) { + runtime.error(result.error); + } return runtime.exit(1); } @@ -1301,13 +1313,19 @@ export async function runPluginInstallCommand(params: { if (clawhubSpec) { const result = await installPluginFromClawHub({ ...safetyOverrides, + ...resolveClawHubRiskAcknowledgementCliOptions({ + acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk, + action: "installing", + }), mode: installMode, spec: raw, extensionsDir, logger: createPluginInstallLogger(runtime), }); if (!result.ok) { - runtime.error(result.error); + if (!isClawHubBlockedCliFailure(result)) { + runtime.error(result.error); + } return runtime.exit(1); } diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 34f609ab47c5..28e4918022b9 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -24,6 +24,7 @@ import { updateNpmInstalledPlugins, } from "../plugins/update.js"; import { defaultRuntime } from "../runtime.js"; +import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js"; import { containsConfigIncludeDirective, resolveCombinedPluginAndHookConfigMutationPreflight, @@ -101,7 +102,12 @@ function projectUpdaterResultOntoSourceConfig(params: { /** Run plugin/hook-pack updates, persist changed install records, and refresh runtime registry. */ export async function runPluginUpdateCommand(params: { id?: string; - opts: { all?: boolean; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean }; + opts: { + all?: boolean; + acknowledgeClawHubRisk?: boolean; + dryRun?: boolean; + dangerouslyForceUnsafeInstall?: boolean; + }; }) { assertConfigWriteAllowedInCurrentMode(); @@ -143,7 +149,7 @@ export async function runPluginUpdateCommand(params: { ); const logger = { info: (msg: string) => defaultRuntime.log(msg), - warn: (msg: string) => defaultRuntime.log(theme.warn(msg)), + warn: (msg: string) => defaultRuntime.log(msg.includes("╭─") ? msg : theme.warn(msg)), }; if (params.opts.dangerouslyForceUnsafeInstall) { defaultRuntime.log(theme.warn(DEPRECATED_DANGEROUS_FORCE_UNSAFE_UPDATE_WARNING)); @@ -266,6 +272,11 @@ export async function runPluginUpdateCommand(params: { : undefined, syncOfficialPluginInstalls: params.opts.all ? true : undefined, dangerouslyForceUnsafeInstall: params.opts.dangerouslyForceUnsafeInstall, + ...resolveClawHubRiskAcknowledgementCliOptions({ + acknowledgeClawHubRisk: params.opts.acknowledgeClawHubRisk, + action: "updating", + allowPrompt: !params.opts.dryRun, + }), logger, onIntegrityDrift: async (drift) => { const specLabel = drift.resolvedSpec ?? drift.spec; diff --git a/src/cli/plugins-update-outcomes.ts b/src/cli/plugins-update-outcomes.ts index 818b7f0967f9..c10b0d196dfa 100644 --- a/src/cli/plugins-update-outcomes.ts +++ b/src/cli/plugins-update-outcomes.ts @@ -1,5 +1,6 @@ // User-facing logging for plugin and hook-pack update outcomes. import { theme } from "../../packages/terminal-core/src/theme.js"; +import { isClawHubTrustSkippedOutcome } from "../plugins/update.js"; type PluginUpdateCliOutcome = { status: string; @@ -7,6 +8,7 @@ type PluginUpdateCliOutcome = { channelFallback?: { message: string; }; + code?: string; }; /** Log update outcomes with severity styling and report whether any errors occurred. */ @@ -25,6 +27,9 @@ export function logPluginUpdateOutcomes(params: { continue; } if (outcome.status === "skipped") { + if (isClawHubTrustSkippedOutcome(outcome)) { + hasErrors = true; + } params.log(theme.warn(outcome.message)); if (outcome.channelFallback) { params.log(theme.warn(outcome.channelFallback.message)); diff --git a/src/cli/prompt.ts b/src/cli/prompt.ts index 7c662f57f3ad..d2cb564a5994 100644 --- a/src/cli/prompt.ts +++ b/src/cli/prompt.ts @@ -57,3 +57,10 @@ export async function promptYesNo(question: string, defaultYes = false): Promise } return answer.startsWith("y"); } + +export async function promptText(question: string): Promise { + const rl = readline.createInterface({ input, output }); + return await questionUntilClose(rl, question).finally(() => { + rl.close(); + }); +} diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index 4f44d6ff2688..f40f748ae96a 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -659,6 +659,56 @@ describe("skills cli commands", () => { ); }); + it("passes --acknowledge-clawhub-risk through for ClawHub skill installs", async () => { + installSkillFromClawHubMock.mockResolvedValue({ + ok: true, + slug: "calendar", + version: "1.2.3", + targetDir: "/tmp/workspace/skills/calendar", + }); + + await runCommand(["skills", "install", "calendar", "--acknowledge-clawhub-risk"]); + + expect(installSkillFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: "/tmp/workspace", + slug: "calendar", + acknowledgeClawHubRisk: true, + }), + ); + }); + + it("prints acknowledgement guidance for unacknowledged ClawHub skill installs", async () => { + installSkillFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: + "Install cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.", + warning: "WARNING - ClawHub found security risks in this release", + }); + + await expect(runCommand(["skills", "install", "calendar"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors).toContain( + "Install cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.", + ); + }); + + it("prints blocked ClawHub skill install failures when no trust warning was emitted", async () => { + installSkillFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_download_blocked", + error: + 'ClawHub blocked artifact download for "calendar@1.2.3"; install was not started. ClawHub /api/v1/skills/calendar/versions/1.2.3/download failed (403): blocked.', + }); + + await expect(runCommand(["skills", "install", "calendar"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors).toContain( + 'ClawHub blocked artifact download for "calendar@1.2.3"; install was not started. ClawHub /api/v1/skills/calendar/versions/1.2.3/download failed (403): blocked.', + ); + }); + it("rejects using --global and --agent together for installs", async () => { await expect( runCommand(["skills", "install", "calendar", "--global", "--agent", "main"]), @@ -749,6 +799,48 @@ describe("skills cli commands", () => { ); }); + it("passes --acknowledge-clawhub-risk through for ClawHub skill updates", async () => { + readTrackedClawHubSkillSlugsMock.mockResolvedValue(["calendar"]); + updateSkillsFromClawHubMock.mockResolvedValue([ + { + ok: true, + slug: "calendar", + previousVersion: "1.2.2", + version: "1.2.3", + changed: true, + targetDir: "/tmp/workspace/skills/calendar", + }, + ]); + + await runCommand(["skills", "update", "--all", "--acknowledge-clawhub-risk"]); + + expect(updateSkillsFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: "/tmp/workspace", + acknowledgeClawHubRisk: true, + }), + ); + }); + + it("prints acknowledgement guidance for unacknowledged ClawHub skill updates", async () => { + readTrackedClawHubSkillSlugsMock.mockResolvedValue(["calendar"]); + updateSkillsFromClawHubMock.mockResolvedValue([ + { + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: + "Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.", + warning: "WARNING - ClawHub found security risks in this release", + }, + ]); + + await expect(runCommand(["skills", "update", "calendar"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors).toContain( + "Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.", + ); + }); + it("updates tracked ClawHub skills in the cwd-inferred agent workspace", async () => { routeWorkspaceByAgent(); resolveAgentIdByWorkspacePathMock.mockReturnValue("writer"); diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 5a151bc454e6..635983f5b876 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -13,6 +13,7 @@ import { resolveDefaultAgentId, } from "../agents/agent-scope.js"; import { getRuntimeConfig } from "../config/config.js"; +import { CLAWHUB_TRUST_ERROR_CODE } from "../infra/clawhub-install-trust.js"; import { fetchClawHubSkillCard, fetchClawHubSkillVerification, @@ -49,6 +50,7 @@ import type { SkillProposalSupportFileInput, } from "../skills/workshop/types.js"; import { CONFIG_DIR } from "../utils.js"; +import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js"; import { resolveOptionFromCommand } from "./cli-utils.js"; import { parseStrictPositiveIntOption } from "./program/helpers.js"; import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js"; @@ -68,6 +70,32 @@ type ResolvedClawHubSkillVerificationTarget = Extract< { ok: true } >; +function resolveSkillClawHubRiskOptions( + acknowledgeClawHubRisk: boolean, + action: "installing" | "updating", +) { + const riskOptions = resolveClawHubRiskAcknowledgementCliOptions({ + acknowledgeClawHubRisk, + action, + }); + return { + ...(riskOptions.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(riskOptions.onClawHubRisk ? { onClawHubRisk: riskOptions.onClawHubRisk } : {}), + }; +} + +function formatSkillWarning(message: string): string { + return message.includes("╭─") ? message : theme.warn(message); +} + +function isClawHubSkillBlockedCliFailure(result: { code?: string; warning?: string }): boolean { + return ( + result.code === CLAWHUB_TRUST_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED && + typeof result.warning === "string" && + result.warning.trim().length > 0 + ); +} + type ResolveSkillsWorkspaceOptions = { agentId?: string; cwd?: string; @@ -334,6 +362,11 @@ export function registerSkillsCli(program: Command) { "Install a pending GitHub-backed skill before ClawHub scan completes", false, ) + .option( + "--acknowledge-clawhub-risk", + "Acknowledge ClawHub release trust warnings without prompting", + false, + ) .option("--global", "Install into the shared managed skills directory", false) .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") .option("--as ", "Install a git/local skill under this slug") @@ -345,6 +378,8 @@ export function registerSkillsCli(program: Command) { version?: string; force?: boolean; forceInstall?: boolean; + acknowledgeClawhubRisk?: boolean; + acknowledgeClawHubRisk?: boolean; global?: boolean; agent?: string; as?: string; @@ -369,7 +404,7 @@ export function registerSkillsCli(program: Command) { force: Boolean(opts.force), logger: { info: (message) => defaultRuntime.log(message), - warn: (message) => defaultRuntime.log(theme.warn(message)), + warn: (message) => defaultRuntime.log(formatSkillWarning(message)), }, }); if (!result.ok) { @@ -395,12 +430,19 @@ export function registerSkillsCli(program: Command) { version: opts.version, force: Boolean(opts.force), ...(opts.forceInstall ? { forceInstall: true } : {}), + ...resolveSkillClawHubRiskOptions( + opts.acknowledgeClawhubRisk === true || opts.acknowledgeClawHubRisk === true, + "installing", + ), logger: { info: (message) => defaultRuntime.log(message), + warn: (message) => defaultRuntime.log(formatSkillWarning(message)), }, }); if (!result.ok) { - defaultRuntime.error(result.error); + if (!isClawHubSkillBlockedCliFailure(result)) { + defaultRuntime.error(result.error); + } defaultRuntime.exit(1); return; } @@ -422,12 +464,24 @@ export function registerSkillsCli(program: Command) { "Install a pending GitHub-backed skill before ClawHub scan completes", false, ) + .option( + "--acknowledge-clawhub-risk", + "Acknowledge ClawHub release trust warnings without prompting", + false, + ) .option("--global", "Update skills in the shared managed skills directory", false) .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") .action( async ( slug: string | undefined, - opts: { all?: boolean; forceInstall?: boolean; global?: boolean; agent?: string }, + opts: { + all?: boolean; + forceInstall?: boolean; + acknowledgeClawhubRisk?: boolean; + acknowledgeClawHubRisk?: boolean; + global?: boolean; + agent?: string; + }, command: Command, ) => { try { @@ -454,8 +508,13 @@ export function registerSkillsCli(program: Command) { workspaceDir: target.workspaceDir, slug, ...(opts.forceInstall ? { forceInstall: true } : {}), + ...resolveSkillClawHubRiskOptions( + opts.acknowledgeClawhubRisk === true || opts.acknowledgeClawHubRisk === true, + "updating", + ), logger: { info: (message) => defaultRuntime.log(message), + warn: (message) => defaultRuntime.log(formatSkillWarning(message)), }, config: target.config, }); @@ -463,7 +522,9 @@ export function registerSkillsCli(program: Command) { for (const result of results) { if (!result.ok) { failed = true; - defaultRuntime.error(result.error); + if (!isClawHubSkillBlockedCliFailure(result)) { + defaultRuntime.error(result.error); + } continue; } if (result.changed) { diff --git a/src/cli/update-cli.option-collisions.test.ts b/src/cli/update-cli.option-collisions.test.ts index 16a14610d7cf..9e5fb3630390 100644 --- a/src/cli/update-cli.option-collisions.test.ts +++ b/src/cli/update-cli.option-collisions.test.ts @@ -47,6 +47,13 @@ function firstCallOptions(mock: { mock: { calls: unknown[][] } }) { return mock.mock.calls[0]?.[0]; } +type UpdateFinalizeCommandOptions = { + acknowledgeClawHubRisk?: boolean; + json?: boolean; + timeout?: string; + restart?: boolean; +}; + describe("update cli option collisions", () => { beforeEach(() => { updateCommand.mockClear(); @@ -72,20 +79,25 @@ describe("update cli option collisions", () => { }, }, { - name: "forwards parent-captured --json/--timeout to hidden `update finalize`", - argv: ["update", "finalize", "--json", "--timeout", "17"], + name: "forwards parent-captured options to hidden `update finalize`", + argv: [ + "update", + "--acknowledge-clawhub-risk", + "finalize", + "--json", + "--timeout", + "17", + "--no-restart", + ], assert: () => { expect(updateFinalizeCommand).toHaveBeenCalledTimes(1); - const opts = firstCallOptions(updateFinalizeCommand); - expect( - (opts as { json?: boolean; timeout?: string; restart?: boolean } | undefined)?.json, - ).toBe(true); - expect( - (opts as { json?: boolean; timeout?: string; restart?: boolean } | undefined)?.timeout, - ).toBe("17"); - expect( - (opts as { json?: boolean; timeout?: string; restart?: boolean } | undefined)?.restart, - ).toBe(false); + const opts = firstCallOptions(updateFinalizeCommand) as + | UpdateFinalizeCommandOptions + | undefined; + expect(opts?.json).toBe(true); + expect(opts?.timeout).toBe("17"); + expect(opts?.restart).toBe(false); + expect(opts?.acknowledgeClawHubRisk).toBe(true); }, }, { diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 77e472fc840a..e4210bc047c0 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -10,6 +10,7 @@ import { TEST_BUNDLED_RUNTIME_SIDECAR_PATHS } from "../../test/helpers/bundled-r import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js"; +import type { ClawHubRiskAcknowledgementRequest } from "../infra/clawhub-install-trust.js"; import { writePackageDistInventory } from "../infra/package-dist-inventory.js"; import { isBetaTag } from "../infra/update-channels.js"; import { @@ -19,6 +20,7 @@ import { writeUpdatePostInstallDoctorResult, } from "../infra/update-doctor-result.js"; import type { UpdateRunResult } from "../infra/update-runner.js"; +import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub-error-codes.js"; import { captureEnv, withEnvAsync } from "../test-utils/env.js"; import { VERSION } from "../version.js"; import { createCliRuntimeCapture } from "./test-runtime-capture.js"; @@ -26,9 +28,14 @@ import { isOwningNpmCommand } from "./update-cli.test-helpers.js"; const confirm = vi.fn(); const select = vi.fn(); +const text = vi.fn(); const spinner = vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })); const isCancel = (value: unknown) => value === "cancel"; +type ClawHubRiskHandler = ( + request: ClawHubRiskAcknowledgementRequest, +) => boolean | Promise; + const readPackageName = vi.fn(); const readPackageVersion = vi.fn(); const resolveGlobalManager = vi.fn(); @@ -54,6 +61,7 @@ const loadInstalledPluginIndexInstallRecords = vi.fn( const checkShellCompletionStatus = vi.fn(); const ensureCompletionCacheExists = vi.fn(); const installCompletion = vi.fn(); +const createPreUpdateConfigSnapshotMock = vi.fn(); const legacyConfigRepairMocks = vi.hoisted(() => ({ repairLegacyConfigForUpdateChannel: vi.fn(), })); @@ -79,6 +87,7 @@ const serviceEnvSnapshot = captureEnv([ vi.mock("@clack/prompts", () => ({ confirm, select, + text, isCancel, spinner, })); @@ -228,10 +237,14 @@ vi.mock("../plugins/official-external-install-records.js", () => ({ resolveTrustedSourceLinkedOfficialNpmSpec: vi.fn(() => undefined), })); -vi.mock("../plugins/update.js", () => ({ - syncPluginsForUpdateChannel: (...args: unknown[]) => syncPluginsForUpdateChannel(...args), - updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args), -})); +vi.mock("../plugins/update.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + syncPluginsForUpdateChannel: (...args: unknown[]) => syncPluginsForUpdateChannel(...args), + updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args), + }; +}); vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) => { const actual = @@ -269,6 +282,10 @@ vi.mock("./update-cli/post-core-plugin-convergence.js", () => ({ })), })); +vi.mock("../config/backup-rotation.js", () => ({ + createPreUpdateConfigSnapshot: (...args: unknown[]) => createPreUpdateConfigSnapshotMock(...args), +})); + vi.mock("../daemon/service.js", () => ({ readGatewayServiceState: async () => { const command = await serviceReadCommand(); @@ -366,8 +383,18 @@ const { runCommandWithTimeout } = await import("../process/exec.js"); const { runDaemonRestart, runDaemonInstall } = await import("./daemon-cli.js"); const { doctorCommand } = await import("../commands/doctor.js"); const { defaultRuntime } = await import("../runtime.js"); -const { updateCommand, updateFinalizeCommand, updateStatusCommand, updateWizardCommand } = - await import("./update-cli.js"); +const postCorePluginConvergence = await import("./update-cli/post-core-plugin-convergence.js"); +const runPostCorePluginConvergenceSpy = vi.spyOn( + postCorePluginConvergence, + "runPostCorePluginConvergence", +); +const { + registerUpdateCli, + updateCommand, + updateFinalizeCommand, + updateStatusCommand, + updateWizardCommand, +} = await import("./update-cli.js"); const updateCliShared = await import("./update-cli/shared.js"); const { ensureGitCheckout, resolveGitInstallDir } = updateCliShared; const { spawnSync } = await import("node:child_process"); @@ -508,6 +535,11 @@ describe("update-cli", () => { ([argv]) => argv[2] === "doctor" && argv[3] === "--non-interactive" && argv[4] === "--fix", ); + const doctorCommandCallIndex = () => + commandCalls().findIndex( + ([argv]) => argv[2] === "doctor" && argv[3] === "--non-interactive" && argv[4] === "--fix", + ); + const gatewayCommandCall = (entryPath: string, action: "install" | "restart") => commandCalls().find( ([argv]) => argv[1] === entryPath && argv[2] === "gateway" && argv[3] === action, @@ -529,20 +561,37 @@ describe("update-cli", () => { const syncPluginCall = (index = 0) => { const calls = syncPluginsForUpdateChannel.mock.calls as unknown as Array< - [{ channel?: string; config?: OpenClawConfig }] + [Record & { channel?: string; config?: OpenClawConfig }] >; return calls[index]?.[0]; }; const npmPluginUpdateCall = (index = 0) => { const calls = updateNpmInstalledPlugins.mock.calls as unknown as Array< - [{ config?: OpenClawConfig; timeoutMs?: number }] + [Record & { config?: OpenClawConfig; timeoutMs?: number }] >; return calls[index]?.[0]; }; const lastNpmPluginUpdateCall = () => npmPluginUpdateCall(updateNpmInstalledPlugins.mock.calls.length - 1); + const hasClawHubRiskHandler = ( + call: Record | undefined, + ): call is Record & { onClawHubRisk: ClawHubRiskHandler } => + typeof call?.onClawHubRisk === "function"; + + const getConfirmMessage = (): string => { + const options = confirm.mock.calls[0]?.[0]; + if (!options || typeof options !== "object" || !("message" in options)) { + throw new Error("expected confirm message"); + } + const message = options.message; + if (typeof message !== "string") { + throw new Error("expected confirm message to be a string"); + } + return message; + }; + const replaceConfigCall = (index = 0) => vi.mocked(replaceConfigFile).mock.calls[index]?.[0]; const lastReplaceConfigCall = () => replaceConfigCall(vi.mocked(replaceConfigFile).mock.calls.length - 1); @@ -1248,6 +1297,33 @@ describe("update-cli", () => { expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); }); + it("carries ClawHub risk acknowledgement into post-core resume", async () => { + const { entrypoints } = setupUpdatedRootRefresh({ + gatewayUpdateImpl: async (root) => + makeOkUpdateResult({ + mode: "git", + root, + before: { sha: "old-sha", version: "2026.4.26" }, + after: { sha: "new-sha", version: "2026.4.27" }, + }), + }); + + await updateCommand({ + channel: "dev", + yes: true, + restart: false, + acknowledgeClawHubRisk: true, + }); + + expect(spawnCall()?.[1]).toEqual([ + entrypoints[0], + "update", + "--no-restart", + "--yes", + "--acknowledge-clawhub-risk", + ]); + }); + it("keeps downgrade post-update work in the current process", async () => { const downgradedRoot = createCaseDir("openclaw-downgraded-root"); setupUpdatedRootRefresh({ @@ -1766,6 +1842,181 @@ describe("update-cli", () => { ); }); + it("includes non-blocking ClawHub trust warnings in json post-core plugin output", async () => { + const trustWarning = + "╭─ REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check ─╮\n" + + "│ • Security scan: pending │\n" + + "│ • Status: security scan is pending │\n" + + "╰────────────────────────────────────────────────────────────────────────╯"; + updateNpmInstalledPlugins.mockImplementationOnce( + async (params: { + config: OpenClawConfig; + logger?: { terminalLinks?: boolean; warn?: (message: string) => void }; + }) => { + expect(params.logger?.terminalLinks).toBe(false); + params.logger?.warn?.(trustWarning); + return { + changed: false, + config: params.config, + outcomes: [ + { + pluginId: "demo", + status: "unchanged", + message: "demo is up to date.", + }, + ], + }; + }, + ); + vi.mocked(defaultRuntime.writeJson).mockClear(); + + await updateCommand({ json: true, restart: false }); + + const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined; + expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning"); + expect(pluginWarning(jsonOutput)?.reason).toBe(trustWarning); + expect(pluginWarning(jsonOutput)?.guidance).toEqual([]); + expect(pluginOutcome(jsonOutput)?.status).toBe("unchanged"); + }); + + it("includes colored ClawHub trust warnings in json post-core plugin output", async () => { + const trustWarning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + const coloredTrustWarning = `\u001b[33m${trustWarning}\u001b[39m`; + updateNpmInstalledPlugins.mockImplementationOnce( + async (params: { + config: OpenClawConfig; + logger?: { terminalLinks?: boolean; warn?: (message: string) => void }; + }) => { + expect(params.logger?.terminalLinks).toBe(false); + params.logger?.warn?.(coloredTrustWarning); + return { + changed: true, + config: params.config, + outcomes: [ + { + pluginId: "demo", + status: "updated", + currentVersion: "1.2.3", + nextVersion: "1.2.4", + message: "Updated demo: 1.2.3 -> 1.2.4.", + }, + ], + }; + }, + ); + vi.mocked(defaultRuntime.writeJson).mockClear(); + + await updateCommand({ json: true, restart: false, acknowledgeClawHubRisk: true }); + + const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined; + expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning"); + expect(pluginWarning(jsonOutput)?.reason).toBe(trustWarning); + expect(pluginWarning(jsonOutput)?.reason).not.toContain("\u001b"); + expect(pluginOutcome(jsonOutput)?.status).toBe("updated"); + }); + + it("includes failed ClawHub sync trust warnings in json post-core plugin output", async () => { + const trustWarning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "│ • Finding: suspicious payload strings │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + syncPluginsForUpdateChannel.mockResolvedValueOnce({ + changed: false, + config: baseConfig, + summary: { + switchedToBundled: [], + switchedToNpm: [], + warnings: [trustWarning], + errors: [ + "Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo@1.2.4).", + ], + }, + }); + vi.mocked(defaultRuntime.writeJson).mockClear(); + + await updateCommand({ json: true, restart: false }); + + const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined; + expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning"); + expect(jsonOutput?.postUpdate?.plugins?.sync.warnings).toEqual([trustWarning]); + expect(jsonOutput?.postUpdate?.plugins?.sync.errors).toEqual([ + "Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo@1.2.4).", + ]); + }); + + it("does not print duplicate failed ClawHub sync trust warnings in human post-core output", async () => { + const trustWarning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "│ • Finding: suspicious payload strings │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + syncPluginsForUpdateChannel.mockImplementationOnce( + async (params: { config: OpenClawConfig; logger?: { warn?: (message: string) => void } }) => { + params.logger?.warn?.(trustWarning); + return { + changed: false, + config: params.config, + summary: { + switchedToBundled: [], + switchedToNpm: [], + warnings: [trustWarning], + errors: [ + "Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo@1.2.4).", + ], + }, + }; + }, + ); + + await updateCommand({ yes: true, restart: false }); + + const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); + expect(logs.filter((line) => line === trustWarning)).toHaveLength(1); + }); + + it("does not print duplicate ClawHub update trust warnings in human post-core output", async () => { + const trustWarning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "│ • Finding: suspicious payload strings │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + updateNpmInstalledPlugins.mockImplementationOnce( + async (params: { config: OpenClawConfig; logger?: { warn?: (message: string) => void } }) => { + params.logger?.warn?.(trustWarning); + return { + changed: false, + config: params.config, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED, + warning: trustWarning, + message: + "Skipped demo ClawHub update: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. Existing installed plugin left unchanged.", + }, + ], + }; + }, + ); + + await updateCommand({ yes: true, restart: false }); + + const output = vi + .mocked(defaultRuntime.log) + .mock.calls.map((call) => String(call[0])) + .join("\n"); + const trustWarningOccurrences = output.split(trustWarning).length - 1; + expect(trustWarningOccurrences).toBe(1); + expect(output).toContain("Skipped demo ClawHub update"); + expect(output).toContain("Run openclaw update repair to retry post-update plugin repair."); + expect(output).toContain("Run openclaw plugins inspect demo --runtime --json for details."); + }); + it("detects missing plugin payloads from persisted records before npm updates", async () => { const installPath = createCaseDir("openclaw-missing-plugin-payload"); fsSync.mkdirSync(installPath, { recursive: true }); @@ -1879,6 +2130,103 @@ describe("update-cli", () => { expect(pluginOutcome(jsonOutput)?.status).toBe("skipped"); }); + it("marks unacknowledged ClawHub risk skips as post-update warnings", async () => { + const trustWarning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "│ • Finding: suspicious payload strings │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + updateNpmInstalledPlugins.mockResolvedValueOnce({ + changed: false, + config: baseConfig, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED, + warning: trustWarning, + message: + "Skipped demo ClawHub update: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. Existing installed plugin left unchanged.", + }, + ], + }); + vi.mocked(defaultRuntime.writeJson).mockClear(); + + await updateCommand({ json: true, restart: false }); + + const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined; + expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning"); + expect(pluginWarning(jsonOutput)?.pluginId).toBe("demo"); + expect(pluginWarning(jsonOutput)?.reason).toContain("Security scan: suspicious"); + expect(pluginWarning(jsonOutput)?.reason).toContain("suspicious payload strings"); + expect(pluginWarning(jsonOutput)?.reason).toContain("--acknowledge-clawhub-risk"); + expect(pluginWarning(jsonOutput)?.guidance).toEqual([ + "Run openclaw update repair to retry post-update plugin repair.", + "Run openclaw plugins inspect demo --runtime --json for details.", + ]); + expect(pluginOutcome(jsonOutput)?.pluginId).toBe("demo"); + expect(pluginOutcome(jsonOutput)?.status).toBe("skipped"); + }); + + it("marks blocked ClawHub update skips as post-update warnings", async () => { + const trustWarning = + "╭─ BLOCKED - ClawHub flagged this release as malicious ─╮\n" + + "│ • Security scan: malicious │\n" + + "╰──────────────────────────────────────────────────────╯"; + updateNpmInstalledPlugins.mockResolvedValueOnce({ + changed: false, + config: baseConfig, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_download_blocked", + warning: trustWarning, + message: + "Skipped demo ClawHub update: ClawHub blocked this release; update was not started. Existing installed plugin left unchanged.", + }, + ], + }); + vi.mocked(defaultRuntime.writeJson).mockClear(); + + await updateCommand({ json: true, restart: false }); + + const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined; + expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning"); + expect(pluginWarning(jsonOutput)?.pluginId).toBe("demo"); + expect(pluginWarning(jsonOutput)?.reason).toContain("Security scan: malicious"); + expect(pluginWarning(jsonOutput)?.reason).toContain("ClawHub blocked this release"); + expect(pluginOutcome(jsonOutput)?.pluginId).toBe("demo"); + expect(pluginOutcome(jsonOutput)?.status).toBe("skipped"); + expect(pluginOutcome(jsonOutput)?.message).toContain("Run openclaw update repair"); + }); + + it("prints unacknowledged ClawHub risk skips in human post-update output", async () => { + updateNpmInstalledPlugins.mockResolvedValueOnce({ + changed: false, + config: baseConfig, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED, + message: + "Skipped demo ClawHub update: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. Existing installed plugin left unchanged.", + }, + ], + }); + + await updateCommand({ yes: true, restart: false }); + + const logs = vi + .mocked(defaultRuntime.log) + .mock.calls.map((call) => String(call[0])) + .join("\n"); + expect(logs).toContain("--acknowledge-clawhub-risk"); + expect(logs).toContain("Run openclaw update repair to retry post-update plugin repair."); + expect(logs).toContain("Run openclaw plugins inspect demo --runtime --json for details."); + }); + it("fails unexpected post-core plugin sync exceptions", async () => { syncPluginsForUpdateChannel.mockRejectedValueOnce(new Error("plugin sync invariant broke")); @@ -2068,6 +2416,29 @@ describe("update-cli", () => { expect(seenJson).toBe(true); }); + it("parses update --acknowledge-clawhub-risk as the update command option", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + const program = new Command(); + program.name("openclaw"); + program.exitOverride(); + registerUpdateCli(program); + + await program.parseAsync([ + "node", + "openclaw", + "update", + "--channel", + "beta", + "--yes", + "--no-restart", + "--acknowledge-clawhub-risk", + ]); + + expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true); + expect(npmPluginUpdateCall()?.acknowledgeClawHubRisk).toBe(true); + }); + it.each([ { name: "defaults to dev channel for git installs when unset", @@ -2738,6 +3109,12 @@ describe("update-cli", () => { expect( (doctorCall?.[1].env as NodeJS.ProcessEnv | undefined)?.OPENCLAW_UPDATE_IN_PROGRESS, ).toBe("1"); + const doctorIndex = doctorCommandCallIndex(); + const snapshotOrder = createPreUpdateConfigSnapshotMock.mock.invocationCallOrder[0]; + const doctorOrder = vi.mocked(runCommandWithTimeout).mock.invocationCallOrder[doctorIndex]; + expect(requireValue(snapshotOrder, "pre-update snapshot call order")).toBeLessThan( + requireValue(doctorOrder, "post-update doctor call order"), + ); }); it("continues package post-core work for explicit post-update doctor advisories", async () => { @@ -5336,6 +5713,214 @@ describe("update-cli", () => { expect(updateCall?.syncOfficialPluginInstalls).toBe(true); }); + it("forwards ClawHub risk acknowledgement to post-update plugin work", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + + await updateCommand({ + channel: "beta", + yes: true, + restart: false, + acknowledgeClawHubRisk: true, + }); + + expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true); + expect(npmPluginUpdateCall()?.acknowledgeClawHubRisk).toBe(true); + expect(lastNpmPluginUpdateCall()?.acknowledgeClawHubRisk).toBe(true); + expect(runPostCorePluginConvergenceSpy).toHaveBeenCalledWith( + expect.objectContaining({ acknowledgeClawHubRisk: true }), + ); + }); + + it("does not prompt for ClawHub risk during post-update plugin work when stdout is not interactive", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + setTty(true); + setStdoutTty(false); + + await updateCommand({ + channel: "beta", + restart: false, + }); + + expect(syncPluginCall()?.onClawHubRisk).toBeUndefined(); + expect(npmPluginUpdateCall()?.onClawHubRisk).toBeUndefined(); + expect(lastNpmPluginUpdateCall()?.onClawHubRisk).toBeUndefined(); + }); + + it("does not prompt for ClawHub risk during post-update plugin work when --yes is set", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + setTty(true); + setStdoutTty(true); + + await updateCommand({ + channel: "beta", + yes: true, + restart: false, + }); + + expect(syncPluginCall()?.onClawHubRisk).toBeUndefined(); + expect(npmPluginUpdateCall()?.onClawHubRisk).toBeUndefined(); + expect(lastNpmPluginUpdateCall()?.onClawHubRisk).toBeUndefined(); + }); + + it("does not prompt for ClawHub risk during dry-run post-update plugin work", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + setTty(true); + setStdoutTty(true); + + await updateCommand({ + channel: "beta", + dryRun: true, + restart: false, + }); + + expect(syncPluginCall()?.onClawHubRisk).toBeUndefined(); + expect(npmPluginUpdateCall()?.onClawHubRisk).toBeUndefined(); + expect(lastNpmPluginUpdateCall()?.onClawHubRisk).toBeUndefined(); + }); + + it("sanitizes ClawHub risk prompt labels during post-update plugin work", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + setTty(true); + setStdoutTty(true); + + await updateCommand({ + channel: "beta", + restart: false, + }); + + const syncCall = syncPluginCall(); + expect(hasClawHubRiskHandler(syncCall)).toBe(true); + if (!hasClawHubRiskHandler(syncCall)) { + throw new Error("expected ClawHub risk prompt handler"); + } + + confirm.mockClear(); + confirm.mockResolvedValueOnce(true); + await syncCall.onClawHubRisk({ + packageName: "demo\npkg", + version: "1.2.3\u001b[2K", + trust: { + scanStatus: "suspicious", + moderationState: null, + blockedFromDownload: false, + reasons: ["payload_strings"], + pending: false, + stale: false, + }, + acknowledgementKind: "confirm", + warning: "warning", + }); + + const message = getConfirmMessage(); + expect(message).toContain("Update ClawHub package"); + expect(message).toContain('"demo\\npkg@1.2.3"'); + expect(message).not.toContain("\n"); + expect(message).not.toContain("\u001b"); + }); + + it("prints ClawHub risk warnings before interactive post-update acknowledgement prompts", async () => { + const tempDir = createCaseDir("openclaw-update"); + const warning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + mockPackageInstallStatus(tempDir); + setTty(true); + setStdoutTty(true); + + await updateCommand({ + channel: "beta", + restart: false, + }); + + const syncCall = syncPluginCall(); + expect(hasClawHubRiskHandler(syncCall)).toBe(true); + if (!hasClawHubRiskHandler(syncCall)) { + throw new Error("expected ClawHub risk prompt handler"); + } + + confirm.mockImplementationOnce(async () => { + const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); + expect(logs.some((line) => line.includes(warning))).toBe(true); + return true; + }); + await syncCall.onClawHubRisk({ + packageName: "demo", + version: "1.2.3", + trust: { + scanStatus: "suspicious", + moderationState: null, + blockedFromDownload: false, + reasons: ["payload_strings"], + pending: false, + stale: false, + }, + acknowledgementKind: "confirm", + warning, + }); + }); + + it("does not duplicate ClawHub risk warnings already printed before prompts", async () => { + const tempDir = createCaseDir("openclaw-update"); + const warning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + mockPackageInstallStatus(tempDir); + setTty(true); + setStdoutTty(true); + + await updateCommand({ + channel: "beta", + restart: false, + }); + + const syncCall = syncPluginCall(); + expect(hasClawHubRiskHandler(syncCall)).toBe(true); + if (!hasClawHubRiskHandler(syncCall)) { + throw new Error("expected ClawHub risk prompt handler"); + } + const logger = syncCall.logger; + if ( + logger === undefined || + logger === null || + typeof logger !== "object" || + !("warn" in logger) || + typeof logger.warn !== "function" + ) { + throw new Error("expected plugin logger"); + } + + logger.warn(`\u001b[33m${warning}\u001b[39m`); + confirm.mockResolvedValueOnce(true); + await syncCall.onClawHubRisk({ + packageName: "demo", + version: "1.2.3", + trust: { + scanStatus: "suspicious", + moderationState: null, + blockedFromDownload: false, + reasons: ["payload_strings"], + pending: false, + stale: false, + }, + acknowledgementKind: "confirm", + warning, + }); + + const output = vi + .mocked(defaultRuntime.log) + .mock.calls.map((call) => String(call[0])) + .join("\n"); + const occurrences = output.split(warning).length - 1; + expect(occurrences).toBe(1); + }); + it("persists channel and runs post-update work after switching from package to git", async () => { const tempDir = createCaseDir("openclaw-update"); const gitRoot = path.join(tempDir, "..", "openclaw"); @@ -6151,6 +6736,20 @@ describe("update-cli", () => { expect(doctorCall?.[0]).toBe(defaultRuntime); expect(doctorCall?.[1]?.nonInteractive).toBe(true); expect(process.env.OPENCLAW_UPDATE_IN_PROGRESS).toBeUndefined(); + const snapshotOrders = createPreUpdateConfigSnapshotMock.mock.invocationCallOrder; + expect(createPreUpdateConfigSnapshotMock).toHaveBeenCalledTimes(2); + expect(requireValue(snapshotOrders[0], "restart snapshot call order")).toBeLessThan( + requireValue( + vi.mocked(runDaemonRestart).mock.invocationCallOrder[0], + "daemon restart call order", + ), + ); + expect(requireValue(snapshotOrders[1], "doctor snapshot call order")).toBeLessThan( + requireValue( + vi.mocked(doctorCommand).mock.invocationCallOrder[0], + "doctor command call order", + ), + ); const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); expect( @@ -6193,7 +6792,13 @@ describe("update-cli", () => { }); vi.mocked(defaultRuntime.writeJson).mockClear(); - await updateFinalizeCommand({ json: true, yes: true, timeout: "9", restart: false }); + await updateFinalizeCommand({ + json: true, + yes: true, + timeout: "9", + restart: false, + acknowledgeClawHubRisk: true, + }); expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1"); expect(doctorEnv?.OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR).toBe("1"); @@ -6207,12 +6812,14 @@ describe("update-cli", () => { yes: true, }); expect(syncPluginCall()?.channel).toBe("stable"); + expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true); expect(lastNpmPluginUpdateCall()?.timeoutMs).toBe(9_000); expect( vi .mocked(readConfigFileSnapshot) .mock.calls.some(([options]) => options?.skipPluginValidation === true), ).toBe(true); + expect(lastNpmPluginUpdateCall()?.acknowledgeClawHubRisk).toBe(true); const output = lastWriteJsonCall() as | { status?: string; diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 58d1c90e1a63..fb331c9a6be0 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -38,6 +38,29 @@ function inheritedUpdateTimeout( return inheritOptionFromParent(command, "timeout"); } +type CommanderUpdateOptions = Record & { + acknowledgeClawhubRisk?: boolean; + acknowledgeClawHubRisk?: boolean; + channel?: string; + dryRun?: boolean; + json?: boolean; + restart?: boolean; + tag?: string; + timeout?: string; + yes?: boolean; +}; + +function normalizeCommanderClawHubRiskOption(opts: CommanderUpdateOptions): boolean { + return opts.acknowledgeClawhubRisk === true || opts.acknowledgeClawHubRisk === true; +} + +function inheritedUpdateClawHubRisk(command?: Command): boolean { + return Boolean( + inheritOptionFromParent(command, "acknowledgeClawhubRisk") ?? + inheritOptionFromParent(command, "acknowledgeClawHubRisk"), + ); +} + function registerUpdateFinalizationCommand(update: Command, name: string, hidden: boolean) { const command = update.command(name, { hidden }); command @@ -46,6 +69,11 @@ function registerUpdateFinalizationCommand(update: Command, name: string, hidden .option("--channel ", "Persist update channel before repair") .option("--timeout ", "Timeout for update repair steps in seconds (default: 1800)") .option("--yes", "Skip confirmation prompts (non-interactive)", false) + .option( + "--acknowledge-clawhub-risk", + "Acknowledge ClawHub release trust warnings during post-update plugin sync", + false, + ) .option("--no-restart", "Accepted for update command parity; repair never restarts") .addHelpText( "after", @@ -68,6 +96,8 @@ function registerUpdateFinalizationCommand(update: Command, name: string, hidden timeout: inheritedUpdateTimeout(opts, actionCommand), yes: Boolean(opts.yes), restart: false, + acknowledgeClawHubRisk: + normalizeCommanderClawHubRiskOption(opts) || inheritedUpdateClawHubRisk(actionCommand), }); } catch (err) { defaultRuntime.error(String(err)); @@ -92,6 +122,11 @@ export function registerUpdateCli(program: Command) { ) .option("--timeout ", "Timeout for each update step in seconds (default: 1800)") .option("--yes", "Skip confirmation prompts (non-interactive)", false) + .option( + "--acknowledge-clawhub-risk", + "Acknowledge ClawHub release trust warnings during post-update plugin sync", + false, + ) .addHelpText("after", () => { const examples = [ ["openclaw update", "Update a source checkout (git)"], @@ -104,6 +139,7 @@ export function registerUpdateCli(program: Command) { ["openclaw update --json", "Output result as JSON"], ["openclaw update --yes", "Non-interactive (accept downgrade prompts)"], ["openclaw update repair", "Repair stranded post-update plugin state"], + ["openclaw update --acknowledge-clawhub-risk", "Acknowledge ClawHub plugin trust warnings"], ["openclaw update wizard", "Interactive update wizard"], ["openclaw --update", "Shorthand for openclaw update"], ] as const; @@ -123,6 +159,7 @@ ${theme.heading("Switch channels:")} ${theme.heading("Non-interactive:")} - Use --yes to accept downgrade prompts + - Use --acknowledge-clawhub-risk only after reviewing ClawHub plugin trust warnings - Combine with --channel/--tag/--no-restart/--json/--timeout as needed - Use --dry-run to preview actions without writing config/installing/restarting @@ -137,16 +174,17 @@ ${theme.heading("Notes:")} ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/update")}`; }) - .action(async (opts) => { + .action(async (opts: CommanderUpdateOptions) => { try { await updateCommand({ json: Boolean(opts.json), restart: Boolean(opts.restart), dryRun: Boolean(opts.dryRun), - channel: opts.channel as string | undefined, - tag: opts.tag as string | undefined, - timeout: opts.timeout as string | undefined, + channel: opts.channel, + tag: opts.tag, + timeout: opts.timeout, yes: Boolean(opts.yes), + acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts), }); } catch (err) { defaultRuntime.error(String(err)); diff --git a/src/cli/update-cli/post-core-plugin-convergence.test.ts b/src/cli/update-cli/post-core-plugin-convergence.test.ts index fd089438c23c..54fe9b401701 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.test.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.test.ts @@ -276,6 +276,29 @@ describe("runPostCorePluginConvergence", () => { expect(result.installRecords).toEqual({ brave: baseline.brave }); }); + it("forwards ClawHub risk acknowledgement options to repair", async () => { + const cfg = { + plugins: { entries: { matrix: { enabled: true } } }, + } as unknown as OpenClawConfig; + const onClawHubRisk = vi.fn(async () => true); + await runPostCorePluginConvergence({ + cfg, + env: {}, + acknowledgeClawHubRisk: true, + onClawHubRisk, + }); + expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledTimes(1); + expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith({ + cfg, + env: { + OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION, + OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1", + }, + acknowledgeClawHubRisk: true, + onClawHubRisk, + }); + }); + it("keeps repair warnings nonblocking with actionable guidance", async () => { mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({ changes: [], @@ -364,6 +387,39 @@ describe("runPostCorePluginConvergence", () => { }); }); + it("surfaces repair notices without marking convergence errored", async () => { + mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({ + changes: ['Installed missing configured plugin "discord".'], + notices: [ + 'ClawHub trust warning for "@openclaw/discord@1.2.3": ClawHub has not completed a fresh clean security check for this release. Status: security scan is pending. Review the package before enabling it.', + ], + warnings: [], + records: { discord: { source: "clawhub", installPath: "/p/discord" } }, + }); + const result = await runPostCorePluginConvergence({ + cfg: { + plugins: { entries: { discord: { enabled: true } } }, + } as unknown as OpenClawConfig, + env: {}, + }); + expect(result.errored).toBe(false); + expect(result.warnings).toStrictEqual([]); + expect(result.notices).toStrictEqual([ + { + reason: + 'ClawHub trust warning for "@openclaw/discord@1.2.3": ClawHub has not completed a fresh clean security check for this release. Status: security scan is pending. Review the package before enabling it.', + message: + 'ClawHub trust warning for "@openclaw/discord@1.2.3": ClawHub has not completed a fresh clean security check for this release. Status: security scan is pending. Review the package before enabling it.', + guidance: [], + }, + ]); + expect(convergenceWarningsToOutcomes(result)).toStrictEqual({ + warnings: result.notices, + outcomes: [], + errored: false, + }); + }); + it("flags errored=true when smoke check finds a missing main entry", async () => { mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({ changes: [], diff --git a/src/cli/update-cli/post-core-plugin-convergence.ts b/src/cli/update-cli/post-core-plugin-convergence.ts index a81e3725a0d2..e0de0d30e474 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.ts @@ -3,6 +3,7 @@ import { repairMissingConfiguredPluginInstalls } from "../../commands/doctor/sha import { UPDATE_POST_CORE_CONVERGENCE_ENV } from "../../commands/doctor/shared/update-phase.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginInstallRecord } from "../../config/types.plugins.js"; +import type { ClawHubRiskAcknowledgementRequest } from "../../infra/clawhub-install-trust.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js"; import { resolveDefaultPluginNpmDir } from "../../plugins/install-paths.js"; import { listManagedPluginNpmRoots } from "../../plugins/npm-project-roots.js"; @@ -27,6 +28,7 @@ export type PostCoreConvergenceWarning = { export type PostCoreConvergenceResult = { changes: string[]; + notices?: PostCoreConvergenceWarning[]; warnings: PostCoreConvergenceWarning[]; errored: boolean; smokeFailures: PluginPayloadSmokeFailure[]; @@ -103,6 +105,8 @@ export async function runPostCorePluginConvergence(params: { * map is what gets persisted and returned via `installRecords`. */ baselineInstallRecords?: Record; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }): Promise { const env: NodeJS.ProcessEnv = { ...params.env, @@ -120,6 +124,8 @@ export async function runPostCorePluginConvergence(params: { cfg: params.cfg, env, ...(prunedBaseline ? { baselineRecords: prunedBaseline.records } : {}), + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), }); const warnings: PostCoreConvergenceWarning[] = repair.warnings.map((message) => ({ @@ -129,6 +135,11 @@ export async function runPostCorePluginConvergence(params: { })); const peerLinkRepair = await repairManagedNpmOpenClawPeerLinks({ env }); warnings.push(...peerLinkRepair.warnings); + const notices: PostCoreConvergenceWarning[] = (repair.notices ?? []).map((message) => ({ + reason: message, + message, + guidance: [], + })); const records: Record = repair.records; // Filter the smoke-check input to active records ONLY: configured / @@ -157,6 +168,7 @@ export async function runPostCorePluginConvergence(params: { ...repair.changes, ...peerLinkRepair.changes, ], + notices, warnings, errored: smoke.failures.length > 0, smokeFailures: smoke.failures, @@ -230,7 +242,7 @@ export function convergenceWarningsToOutcomes(convergence: PostCoreConvergenceRe .filter((w): w is PostCoreConvergenceWarning & { pluginId: string } => Boolean(w.pluginId)) .map((w) => ({ pluginId: w.pluginId, status: "error" as const, message: w.message })); return { - warnings: convergence.warnings, + warnings: [...convergence.warnings, ...(convergence.notices ?? [])], outcomes, errored: convergence.errored, }; diff --git a/src/cli/update-cli/shared.ts b/src/cli/update-cli/shared.ts index 7e5886c84c1f..5afdcf379b68 100644 --- a/src/cli/update-cli/shared.ts +++ b/src/cli/update-cli/shared.ts @@ -35,6 +35,7 @@ export type UpdateCommandOptions = { tag?: string; timeout?: string; yes?: boolean; + acknowledgeClawHubRisk?: boolean; }; export type UpdateStatusOptions = { @@ -48,6 +49,7 @@ export type UpdateFinalizeOptions = { timeout?: string; yes?: boolean; restart?: boolean; + acknowledgeClawHubRisk?: boolean; }; export type UpdateWizardOptions = { diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index dd550bd191a4..9c0bd3e64925 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -5,10 +5,12 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { Writable } from "node:stream"; -import { confirm, isCancel } from "@clack/prompts"; +import { confirm, isCancel, text } from "@clack/prompts"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js"; import { stylePromptMessage } from "../../../packages/terminal-core/src/prompt-style.js"; +import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { checkShellCompletionStatus, @@ -49,6 +51,7 @@ import { resolveGatewayService, type GatewayService, } from "../../daemon/service.js"; +import type { ClawHubRiskAcknowledgementRequest } from "../../infra/clawhub-install-trust.js"; import { createLowDiskSpaceWarning } from "../../infra/disk-space.js"; import { pathExists } from "../../infra/fs-safe.js"; import { readJsonIfExists, writeJson } from "../../infra/json-files.js"; @@ -113,6 +116,7 @@ import { resolveTrustedSourceLinkedOfficialNpmSpec, } from "../../plugins/official-external-install-records.js"; import { + isClawHubTrustSkippedOutcome, syncPluginsForUpdateChannel, updateNpmInstalledPlugins, type PluginUpdateIntegrityDriftParams, @@ -234,6 +238,66 @@ type MissingPluginInstallPayload = { type PostUpdatePluginWarning = NonNullable[number]; +function isClawHubTrustNotice(message: string): boolean { + const trimmed = stripAnsi(message).trimStart(); + return ( + trimmed.startsWith("ClawHub trust warning ") || + trimmed.startsWith("╭─ REVIEW RECOMMENDED - ClawHub ") || + trimmed.startsWith("╭─ WARNING - ClawHub found security risks ") || + trimmed.startsWith("╭─ BLOCKED - ClawHub ") + ); +} + +function isNonBlockingClawHubTrustNotice(message: string): boolean { + const trimmed = stripAnsi(message).trimStart(); + return ( + trimmed.startsWith("ClawHub trust warning ") || + trimmed.startsWith("╭─ REVIEW RECOMMENDED - ClawHub ") + ); +} + +function formatPluginUpdateWarning(message: string): string { + return message.includes("╭─") ? message : theme.warn(message); +} + +function resolveUpdateClawHubRiskAcknowledgementOptions( + opts: UpdateCommandOptions, + params: { + renderWarningBeforePrompt?: (warning: string) => void; + } = {}, +): { + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => Promise; +} { + if (opts.acknowledgeClawHubRisk) { + return { acknowledgeClawHubRisk: true }; + } + if (opts.dryRun || opts.yes || opts.json || !process.stdin.isTTY || !process.stdout.isTTY) { + return {}; + } + return { + onClawHubRisk: async (request) => { + params.renderWarningBeforePrompt?.(request.warning); + const packageName = sanitizeTerminalText(request.packageName); + const releaseLabel = `${packageName}@${sanitizeTerminalText(request.version)}`; + if (request.acknowledgementKind === "type-package") { + const answer = await text({ + message: stylePromptMessage(`type: '${packageName}' to update anyway`), + placeholder: packageName, + }); + return !isCancel(answer) && answer.trim() === packageName; + } + const ok = await confirm({ + message: stylePromptMessage( + `Update ClawHub package "${releaseLabel}" after reviewing the warning above?`, + ), + initialValue: false, + }); + return !isCancel(ok) && ok; + }, + }; +} + function pickUpdateQuip(): string { return UPDATE_QUIPS[Math.floor(Math.random() * UPDATE_QUIPS.length)] ?? "Update complete."; } @@ -551,16 +615,24 @@ function createPostUpdatePluginWarning(params: { }; } -function createGuidedPostUpdatePluginOutcome(outcome: PluginUpdateOutcome): { +function createGuidedPostUpdatePluginOutcome( + outcome: PluginUpdateOutcome, + options: { includeWarningInReason?: boolean } = {}, +): { outcome: PluginUpdateOutcome; warning?: PostUpdatePluginWarning; } { - if (outcome.status !== "error" && !isDisabledAfterFailureOutcome(outcome)) { + if (outcome.status !== "error" && !isActionableSkippedPostUpdateOutcome(outcome)) { return { outcome }; } + const includeWarningInReason = options.includeWarningInReason ?? true; + const warningReason = + outcome.warning && includeWarningInReason + ? `${outcome.warning}\n${outcome.message}` + : outcome.message; const warning = createPostUpdatePluginWarning({ ...(outcome.pluginId && outcome.pluginId !== "unknown" ? { pluginId: outcome.pluginId } : {}), - reason: outcome.message, + reason: warningReason, }); return { outcome: { @@ -589,6 +661,10 @@ function isDisabledAfterFailureOutcome(outcome: PluginUpdateOutcome): boolean { return outcome.status === "skipped" && outcome.message.includes("after plugin update failure"); } +function isActionableSkippedPostUpdateOutcome(outcome: PluginUpdateOutcome): boolean { + return isDisabledAfterFailureOutcome(outcome) || isClawHubTrustSkippedOutcome(outcome); +} + /** * Build the post-core-update result we return when the active config cannot * even be parsed. Mandatory post-core convergence requires a parseable @@ -1767,13 +1843,41 @@ export async function updatePluginsAfterCoreUpdate(params: { return invalid.result; } - const pluginLogger = params.opts.json - ? {} - : { - info: (msg: string) => defaultRuntime.log(msg), - warn: (msg: string) => defaultRuntime.log(theme.warn(msg)), - error: (msg: string) => defaultRuntime.log(theme.error(msg)), - }; + const clawHubTrustNotices = new Set(); + const loggedPluginWarnings = new Set(); + const hasLoggedPluginWarning = (message: string): boolean => + loggedPluginWarnings.has(stripAnsi(message)); + const recordLoggedPluginWarning = (message: string): void => { + loggedPluginWarnings.add(stripAnsi(message)); + }; + const recordClawHubTrustNotice = (message: string): void => { + const shouldRecord = params.opts.json + ? isClawHubTrustNotice(message) + : isNonBlockingClawHubTrustNotice(message); + if (shouldRecord) { + clawHubTrustNotices.add(stripAnsi(message)); + } + }; + const pluginLogger = { + ...(params.opts.json ? { terminalLinks: false } : {}), + info: (msg: string) => { + if (!params.opts.json) { + defaultRuntime.log(msg); + } + }, + warn: (msg: string) => { + recordLoggedPluginWarning(msg); + recordClawHubTrustNotice(msg); + if (!params.opts.json) { + defaultRuntime.log(formatPluginUpdateWarning(msg)); + } + }, + error: (msg: string) => { + if (!params.opts.json) { + defaultRuntime.log(theme.error(msg)); + } + }, + }; if (!params.opts.json) { defaultRuntime.log(""); @@ -1781,6 +1885,21 @@ export async function updatePluginsAfterCoreUpdate(params: { } const warnings: PostUpdatePluginWarning[] = []; + const clawHubRiskAcknowledgementOptions = resolveUpdateClawHubRiskAcknowledgementOptions( + params.opts, + { + renderWarningBeforePrompt: (warning) => { + if (hasLoggedPluginWarning(warning)) { + return; + } + recordLoggedPluginWarning(warning); + recordClawHubTrustNotice(warning); + if (!params.opts.json) { + defaultRuntime.log(formatPluginUpdateWarning(warning)); + } + }, + }, + ); const pluginInstallRecords = params.pluginInstallRecords ?? (await loadInstalledPluginIndexInstallRecords()); const syncConfig = withPluginInstallRecords( @@ -1794,6 +1913,7 @@ export async function updatePluginsAfterCoreUpdate(params: { externalizedBundledPluginBridges: await listPersistedBundledPluginLocationBridges({ workspaceDir: params.root, }), + ...clawHubRiskAcknowledgementOptions, logger: pluginLogger, }); for (const error of syncResult.summary.errors) { @@ -1867,6 +1987,7 @@ export async function updatePluginsAfterCoreUpdate(params: { disableOnFailure: true, logger: pluginLogger, onIntegrityDrift: onPluginIntegrityDrift, + ...clawHubRiskAcknowledgementOptions, }); pluginConfig = repairResult.config; pluginsChanged ||= repairResult.changed; @@ -1887,12 +2008,15 @@ export async function updatePluginsAfterCoreUpdate(params: { disableOnFailure: true, logger: pluginLogger, onIntegrityDrift: onPluginIntegrityDrift, + ...clawHubRiskAcknowledgementOptions, }); pluginConfig = npmResult.config; pluginsChanged ||= npmResult.changed; npmPluginsChanged ||= npmResult.changed; for (const rawOutcome of npmResult.outcomes) { - const guided = createGuidedPostUpdatePluginOutcome(rawOutcome); + const includeWarningInReason = + params.opts.json || !rawOutcome.warning || !hasLoggedPluginWarning(rawOutcome.warning); + const guided = createGuidedPostUpdatePluginOutcome(rawOutcome, { includeWarningInReason }); pluginUpdateOutcomes.push(guided.outcome); if (guided.warning) { warnings.push(guided.warning); @@ -1938,6 +2062,7 @@ export async function updatePluginsAfterCoreUpdate(params: { cfg: pluginConfig, env: process.env, baselineInstallRecords: convergenceBaselineRecords, + ...clawHubRiskAcknowledgementOptions, }); for (const change of convergence.changes) { if (!params.opts.json) { @@ -1992,6 +2117,17 @@ export async function updatePluginsAfterCoreUpdate(params: { }); } + for (const notice of clawHubTrustNotices) { + if (warnings.some((warning) => warning.reason.includes(notice))) { + continue; + } + warnings.push({ + reason: notice, + message: notice, + guidance: [], + }); + } + if (params.opts.json) { return { status: convergenceErrored ? "error" : warnings.length > 0 ? "warning" : "ok", @@ -2032,7 +2168,9 @@ export async function updatePluginsAfterCoreUpdate(params: { ); } for (const warning of syncResult.summary.warnings) { - defaultRuntime.log(theme.warn(warning)); + if (!hasLoggedPluginWarning(warning)) { + defaultRuntime.log(formatPluginUpdateWarning(warning)); + } } for (const error of syncResult.summary.errors) { defaultRuntime.log(theme.warn(createPostUpdatePluginWarning({ reason: error }).message)); @@ -2061,7 +2199,7 @@ export async function updatePluginsAfterCoreUpdate(params: { } for (const outcome of pluginUpdateOutcomes) { - if (outcome.status !== "error") { + if (outcome.status !== "error" && !isActionableSkippedPostUpdateOutcome(outcome)) { continue; } defaultRuntime.log(theme.warn(outcome.message)); @@ -2543,6 +2681,7 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis timeout: opts.timeout, yes: opts.yes, restart: false, + acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk, }, timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS, pluginInstallRecords, @@ -2968,6 +3107,9 @@ async function continuePostCoreUpdateInFreshProcess(params: { if (params.opts.yes) { argv.push("--yes"); } + if (params.opts.acknowledgeClawHubRisk) { + argv.push("--acknowledge-clawhub-risk"); + } if (params.opts.timeout) { argv.push("--timeout", params.opts.timeout); } diff --git a/src/commands/codex-runtime-plugin-install.test.ts b/src/commands/codex-runtime-plugin-install.test.ts new file mode 100644 index 000000000000..8514b03c5df5 --- /dev/null +++ b/src/commands/codex-runtime-plugin-install.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + repairMissingPluginInstallsForIds: vi.fn(), +})); + +type MissingPluginInstallRepairCall = { + pluginIds: string[]; + env?: NodeJS.ProcessEnv; +}; + +function readOnlyMissingPluginInstallRepairCall(): MissingPluginInstallRepairCall { + expect(mocks.repairMissingPluginInstallsForIds).toHaveBeenCalledOnce(); + const calls = mocks.repairMissingPluginInstallsForIds.mock.calls as unknown as Array< + [MissingPluginInstallRepairCall] + >; + const call = calls[0]?.[0]; + if (!call) { + throw new Error("Expected missing plugin install repair call"); + } + return call; +} + +vi.mock("./doctor/shared/missing-configured-plugin-install.js", () => ({ + repairMissingPluginInstallsForIds: mocks.repairMissingPluginInstallsForIds, +})); + +describe("Codex runtime plugin install repair", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ + changes: [], + warnings: [], + }); + }); + + it("surfaces non-fatal ClawHub repair notices to warning-only callers", async () => { + const reviewNotice = "REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check"; + mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ + changes: ['Repaired missing configured plugin "codex".'], + warnings: [], + notices: [reviewNotice], + }); + + const { repairCodexRuntimePluginInstallForModelSelection } = + await import("./codex-runtime-plugin-install.js"); + const result = await repairCodexRuntimePluginInstallForModelSelection({ + cfg: {}, + model: "openai/gpt-5.5", + env: {}, + }); + + const repairCall = readOnlyMissingPluginInstallRepairCall(); + expect(repairCall.pluginIds).toStrictEqual(["codex"]); + expect(repairCall.env).toStrictEqual({}); + expect(result).toStrictEqual({ + required: true, + changes: ['Repaired missing configured plugin "codex".'], + warnings: [reviewNotice], + }); + }); +}); diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index db667cdb9c1b..ea15543da366 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -620,6 +620,69 @@ describe("doctor repair sequencing", () => { ]); }); + it("surfaces ClawHub notices from successful missing configured plugin repair", async () => { + mocks.repairMissingConfiguredPluginInstalls.mockResolvedValueOnce({ + changes: ['Installed missing configured plugin "brave" from @openclaw/brave-plugin.'], + warnings: [], + notices: [ + 'ClawHub trust warning for "@openclaw/brave-plugin@1.2.3": scan=pending; reasons=pending.', + ], + }); + mocks.maybeRepairStalePluginConfig.mockImplementationOnce((cfg: OpenClawConfig) => ({ + config: { + ...cfg, + plugins: { + ...cfg.plugins, + allow: [], + entries: {}, + }, + }, + changes: ["- plugins.entries: removed 1 stale plugin entry (brave)"], + })); + + const result = await runDoctorRepairSequence({ + state: { + cfg: { + plugins: { + allow: ["brave"], + entries: { + brave: { + enabled: true, + source: "clawhub", + package: "@openclaw/brave-plugin", + }, + }, + }, + } as OpenClawConfig, + candidate: { + plugins: { + allow: ["brave"], + entries: { + brave: { + enabled: true, + source: "clawhub", + package: "@openclaw/brave-plugin", + }, + }, + }, + } as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.changeNotes).toStrictEqual([ + 'Installed missing configured plugin "brave" from @openclaw/brave-plugin.', + "- plugins.entries: removed 1 stale plugin entry (brave)", + ]); + expect(result.warningNotes).toStrictEqual([ + 'ClawHub trust warning for "@openclaw/brave-plugin@1.2.3": scan=pending; reasons=pending.', + ]); + expect(mocks.maybeRepairStalePluginConfig).toHaveBeenCalledOnce(); + expect(result.state.pendingChanges).toBe(true); + }); + it("moves legacy Codex routes to canonical OpenAI before missing plugin install repair", async () => { mocks.repairMissingConfiguredPluginInstalls.mockImplementationOnce( async (params: { cfg: OpenClawConfig }) => { diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index 7234b7751a2e..0264a97527f2 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -143,6 +143,10 @@ export async function runDoctorRepairSequence(params: { if (missingConfiguredPluginInstallRepair.warnings.length > 0) { warningNotes.push(sanitizeLines(missingConfiguredPluginInstallRepair.warnings)); } + const missingConfiguredPluginInstallNotices = missingConfiguredPluginInstallRepair.notices ?? []; + if (missingConfiguredPluginInstallNotices.length > 0) { + warningNotes.push(sanitizeLines(missingConfiguredPluginInstallNotices)); + } const failedPluginIds = missingConfiguredPluginInstallRepair.failedPluginIds ?? []; const hasUnscopedInstallRepairWarnings = missingConfiguredPluginInstallRepair.warnings.length > 0 && failedPluginIds.length === 0; diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 517031f00fc8..4affb8d0ac76 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveRegistryUpdateChannel } from "../../../infra/update-channels.js"; +import { CLAWHUB_INSTALL_ERROR_CODE } from "../../../plugins/clawhub-error-codes.js"; import { resolveClawHubInstallSpecsForUpdateChannel, resolveNpmInstallSpecsForUpdateChannel, @@ -150,6 +151,8 @@ vi.mock("../../../plugins/clawhub.js", () => ({ VERSION_NOT_FOUND: "version_not_found", ARTIFACT_UNAVAILABLE: "artifact_unavailable", ARTIFACT_DOWNLOAD_UNAVAILABLE: "artifact_download_unavailable", + CLAWHUB_DOWNLOAD_BLOCKED: "clawhub_download_blocked", + CLAWHUB_SECURITY_UNAVAILABLE: "clawhub_security_unavailable", }, installPluginFromClawHub: mocks.installPluginFromClawHub, })); @@ -179,9 +182,13 @@ vi.mock("../../../plugins/provider-install-catalog.js", () => ({ resolveProviderInstallCatalogEntries: mocks.resolveProviderInstallCatalogEntries, })); -vi.mock("../../../plugins/update.js", () => ({ - updateNpmInstalledPlugins: mocks.updateNpmInstalledPlugins, -})); +vi.mock("../../../plugins/update.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + updateNpmInstalledPlugins: mocks.updateNpmInstalledPlugins, + }; +}); describe("repairMissingConfiguredPluginInstalls", () => { beforeEach(() => { @@ -363,6 +370,36 @@ describe("repairMissingConfiguredPluginInstalls", () => { }); it("uses an explicit ClawHub install spec before npm", async () => { + const reviewNotice = + "╭─ REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check ─╮\n" + + "│ • Status: security scan is pending │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + const coloredReviewNotice = `\u001b[33m${reviewNotice}\u001b[39m`; + mocks.installPluginFromClawHub.mockImplementationOnce( + async (params: { logger?: { warn?: (message: string) => void } }) => { + params.logger?.warn?.(coloredReviewNotice); + return { + ok: true, + pluginId: "matrix", + targetDir: "/tmp/openclaw-plugins/matrix", + version: "1.2.3", + clawhub: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "@openclaw/plugin-matrix", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + version: "1.2.3", + integrity: "sha256-clawhub", + resolvedAt: "2026-05-01T00:00:00.000Z", + clawpackSha256: "0".repeat(64), + clawpackSpecVersion: 1, + clawpackManifestSha256: "1".repeat(64), + clawpackSize: 1234, + }, + }; + }, + ); mocks.listChannelPluginCatalogEntries.mockReturnValue([ { id: "matrix", @@ -387,17 +424,146 @@ describe("repairMissingConfiguredPluginInstalls", () => { env: {}, }); - expectRecordFields(mockCallArg(mocks.installPluginFromClawHub), { + const clawHubCall = expectRecordFields(mockCallArg(mocks.installPluginFromClawHub), { spec: "clawhub:@openclaw/plugin-matrix@stable", expectedPluginId: "matrix", }); + expect(clawHubCall.logger).toEqual(expect.objectContaining({ terminalLinks: false })); expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled(); expect(result.changes).toEqual([ 'Installed missing configured plugin "matrix" from clawhub:@openclaw/plugin-matrix@stable.', ]); + expect(result.notices).toContain(reviewNotice); + expect(result.notices?.[0]).not.toContain("\u001b"); expect(result.warnings).toStrictEqual([]); }); + it("adds actionable acknowledgement guidance for risky ClawHub candidate failures", async () => { + mocks.installPluginFromClawHub.mockResolvedValueOnce({ + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: + 'ClawHub release "@openclaw/plugin-matrix@stable" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue.', + }); + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "matrix", + pluginId: "matrix", + meta: { label: "Matrix" }, + install: { + clawhubSpec: "clawhub:@openclaw/plugin-matrix@stable", + }, + }, + ]); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + channels: { + matrix: { enabled: true, homeserver: "https://matrix.example.org" }, + }, + }, + env: {}, + }); + + expect(result.warnings[0]).toContain( + "openclaw plugins install clawhub:@openclaw/plugin-matrix@stable --acknowledge-clawhub-risk", + ); + }); + + it("adds repair warnings for blocked ClawHub update outcomes", async () => { + const records = { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo@stable", + clawhubPackage: "@openclaw/plugin-demo", + installPath: "/missing/demo", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.updateNpmInstalledPlugins.mockResolvedValueOnce({ + changed: false, + config: { + plugins: { + installs: records, + }, + }, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_download_blocked", + message: + 'Skipped demo ClawHub update: ClawHub release "@openclaw/plugin-demo@1.2.4" cannot be installed because ClawHub flagged it as blocked or malicious. Review the security details above or choose a different version. Existing installed plugin left unchanged.', + }, + ], + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + env: {}, + }); + + expect(mocks.updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + pluginIds: ["demo"], + }), + ); + expect(result.changes).toStrictEqual([]); + expect(result.warnings).toStrictEqual([ + 'Skipped demo ClawHub update: ClawHub release "@openclaw/plugin-demo@1.2.4" cannot be installed because ClawHub flagged it as blocked or malicious. Review the security details above or choose a different version. Existing installed plugin left unchanged.', + ]); + }); + + it("sanitizes and shell-quotes ClawHub acknowledgement guidance specs before rendering commands", async () => { + mocks.installPluginFromClawHub.mockResolvedValueOnce({ + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: + 'ClawHub release "@openclaw/plugin-matrix@stable" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue.', + }); + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "matrix", + pluginId: "matrix", + meta: { label: "Matrix" }, + install: { + clawhubSpec: "clawhub:@openclaw/plugin-matrix\n\u001b[31m@stable;$(touch /tmp/pwned)", + }, + }, + ]); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + channels: { + matrix: { enabled: true, homeserver: "https://matrix.example.org" }, + }, + }, + env: {}, + }); + + const warning = result.warnings[0] ?? ""; + expect(warning).toContain( + "openclaw plugins install 'clawhub:@openclaw/plugin-matrix\\n@stable;$(touch /tmp/pwned)' --acknowledge-clawhub-risk", + ); + expect(warning).not.toContain( + "openclaw plugins install clawhub:@openclaw/plugin-matrix\\n@stable;$(touch /tmp/pwned) --acknowledge-clawhub-risk", + ); + expect(warning).not.toContain("\u001b"); + expect(warning).not.toContain("plugin-matrix\n"); + }); + it("installs a missing channel plugin selected by environment config from npm", async () => { mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ ok: true, @@ -2674,6 +2840,268 @@ describe("repairMissingConfiguredPluginInstalls", () => { expect(result.changes).toEqual(['Repaired missing configured plugin "demo".']); }); + it("forwards ClawHub risk acknowledgement to persisted-record repair", async () => { + const records = { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo@1.0.0", + clawhubPackage: "@openclaw/plugin-demo", + installPath: "/missing/demo", + }, + }; + const onClawHubRisk = vi.fn(async () => true); + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.updateNpmInstalledPlugins.mockResolvedValue({ + changed: true, + config: { + plugins: { + installs: { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo@1.0.0", + installPath: "/tmp/openclaw-plugins/demo", + }, + }, + }, + }, + outcomes: [ + { + pluginId: "demo", + status: "updated", + message: "Updated demo.", + }, + ], + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + env: {}, + acknowledgeClawHubRisk: true, + onClawHubRisk, + }); + + const updateArg = expectRecordFields(mockCallArg(mocks.updateNpmInstalledPlugins), { + pluginIds: ["demo"], + acknowledgeClawHubRisk: true, + onClawHubRisk, + }); + expect(updateArg.logger).toEqual(expect.objectContaining({ terminalLinks: false })); + const updateConfig = updateArg.config as Record; + expectRecordFields(updateConfig.plugins, { installs: records }); + }); + + it("keeps non-ClawHub updater warnings as persisted-record repair warnings", async () => { + const records = { + demo: { + source: "npm", + spec: "@openclaw/plugin-demo@1.0.0", + installPath: "/missing/demo", + }, + }; + const repairWarning = + 'Could not repair openclaw peer link for "demo" at /tmp/openclaw-plugins/demo: permission denied'; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.updateNpmInstalledPlugins.mockImplementationOnce( + async (params: { + logger?: { warn?: (message: string) => void }; + config: Record; + }) => { + params.logger?.warn?.(repairWarning); + return { + changed: true, + config: { + plugins: { + installs: { + demo: { + source: "npm", + spec: "@openclaw/plugin-demo@1.0.0", + installPath: "/tmp/openclaw-plugins/demo", + }, + }, + }, + }, + outcomes: [ + { + pluginId: "demo", + status: "updated", + message: "Updated demo.", + }, + ], + }; + }, + ); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + env: {}, + }); + + expect(result.warnings).toContain(repairWarning); + expect(result.notices ?? []).not.toContain(repairWarning); + }); + + it("keeps ClawHub review notices non-fatal during persisted-record repair", async () => { + const records = { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo@1.0.0", + clawhubPackage: "@openclaw/plugin-demo", + installPath: "/missing/demo", + }, + }; + const reviewNotice = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + const coloredReviewNotice = `\u001b[33m${reviewNotice}\u001b[39m`; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.updateNpmInstalledPlugins.mockImplementationOnce( + async (params: { + logger?: { warn?: (message: string) => void }; + config: Record; + }) => { + params.logger?.warn?.(coloredReviewNotice); + return { + changed: true, + config: { + plugins: { + installs: { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo@1.0.0", + installPath: "/tmp/openclaw-plugins/demo", + }, + }, + }, + }, + outcomes: [ + { + pluginId: "demo", + status: "updated", + message: "Updated demo.", + }, + ], + }; + }, + ); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + env: {}, + }); + + expect(result.notices).toContain(reviewNotice); + expect(result.notices?.[0]).not.toContain("\u001b"); + expect(result.warnings).toStrictEqual([]); + }); + + it("adds actionable acknowledgement guidance for risky persisted ClawHub repair failures", async () => { + const records = { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo@1.0.0", + clawhubPackage: "@openclaw/plugin-demo", + installPath: "/missing/demo", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.updateNpmInstalledPlugins.mockResolvedValue({ + changed: false, + config: { plugins: { installs: records } }, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED, + message: + 'Skipped demo ClawHub update: ClawHub release "@openclaw/plugin-demo@1.0.0" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue. Existing installed plugin left unchanged.', + }, + ], + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + env: {}, + }); + + expect(result.warnings[0]).toContain( + "openclaw plugins install clawhub:@openclaw/plugin-demo@1.0.0 --acknowledge-clawhub-risk", + ); + }); + + it("prefixes legacy persisted ClawHub package records in acknowledgement guidance", async () => { + const records = { + demo: { + source: "clawhub", + clawhubPackage: "@openclaw/plugin-demo", + installPath: "/missing/demo", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.updateNpmInstalledPlugins.mockResolvedValue({ + changed: false, + config: { plugins: { installs: records } }, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED, + message: + 'Skipped demo ClawHub update: ClawHub release "@openclaw/plugin-demo@latest" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue. Existing installed plugin left unchanged.', + }, + ], + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + env: {}, + }); + + expect(result.warnings[0]).toContain( + "openclaw plugins install clawhub:@openclaw/plugin-demo --acknowledge-clawhub-risk", + ); + }); + it("repairs a broken managed package entry from its attributed registry diagnostic", async () => { const records = { demo: { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 158e61a141f7..91f153d1c2ac 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -3,6 +3,8 @@ import { existsSync } from "node:fs"; import { readFile, rm } from "node:fs/promises"; import path from "node:path"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { stripAnsi } from "../../../../packages/terminal-core/src/ansi.js"; +import { sanitizeTerminalText } from "../../../../packages/terminal-core/src/safe-text.js"; import { listExplicitlyDisabledChannelIdsForConfig, listPotentialConfiguredChannelIds, @@ -23,7 +25,11 @@ import { } from "../../../infra/update-channels.js"; import { resolveConfiguredChannelPresencePolicy } from "../../../plugins/channel-plugin-ids.js"; import { buildClawHubPluginInstallRecordFields } from "../../../plugins/clawhub-install-records.js"; -import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../../../plugins/clawhub.js"; +import { + CLAWHUB_INSTALL_ERROR_CODE, + installPluginFromClawHub, + type ClawHubRiskAcknowledgementRequest, +} from "../../../plugins/clawhub.js"; import { collectConfiguredMemoryEmbeddingProviderIds } from "../../../plugins/gateway-startup-plugin-ids.js"; import { collectConfiguredSpeechProviderIds } from "../../../plugins/gateway-startup-speech-providers.js"; import { @@ -57,7 +63,10 @@ import { } from "../../../plugins/official-external-plugin-catalog.js"; import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.types.js"; import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js"; -import { updateNpmInstalledPlugins } from "../../../plugins/update.js"; +import { + isClawHubTrustSkippedOutcome, + updateNpmInstalledPlugins, +} from "../../../plugins/update.js"; import { resolveWebSearchInstallCatalogEntriesForEnv, resolveWebSearchInstallCatalogEntry, @@ -117,6 +126,50 @@ function shouldFallbackClawHubToNpm(params: { ); } +function appendClawHubRiskAcknowledgementGuidance(params: { + message: string; + spec: string | undefined; +}): string { + if (!params.spec || !params.message.includes("--acknowledge-clawhub-risk")) { + return params.message; + } + const sanitizedSpec = sanitizeTerminalText(params.spec); + const shellSpec = shellQuotePosixArg(sanitizedSpec); + return `${params.message} To review and acknowledge this ClawHub package, run \`openclaw plugins install ${shellSpec} --acknowledge-clawhub-risk\` from a trusted shell, then rerun repair.`; +} + +function shellQuotePosixArg(value: string): string { + if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(value)) { + return value; + } + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function isActionableClawHubSkippedOutcome(outcome: { status: string; code?: string }): boolean { + return isClawHubTrustSkippedOutcome(outcome); +} + +function isClawHubReviewNotice(message: string): boolean { + const trimmed = stripAnsi(message).trimStart(); + return ( + trimmed.startsWith("╭─ REVIEW RECOMMENDED - ClawHub ") || + trimmed.startsWith("╭─ WARNING - ClawHub found security risks ") + ); +} + +function recordClawHubInstallSpec(record: PluginInstallRecord | undefined): string | undefined { + if (!record || record.source !== "clawhub") { + return undefined; + } + if (record.spec) { + return record.spec; + } + if (record.clawhubPackage) { + return `clawhub:${record.clawhubPackage}`; + } + return undefined; +} + function resolveCandidateClawHubSpec(install: PluginPackageInstall): string | undefined { const explicit = install.clawhubSpec?.trim(); if (explicit) { @@ -958,15 +1011,19 @@ async function installCandidate(params: { mode?: "install" | "update"; preferNpm?: boolean; repairReason?: InstallCandidateRepairReason; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }): Promise<{ records: Record; changes: string[]; + notices: string[]; warnings: string[]; failedPluginId?: string; }> { const { candidate } = params; const extensionsDir = resolveDefaultPluginExtensionsDir(params.env); const changes: string[] = []; + const warnings: string[] = []; const clawhubSpecs = candidate.clawhubSpec ? resolveClawHubInstallSpecsForUpdateChannel({ spec: candidate.clawhubSpec, @@ -1016,12 +1073,19 @@ async function installCandidate(params: { !(params.preferNpm && npmInstallSpec) && candidate.defaultChoice !== "npm"; if (shouldTryClawHub) { + const clawhubInstallSpecLabel = sanitizeTerminalText(clawhubInstallSpec); const clawhubResult = await installPluginFromClawHub({ spec: clawhubInstallSpec, extensionsDir, env: params.env, expectedPluginId: candidate.pluginId, mode: params.mode === "update" || existingClawHubPackagePath ? "update" : "install", + logger: { + terminalLinks: false, + warn: (message) => warnings.push(stripAnsi(message)), + }, + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), }); if (clawhubResult.ok) { const pluginId = clawhubResult.pluginId; @@ -1038,10 +1102,11 @@ async function installCandidate(params: { changes: [ formatInstalledConfiguredPluginChange({ pluginId, - installSpec: clawhubInstallSpec, + installSpec: clawhubInstallSpecLabel, repairReason: params.repairReason, }), ], + notices: warnings, warnings: [], }; } @@ -1049,24 +1114,33 @@ async function installCandidate(params: { !npmInstallSpec || !shouldFallbackClawHubToNpm({ result: clawhubResult, npmSpec: npmInstallSpec }) ) { + const failure = `Failed to install missing configured plugin "${candidate.pluginId}" from ${clawhubInstallSpecLabel}: ${clawhubResult.error}`; return { records: params.records, changes: [], + notices: [], warnings: [ - `Failed to install missing configured plugin "${candidate.pluginId}" from ${clawhubInstallSpec}: ${clawhubResult.error}`, + ...warnings, + appendClawHubRiskAcknowledgementGuidance({ + message: failure, + spec: clawhubInstallSpec, + }), ], failedPluginId: candidate.pluginId, }; } + const npmInstallSpecLabel = sanitizeTerminalText(npmInstallSpec); changes.push( - `ClawHub ${clawhubInstallSpec} unavailable for "${candidate.pluginId}"; falling back to npm ${npmInstallSpec}.`, + `ClawHub ${clawhubInstallSpecLabel} unavailable for "${candidate.pluginId}"; falling back to npm ${npmInstallSpecLabel}.`, ); } if (!npmInstallSpec) { return { records: params.records, changes: [], + notices: [], warnings: [ + ...warnings, `Failed to install missing configured plugin "${candidate.pluginId}": missing npm spec.`, ], failedPluginId: candidate.pluginId, @@ -1101,7 +1175,9 @@ async function installCandidate(params: { return { records: params.records, changes: [], + notices: [], warnings: [ + ...warnings, `Failed to install missing configured plugin "${candidate.pluginId}" from ${npmInstallSpec}: ${result.error}`, ], failedPluginId: candidate.pluginId, @@ -1132,6 +1208,7 @@ async function installCandidate(params: { repairReason: params.repairReason, }), ], + notices: [], warnings: [], }; } @@ -1199,6 +1276,7 @@ async function adoptExistingNpmPackage(params: { }): Promise<{ records: Record; changes: string[]; + notices: string[]; warnings: string[]; }> { const npmName = parseRegistryNpmSpec(params.npmInstallSpec)?.name; @@ -1230,6 +1308,7 @@ async function adoptExistingNpmPackage(params: { changes: [ `Repaired missing configured plugin "${params.candidate.pluginId}" from existing npm payload ${params.npmInstallSpec}.`, ], + notices: [], warnings: [], }; } @@ -1238,6 +1317,8 @@ export type RepairMissingPluginInstallsResult = { /** User-facing repair notes for installed or recovered plugin records. */ changes: string[]; /** User-facing warnings for failed or skipped plugin install repairs. */ + /** User-facing notices from successful repairs that still need operator review. */ + notices?: string[]; warnings: string[]; /** Plugin ids successfully repaired from current configuration. */ repairedPluginIds?: string[]; @@ -1261,6 +1342,8 @@ export type RepairMissingPluginInstallsResult = { export async function repairMissingConfiguredPluginInstalls(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; /** * Optional pre-seeded records. When provided, this map is used instead of * the disk-loaded install-record snapshot. Pass the in-memory records @@ -1276,6 +1359,8 @@ export async function repairMissingConfiguredPluginInstalls(params: { pluginIds: collectConfiguredPluginIds(params.cfg, params.env), channelIds: collectConfiguredChannelIds(params.cfg, params.env), blockedPluginIds: collectBlockedPluginIds(params.cfg), + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), ...(params.baselineRecords ? { baselineRecords: params.baselineRecords } : {}), }); } @@ -1288,6 +1373,8 @@ export async function repairMissingPluginInstallsForIds(params: { blockedPluginIds?: Iterable; env?: NodeJS.ProcessEnv; baselineRecords?: Record; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }): Promise { return repairMissingPluginInstalls({ cfg: params.cfg, @@ -1305,6 +1392,8 @@ export async function repairMissingPluginInstallsForIds(params: { .map((pluginId) => pluginId.trim()) .filter((pluginId) => pluginId), ), + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), ...(params.baselineRecords ? { baselineRecords: params.baselineRecords } : {}), }); } @@ -1316,6 +1405,8 @@ async function repairMissingPluginInstalls(params: { blockedPluginIds?: ReadonlySet; env?: NodeJS.ProcessEnv; baselineRecords?: Record; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }): Promise { const env = params.env ?? process.env; const snapshot = loadManifestMetadataSnapshot({ @@ -1389,6 +1480,7 @@ async function repairMissingPluginInstalls(params: { }); const officialReplacementPluginIds = new Set(officialReplacementInstallCandidates.keys()); const changes: string[] = []; + const notices: string[] = []; const warnings: string[] = []; const deferredRepairDetails: string[] = []; const failedPluginIds = new Set(); @@ -1467,9 +1559,18 @@ async function repairMissingPluginInstalls(params: { pluginIds: missingRecordedPluginIds, updateChannel, logger: { - warn: (message) => warnings.push(message), + terminalLinks: false, + warn: (message) => { + if (isClawHubReviewNotice(message)) { + notices.push(stripAnsi(message)); + return; + } + warnings.push(message); + }, error: (message) => warnings.push(message), }, + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), }); for (const outcome of updateResult.outcomes) { if (outcome.status === "updated" || outcome.status === "unchanged") { @@ -1484,6 +1585,14 @@ async function repairMissingPluginInstalls(params: { } else if (outcome.status === "error") { warnings.push(outcome.message); failedPluginIds.add(outcome.pluginId); + } else if (isActionableClawHubSkippedOutcome(outcome)) { + warnings.push( + appendClawHubRiskAcknowledgementGuidance({ + message: outcome.message, + spec: recordClawHubInstallSpec(nextRecords[outcome.pluginId]), + }), + ); + failedPluginIds.add(outcome.pluginId); } } nextRecords = updateResult.config.plugins?.installs ?? nextRecords; @@ -1562,6 +1671,8 @@ async function repairMissingPluginInstalls(params: { ...(installedPluginIdsWithStaleVersionBoundRuntimePackages.has(candidate.pluginId) ? { repairReason: "stale-version-bound-runtime" as const } : {}), + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), }); if (shouldReplaceBrokenOfficialInstall) { const installedRecord = installed.records[candidate.pluginId]; @@ -1583,6 +1694,7 @@ async function repairMissingPluginInstalls(params: { } nextRecords = installed.records; changes.push(...installed.changes); + notices.push(...installed.notices); warnings.push(...installed.warnings); if (!installed.failedPluginId && installed.records[candidate.pluginId]) { repairedPluginIds.add(candidate.pluginId); @@ -1605,6 +1717,7 @@ async function repairMissingPluginInstalls(params: { return { changes, warnings, + ...(notices.length > 0 ? { notices } : {}), ...(deferredRepairDetails.length > 0 ? { deferredRepairDetails } : {}), ...(repairedPluginIds.size > 0 ? { diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts index 1e0ee10356db..065910656eaa 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts @@ -577,6 +577,38 @@ describe("configured plugin install release step", () => { expect(result.completed).toBe(true); }); + it("surfaces non-fatal repair notices without blocking release repair completion", async () => { + const reviewNotice = "REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check"; + mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ + changes: ['Installed missing configured plugin "codex".'], + warnings: [], + notices: [reviewNotice], + }); + + const { maybeRunConfiguredPluginInstallReleaseStep } = + await import("./release-configured-plugin-installs.js"); + const result = await maybeRunConfiguredPluginInstallReleaseStep({ + cfg: { + agents: { + defaults: { + model: "openai/gpt-5.4", + agentRuntime: { id: "codex" }, + }, + }, + }, + currentVersion: "2026.5.2-beta.1", + touchedVersion: "2026.5.1", + env: {}, + }); + + expect(result).toEqual({ + changes: ['Installed missing configured plugin "codex".'], + warnings: [reviewNotice], + completed: true, + touchedConfig: true, + }); + }); + it("does not stamp config during update-time deferred install repair", async () => { mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ changes: [ diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index 30b206b7469b..5bd2ec6a0902 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -370,6 +370,7 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: { blockedPluginIds: collectBlockedPluginIds(params.cfg), env, }); + const warnings = [...repaired.warnings, ...(repaired.notices ?? [])]; const postInstallDoctorResult = createPostInstallDoctorResultForDeferredRepair({ updateInProgress, details: repaired.deferredRepairDetails ?? [], @@ -377,7 +378,7 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: { }); return { changes: repaired.changes, - warnings: repaired.warnings, + warnings, completed: repaired.warnings.length === 0, touchedConfig: false, ...(postInstallDoctorResult ? { postInstallDoctorResult } : {}), @@ -394,6 +395,7 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: { env, }); const completed = repaired.warnings.length === 0 && !updateInProgress; + const warnings = [...repaired.warnings, ...(repaired.notices ?? [])]; const postInstallDoctorResult = createPostInstallDoctorResultForDeferredRepair({ updateInProgress, details: repaired.deferredRepairDetails ?? [], @@ -401,7 +403,7 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: { }); return { changes: repaired.changes, - warnings: repaired.warnings, + warnings, completed, touchedConfig: completed, ...(postInstallDoctorResult ? { postInstallDoctorResult } : {}), diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index 43923707bc16..669929c300b0 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -160,7 +160,18 @@ type NpmSpecInstallCall = { type ClawHubInstallCall = { config?: OpenClawConfig; expectedPluginId?: string; + logger?: { + info?: (message: string) => void; + warn?: (message: string) => void; + }; mode?: string; + onClawHubRisk?: (request: { + acknowledgementKind: "confirm" | "type-package"; + packageName: string; + trust: unknown; + version: string; + warning: string; + }) => boolean | Promise; spec?: string; timeoutMs?: number; }; @@ -532,6 +543,7 @@ describe("ensureOnboardingPluginInstalled", () => { expect(clawHubCall.expectedPluginId).toBe("demo-plugin"); expect(clawHubCall.mode).toBe("install"); expect(clawHubCall.timeoutMs).toBe(300_000); + expect(typeof clawHubCall.onClawHubRisk).toBe("function"); expect(update).toHaveBeenCalledWith("Downloading"); expect(stop).toHaveBeenCalledWith("Installed Demo Provider plugin"); const [, recordUpdate] = readFirstMockCall(recordPluginInstall, "recordPluginInstall") as [ @@ -556,6 +568,65 @@ describe("ensureOnboardingPluginInstalled", () => { expect(installed?.spec).toBe("clawhub:demo-plugin@2026.5.2"); }); + it("renders ClawHub trust warnings with line breaks before prompting during onboarding", async () => { + const warning = [ + "╭─ WARNING - ClawHub found security risks in this release ─╮", + "│ • Security scan: suspicious │", + "│ Review before installing. │", + "╰───────────────────────────────────────────────────────────────────────╯", + ].join("\n"); + installPluginFromClawHub.mockImplementation(async (params: ClawHubInstallCall) => { + params.logger?.warn?.(warning); + const acknowledged = + (await params.onClawHubRisk?.({ + acknowledgementKind: "type-package", + packageName: "demo-plugin", + trust: {}, + version: "2026.5.2", + warning, + })) ?? false; + return { + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: acknowledged ? "unexpected acknowledgement" : "risk was not acknowledged", + warning, + }; + }); + const log = vi.fn(); + const text = vi.fn(async () => "wrong-package"); + + const result = await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Provider", + install: { + clawhubSpec: "clawhub:demo-plugin@2026.5.2", + defaultChoice: "clawhub", + }, + }, + prompter: { + select: vi.fn(async () => "clawhub"), + note: vi.fn(), + text, + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + } as never, + runtime: { log } as never, + }); + + expect(result.status).toBe("failed"); + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('type the package name for "demo-plugin@2026.5.2"'), + }), + ); + const renderedWarning = log.mock.calls.map(([message]) => String(message)).join("\n"); + expect(renderedWarning).toContain("Security scan: suspicious"); + expect(renderedWarning).toContain("\n│ Review before installing."); + expect(renderedWarning).not.toContain("\\n│ Review before installing."); + expect(log.mock.invocationCallOrder[0]).toBeLessThan(text.mock.invocationCallOrder[0]); + }); + it("passes npm specs and optional expected integrity to npm installs with progress", async () => { const cfg: OpenClawConfig = { security: { diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index e9de43ecfccb..289847469264 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -115,6 +115,13 @@ function shouldFallbackClawHubToNpm(params: { ); } +function readInstallFailureWarning(result: InstallPluginFromClawHubResult): string | undefined { + if (result.ok || !("warning" in result) || typeof result.warning !== "string") { + return undefined; + } + return result.warning; +} + function resolveRealDirectory(dir: string): string | null { try { const resolved = fs.realpathSync(dir); @@ -678,6 +685,30 @@ function logInstallWarningWithSpacing(runtime: RuntimeEnv, message: string): voi runtime.log?.(`${sanitized}\n`); } +function logInstallWarningWithLineBreaks(runtime: RuntimeEnv, message: string): void { + const sanitized = message + .split("\n") + .map((line) => sanitizeTerminalText(line)) + .join("\n") + .trim(); + if (!sanitized) { + return; + } + runtime.log?.(`${sanitized}\n`); +} + +function isReviewRequiredClawHubTrustWarning(message: string): boolean { + return message.includes("WARNING - ClawHub found security risks"); +} + +function isClawHubTrustWarning(message: string): boolean { + return ( + isReviewRequiredClawHubTrustWarning(message) || + message.includes("BLOCKED - ClawHub") || + message.includes("REVIEW RECOMMENDED - ClawHub") + ); +} + async function installPluginFromNpmSpecWithProgress(params: { cfg: OpenClawConfig; entry: OnboardingPluginInstallEntry; @@ -969,6 +1000,11 @@ async function installPluginFromClawHubSpecWithProgress(params: { } animated.setLabel(shortenInstallLabel(sanitized)); }; + let renderedTrustWarning = false; + const renderTrustWarning = (message: string) => { + logInstallWarningWithLineBreaks(params.runtime, message); + renderedTrustWarning = true; + }; try { const { installPluginFromClawHub } = await import("../plugins/clawhub.js"); @@ -984,13 +1020,43 @@ async function installPluginFromClawHubSpecWithProgress(params: { info: updateProgress, warn: (message) => { updateProgress(message); + if (isReviewRequiredClawHubTrustWarning(message)) { + return; + } + if (isClawHubTrustWarning(message)) { + renderTrustWarning(message); + return; + } logInstallWarningWithSpacing(params.runtime, message); }, }, + onClawHubRisk: async (request) => { + animated.stop(); + progress.stop("Review ClawHub warning"); + renderTrustWarning(request.warning); + const packageName = sanitizeTerminalText(request.packageName); + const releaseLabel = `${packageName}@${sanitizeTerminalText(request.version)}`; + if (request.acknowledgementKind === "type-package") { + const answer = await params.prompter.text({ + message: `To install anyway, type the package name for "${releaseLabel}"`, + placeholder: packageName, + }); + return answer.trim() === packageName; + } + return await params.prompter.confirm({ + message: `Install ClawHub package "${releaseLabel}" after reviewing the warning above?`, + initialValue: false, + }); + }, }), ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS, ); animated.stop(); + const failureWarning = readInstallFailureWarning(result); + if (failureWarning && !renderedTrustWarning) { + progress.stop("Review ClawHub warning"); + renderTrustWarning(failureWarning); + } if (result.ok) { progress.stop(formatPluginInstalled(safeLabel)); } else { diff --git a/src/commands/runtime-plugin-install.ts b/src/commands/runtime-plugin-install.ts index 76f82c19e208..ba58e83abeab 100644 --- a/src/commands/runtime-plugin-install.ts +++ b/src/commands/runtime-plugin-install.ts @@ -160,7 +160,7 @@ export async function repairRuntimePluginInstallForModelSelection(params: { return { required: true, changes: result.changes, - warnings: result.warnings, + warnings: [...result.warnings, ...(result.notices ?? [])], }; } diff --git a/src/config/types.installs.ts b/src/config/types.installs.ts index c1503f798872..57491727a6e8 100644 --- a/src/config/types.installs.ts +++ b/src/config/types.installs.ts @@ -16,6 +16,14 @@ export type InstallRecordBase = { clawhubPackage?: string; clawhubFamily?: "code-plugin" | "bundle-plugin"; clawhubChannel?: "official" | "community" | "private"; + clawhubTrustDisposition?: "clean" | "review-recommended" | "review-required" | "blocked"; + clawhubTrustScanStatus?: string; + clawhubTrustModerationState?: string; + clawhubTrustReasons?: string[]; + clawhubTrustPending?: boolean; + clawhubTrustStale?: boolean; + clawhubTrustCheckedAt?: string; + clawhubTrustAcknowledgedAt?: string; artifactKind?: "legacy-zip" | "npm-pack"; artifactFormat?: "zip" | "tgz"; npmIntegrity?: string; diff --git a/src/config/zod-schema.installs.ts b/src/config/zod-schema.installs.ts index 59d43a8e7dbb..956608efee5e 100644 --- a/src/config/zod-schema.installs.ts +++ b/src/config/zod-schema.installs.ts @@ -31,6 +31,21 @@ export const InstallRecordShape = { clawhubChannel: z .union([z.literal("official"), z.literal("community"), z.literal("private")]) .optional(), + clawhubTrustDisposition: z + .union([ + z.literal("clean"), + z.literal("review-recommended"), + z.literal("review-required"), + z.literal("blocked"), + ]) + .optional(), + clawhubTrustScanStatus: z.string().optional(), + clawhubTrustModerationState: z.string().optional(), + clawhubTrustReasons: z.array(z.string()).optional(), + clawhubTrustPending: z.boolean().optional(), + clawhubTrustStale: z.boolean().optional(), + clawhubTrustCheckedAt: z.string().optional(), + clawhubTrustAcknowledgedAt: z.string().optional(), artifactKind: z.union([z.literal("legacy-zip"), z.literal("npm-pack")]).optional(), artifactFormat: z.union([z.literal("zip"), z.literal("tgz")]).optional(), npmIntegrity: z.string().optional(), diff --git a/src/gateway/server-methods/skills.clawhub.test.ts b/src/gateway/server-methods/skills.clawhub.test.ts index eb590099fc4e..f2e911a896b9 100644 --- a/src/gateway/server-methods/skills.clawhub.test.ts +++ b/src/gateway/server-methods/skills.clawhub.test.ts @@ -263,6 +263,7 @@ describe("skills gateway handlers (clawhub)", () => { slug: "calendar", version: "1.2.3", targetDir: "/tmp/workspace/skills/calendar", + warning: "Review ClawHub security details before installing.", }); const { ok, response, error } = await callSkillsHandler("skills.install", { @@ -281,12 +282,73 @@ describe("skills gateway handlers (clawhub)", () => { expect(ok).toBe(true); expect(error).toBeUndefined(); const result = response as - | { ok?: boolean; message?: string; slug?: string; version?: string } + | { ok?: boolean; message?: string; slug?: string; version?: string; warning?: string } | undefined; expect(result?.ok).toBe(true); expect(result?.message).toBe("Installed calendar@1.2.3"); expect(result?.slug).toBe("calendar"); expect(result?.version).toBe("1.2.3"); + expect(result?.warning).toBe("Review ClawHub security details before installing."); + }); + + it("returns ClawHub skill install trust warnings in Gateway error details", async () => { + installSkillFromClawHubMock.mockResolvedValue({ + ok: false, + error: "ClawHub blocked this release; install was not started.", + code: "clawhub_download_blocked", + version: "1.2.3", + warning: "BLOCKED - ClawHub flagged this release as malicious", + }); + + const { ok, response, error } = await callSkillsHandler("skills.install", { + source: "clawhub", + slug: "calendar", + }); + + expect(ok).toBe(false); + expect(response).toEqual({ + ok: false, + error: "ClawHub blocked this release; install was not started.", + code: "clawhub_download_blocked", + version: "1.2.3", + warning: "BLOCKED - ClawHub flagged this release as malicious", + }); + expect(error).toEqual({ + code: "UNAVAILABLE", + message: "ClawHub blocked this release; install was not started.", + details: { + clawhubTrustCode: "clawhub_download_blocked", + version: "1.2.3", + warning: "BLOCKED - ClawHub flagged this release as malicious", + }, + }); + }); + + it("forwards ClawHub skill install risk acknowledgements", async () => { + installSkillFromClawHubMock.mockResolvedValue({ + ok: true, + slug: "calendar", + version: "1.2.3", + targetDir: "/tmp/workspace/skills/calendar", + }); + + const { ok, error } = await callSkillsHandler("skills.install", { + source: "clawhub", + slug: "calendar", + version: "1.2.3", + acknowledgeClawHubRisk: true, + }); + + expect(installSkillFromClawHubMock).toHaveBeenCalledWith({ + workspaceDir: "/tmp/workspace", + slug: "calendar", + version: "1.2.3", + force: false, + acknowledgeClawHubRisk: true, + config: {}, + }); + expect(ok).toBe(true); + expect(error).toBeUndefined(); }); it("routes explicit agent ClawHub installs through that agent workspace", async () => { @@ -359,6 +421,7 @@ describe("skills gateway handlers (clawhub)", () => { version: "1.2.3", changed: true, targetDir: "/tmp/workspace/skills/calendar", + warning: "Latest skill version needs review before use.", }, ]); @@ -380,7 +443,7 @@ describe("skills gateway handlers (clawhub)", () => { skillKey?: string; config?: { source?: string; - results?: Array<{ ok?: boolean; slug?: string; version?: string }>; + results?: Array<{ ok?: boolean; slug?: string; version?: string; warning?: string }>; }; } | undefined; @@ -391,6 +454,85 @@ describe("skills gateway handlers (clawhub)", () => { expect(result?.config?.results?.[0]?.ok).toBe(true); expect(result?.config?.results?.[0]?.slug).toBe("calendar"); expect(result?.config?.results?.[0]?.version).toBe("1.2.3"); + expect(result?.config?.results?.[0]?.warning).toBe( + "Latest skill version needs review before use.", + ); + }); + + it("forwards ClawHub skill update risk acknowledgements", async () => { + updateSkillsFromClawHubMock.mockResolvedValue([ + { + ok: true, + slug: "calendar", + previousVersion: "1.2.2", + version: "1.2.3", + changed: true, + targetDir: "/tmp/workspace/skills/calendar", + }, + ]); + + const { ok, error } = await callSkillsHandler("skills.update", { + source: "clawhub", + slug: "calendar", + acknowledgeClawHubRisk: true, + }); + + expect(updateSkillsFromClawHubMock).toHaveBeenCalledWith({ + workspaceDir: "/tmp/workspace", + slug: "calendar", + acknowledgeClawHubRisk: true, + config: {}, + }); + expect(ok).toBe(true); + expect(error).toBeUndefined(); + }); + + it("returns ClawHub skill update trust warnings in Gateway error details", async () => { + updateSkillsFromClawHubMock.mockResolvedValue([ + { + ok: false, + error: "ClawHub blocked this release; update was not started.", + code: "clawhub_download_blocked", + warning: "Latest skill version is marked malicious; OpenClaw will not download it.", + }, + ]); + + const { ok, response, error } = await callSkillsHandler("skills.update", { + source: "clawhub", + slug: "calendar", + }); + + expect(ok).toBe(false); + expect(response).toEqual({ + ok: false, + skillKey: "calendar", + config: { + source: "clawhub", + results: [ + { + ok: false, + error: "ClawHub blocked this release; update was not started.", + code: "clawhub_download_blocked", + warning: "Latest skill version is marked malicious; OpenClaw will not download it.", + }, + ], + }, + }); + expect(error).toEqual({ + code: "UNAVAILABLE", + message: "ClawHub blocked this release; update was not started.", + details: { + results: [ + { + ok: false, + error: "ClawHub blocked this release; update was not started.", + code: "clawhub_download_blocked", + warning: "Latest skill version is marked malicious; OpenClaw will not download it.", + }, + ], + warnings: ["Latest skill version is marked malicious; OpenClaw will not download it."], + }, + }); }); it("rejects ClawHub skills.update requests without slug or all", async () => { diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index e0b8ea76b92a..fec542142236 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -1,6 +1,7 @@ // Gateway RPC handlers for skill discovery, install/update, and proposal workflows. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { + buildClawHubTrustErrorDetails, ErrorCodes, errorShape, validateSkillsBinsParams, @@ -118,6 +119,12 @@ function respondSkillWorkshopError(respond: RespondFn, err: unknown) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, formatErrorMessage(err))); } +function collectClawHubTrustWarnings(results: Array<{ warning?: string }>): string[] { + return results + .map((result) => normalizeOptionalString(result.warning)) + .filter((warning): warning is string => Boolean(warning)); +} + function buildRevisionAgentInstruction(proposal: Awaited>) { if (!proposal) { return ""; @@ -539,14 +546,17 @@ export const skillsHandlers: GatewayRequestHandlers = { slug: string; version?: string; force?: boolean; + acknowledgeClawHubRisk?: boolean; }; const result = await installSkillFromClawHub({ workspaceDir: workspaceDirRaw, slug: p.slug, version: p.version, force: Boolean(p.force), + ...(p.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), config: cfg, }); + const errorDetails = result.ok ? undefined : buildClawHubTrustErrorDetails(result); respond( result.ok, result.ok @@ -559,9 +569,16 @@ export const skillsHandlers: GatewayRequestHandlers = { slug: result.slug, version: result.version, targetDir: result.targetDir, + ...(result.warning ? { warning: result.warning } : {}), } : result, - result.ok ? undefined : errorShape(ErrorCodes.UNAVAILABLE, result.error), + result.ok + ? undefined + : errorShape( + ErrorCodes.UNAVAILABLE, + result.error, + errorDetails ? { details: errorDetails } : undefined, + ), ); return; } @@ -629,6 +646,7 @@ export const skillsHandlers: GatewayRequestHandlers = { source: "clawhub"; slug?: string; all?: boolean; + acknowledgeClawHubRisk?: boolean; }; if (!p.slug && !p.all) { respond( @@ -657,9 +675,11 @@ export const skillsHandlers: GatewayRequestHandlers = { const results = await updateSkillsFromClawHub({ workspaceDir: resolved.workspaceDir, slug: p.slug, + ...(p.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), config: resolved.cfg, }); const errors = results.filter((result) => !result.ok); + const warnings = collectClawHubTrustWarnings(results); respond( errors.length === 0, { @@ -672,7 +692,12 @@ export const skillsHandlers: GatewayRequestHandlers = { }, errors.length === 0 ? undefined - : errorShape(ErrorCodes.UNAVAILABLE, errors.map((result) => result.error).join("; ")), + : errorShape(ErrorCodes.UNAVAILABLE, errors.map((result) => result.error).join("; "), { + details: { + results, + ...(warnings.length > 0 ? { warnings } : {}), + }, + }), ); return; } diff --git a/src/infra/clawhub-install-trust.ts b/src/infra/clawhub-install-trust.ts new file mode 100644 index 000000000000..0bd949f9e8b1 --- /dev/null +++ b/src/infra/clawhub-install-trust.ts @@ -0,0 +1,1098 @@ +// Shared ClawHub exact-release trust gate for plugin and skill installs. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { stripAnsi, visibleWidth } from "../../packages/terminal-core/src/ansi.js"; +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import { formatTerminalLink } from "../../packages/terminal-core/src/terminal-link.js"; +import { theme } from "../../packages/terminal-core/src/theme.js"; +import { + fetchClawHubPackageSecurity, + fetchClawHubSkillVerification, + fetchClawHubSkillSecurityVerdicts, + resolveClawHubBaseUrl, + type ClawHubPackageSecurityResponse, + type ClawHubPackageSecurityTrust, + type ClawHubSkillSecurityVerdictItem, + type ClawHubSkillVerificationResponse, +} from "./clawhub.js"; +import { formatErrorMessage } from "./errors.js"; + +export const CLAWHUB_TRUST_ERROR_CODE = { + CLAWHUB_SECURITY_UNAVAILABLE: "clawhub_security_unavailable", + CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED: "clawhub_risk_acknowledgement_required", + CLAWHUB_DOWNLOAD_BLOCKED: "clawhub_download_blocked", +} as const; + +export type ClawHubTrustErrorCode = + (typeof CLAWHUB_TRUST_ERROR_CODE)[keyof typeof CLAWHUB_TRUST_ERROR_CODE]; + +export type ClawHubRiskAcknowledgementRequest = { + packageName: string; + version: string; + trust: ClawHubPackageSecurityTrust; + acknowledgementKind: "confirm" | "type-package"; + warning: string; +}; + +export type ClawHubTrustInstallRecordFields = { + clawhubTrustDisposition: "clean" | "review-recommended" | "review-required" | "blocked"; + clawhubTrustScanStatus?: string; + clawhubTrustModerationState?: string; + clawhubTrustReasons?: string[]; + clawhubTrustPending?: true; + clawhubTrustStale?: true; + clawhubTrustCheckedAt: string; + clawhubTrustAcknowledgedAt?: string; +}; + +export type ClawHubTrustAcceptedResult = { + ok: true; + trustInstallRecordFields: ClawHubTrustInstallRecordFields; + warning?: string; +}; + +export type ClawHubTrustFailure = { + ok: false; + error: string; + code?: ClawHubTrustErrorCode; + warning?: string; + version?: string; +}; + +type ClawHubInstallLogger = { + warn?: (message: string) => void; + terminalLinks?: boolean; +}; + +type ClawHubTrustSubject = { + kind: "plugin" | "skill"; + packageName: string; + ownerHandle?: string; +}; + +type ClawHubSkillSecurityLinks = { + subject: string; + security: string; +}; + +type ClawHubPluginSecurityLinks = { + subject: string; + clawscan: string; +}; + +type ClawHubSecurityLinks = ClawHubSkillSecurityLinks | ClawHubPluginSecurityLinks; +type ClawHubFetchedSubjectSecurity = { + security: ClawHubPackageSecurityResponse; + links?: { + subject?: string; + security?: string; + }; +}; + +const CLAWHUB_RISK_MODERATION_STATES = new Set(["blocked", "quarantined", "revoked"]); +const CLAWHUB_BLOCKING_MODERATION_STATES = new Set(["blocked", "quarantined", "revoked"]); +const CLAWHUB_SAFE_MODERATION_STATES = new Set(["", "approved"]); +const CLAWHUB_NON_RISK_SCAN_STATUSES = new Set(["pending", "scan_pending", "stale", "stale_scan"]); +const CLAWHUB_NON_RISK_REASONS = new Set([ + "pending", + "pending_scan", + "scan:pending", + "scan_pending", + "stale", + "scan:stale", + "stale_scan", +]); +const CLAWHUB_NON_SECURITY_SKILL_VERIFY_REASONS = new Set(["card.missing", "card_missing"]); +const CLAWHUB_EVIDENCE_LABEL_WIDTH = 15; +const CLAWHUB_RAW_LINK_LABEL_WIDTH = 16; + +function normalizeClawHubTrustToken(value: string | null | undefined): string { + return normalizeOptionalString(value)?.toLowerCase() ?? ""; +} + +function formatClawHubTrustStatus(label: string, token: string): string { + return token ? `${label} is ${token}` : `${label} is missing`; +} + +function formatClawHubReasonCode(reason: string): string { + const normalized = normalizeClawHubTrustToken(reason); + switch (normalized) { + case "scan:malicious": + return "malicious behavior detected"; + case "static:malicious": + return "malicious behavior detected"; + case "payload_strings": + return "suspicious payload strings"; + case "security.status_not_clean": + return "security status is not clean"; + case "skill.not_found": + return "skill was not found"; + case "version.not_found": + return "skill version was not found"; + case "scan:pending": + case "pending_scan": + case "scan_pending": + return "scan pending"; + case "scan:stale": + case "stale_scan": + return "scan data stale"; + default: + return reason; + } +} + +type ClawHubTrustAssessment = { + disposition: ClawHubTrustInstallRecordFields["clawhubTrustDisposition"]; + riskReasons: string[]; + notices: string[]; +}; + +function isPendingOrStaleTrustWarning(trust: ClawHubPackageSecurityTrust): boolean { + return trust.pending || trust.stale; +} + +function isNonRiskScanStatus(trust: ClawHubPackageSecurityTrust, scanStatus: string): boolean { + return isPendingOrStaleTrustWarning(trust) && CLAWHUB_NON_RISK_SCAN_STATUSES.has(scanStatus); +} + +function isNonRiskReason(trust: ClawHubPackageSecurityTrust, reason: string): boolean { + return isPendingOrStaleTrustWarning(trust) && CLAWHUB_NON_RISK_REASONS.has(reason); +} + +function resolveClawHubRiskReasons(trust: ClawHubPackageSecurityTrust): string[] { + const reasons: string[] = []; + if (trust.blockedFromDownload) { + reasons.push("Download disabled by ClawHub for this release"); + } + const scanStatus = normalizeClawHubTrustToken(trust.scanStatus); + if (scanStatus !== "clean" && !isNonRiskScanStatus(trust, scanStatus)) { + reasons.push(formatClawHubTrustStatus("security scan status", scanStatus)); + } + const moderationState = normalizeClawHubTrustToken(trust.moderationState); + if ( + CLAWHUB_RISK_MODERATION_STATES.has(moderationState) || + !CLAWHUB_SAFE_MODERATION_STATES.has(moderationState) + ) { + reasons.push(formatClawHubTrustStatus("moderation state", moderationState)); + } + for (const reason of trust.reasons) { + const normalized = normalizeClawHubTrustToken(reason); + if (normalized && !isNonRiskReason(trust, normalized)) { + reasons.push(formatClawHubReasonCode(reason)); + } + } + return reasons; +} + +function resolveClawHubTrustStatusNotices(trust: ClawHubPackageSecurityTrust): string[] { + const notices: string[] = []; + if (trust.pending) { + notices.push("security scan is pending"); + } + if (trust.stale) { + notices.push("scan data is stale"); + } + for (const reason of trust.reasons) { + const normalized = normalizeClawHubTrustToken(reason); + if (normalized && isNonRiskReason(trust, normalized)) { + notices.push(formatClawHubReasonCode(reason)); + } + } + return notices; +} + +function isBlockingClawHubTrust(trust: ClawHubPackageSecurityTrust): boolean { + if (trust.blockedFromDownload) { + return true; + } + if (normalizeClawHubTrustToken(trust.scanStatus) === "malicious") { + return true; + } + if (CLAWHUB_BLOCKING_MODERATION_STATES.has(normalizeClawHubTrustToken(trust.moderationState))) { + return true; + } + return trust.reasons.some((reason) => { + const normalized = normalizeClawHubTrustToken(reason); + return normalized === "scan:malicious" || normalized === "static:malicious"; + }); +} + +function hasMaliciousClawHubTrustSignal(trust: ClawHubPackageSecurityTrust): boolean { + if (normalizeClawHubTrustToken(trust.scanStatus) === "malicious") { + return true; + } + return trust.reasons.some((reason) => { + const normalized = normalizeClawHubTrustToken(reason); + return normalized === "scan:malicious" || normalized === "static:malicious"; + }); +} + +function assessClawHubTrust(trust: ClawHubPackageSecurityTrust): ClawHubTrustAssessment { + const riskReasons = resolveClawHubRiskReasons(trust); + const notices = resolveClawHubTrustStatusNotices(trust); + if (riskReasons.length === 0 && notices.length === 0) { + return { disposition: "clean", riskReasons, notices }; + } + if (isBlockingClawHubTrust(trust)) { + return { disposition: "blocked", riskReasons, notices }; + } + if (riskReasons.length > 0) { + return { disposition: "review-required", riskReasons, notices }; + } + return { disposition: "review-recommended", riskReasons, notices }; +} + +function buildClawHubTrustInstallRecordFields(params: { + trust: ClawHubPackageSecurityTrust; + assessment: ClawHubTrustAssessment; + checkedAt: string; + acknowledgedAt?: string; +}): ClawHubTrustInstallRecordFields { + const scanStatus = normalizeClawHubTrustToken(params.trust.scanStatus); + const moderationState = normalizeClawHubTrustToken(params.trust.moderationState); + const reasons = params.trust.reasons + .map((reason) => normalizeOptionalString(reason)) + .filter((reason): reason is string => Boolean(reason)); + return { + clawhubTrustDisposition: params.assessment.disposition, + ...(scanStatus ? { clawhubTrustScanStatus: scanStatus } : {}), + ...(moderationState ? { clawhubTrustModerationState: moderationState } : {}), + ...(reasons.length > 0 ? { clawhubTrustReasons: reasons } : {}), + ...(params.trust.pending ? { clawhubTrustPending: true } : {}), + ...(params.trust.stale ? { clawhubTrustStale: true } : {}), + clawhubTrustCheckedAt: params.checkedAt, + ...(params.acknowledgedAt ? { clawhubTrustAcknowledgedAt: params.acknowledgedAt } : {}), + }; +} + +function encodeClawHubPackagePath(packageName: string): string { + return packageName + .split("/") + .map((part) => encodeURIComponent(part).replaceAll("%40", "@")) + .join("/"); +} + +function resolveClawHubSubjectUrl(params: { + baseUrl?: string; + subject: ClawHubTrustSubject; +}): string { + if (params.subject.kind === "skill" && params.subject.ownerHandle) { + return `${resolveClawHubBaseUrl(params.baseUrl)}/${encodeURIComponent(params.subject.ownerHandle)}/skills/${encodeURIComponent(params.subject.packageName)}`; + } + const pathRoot = params.subject.kind === "skill" ? "skills" : "plugins"; + return `${resolveClawHubBaseUrl(params.baseUrl)}/${pathRoot}/${encodeClawHubPackagePath(params.subject.packageName)}`; +} + +function resolveClawHubSecurityLinks(params: { + baseUrl?: string; + subject: ClawHubTrustSubject; + version: string; + links?: { + subject?: string; + security?: string; + }; +}): ClawHubSecurityLinks { + const subjectUrl = resolveClawHubSubjectUrl(params); + if (params.subject.kind === "skill") { + const resolvedSubjectUrl = normalizeOptionalString(params.links?.subject) ?? subjectUrl; + return { + subject: resolvedSubjectUrl, + security: + normalizeOptionalString(params.links?.security) ?? + `${resolvedSubjectUrl}/security-audit?version=${encodeURIComponent(params.version)}`, + }; + } + return { + subject: subjectUrl, + clawscan: `${subjectUrl}/security/clawscan`, + }; +} + +function padRight(value: string, width: number): string { + return `${value}${" ".repeat(Math.max(0, width - visibleWidth(value)))}`; +} + +function wrapWords(text: string, width: number): string[] { + if (visibleWidth(text) <= width) { + return [text]; + } + const words = text.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let line = ""; + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (visibleWidth(next) > width && line) { + lines.push(line); + line = word; + } else { + line = next; + } + } + if (line) { + lines.push(line); + } + return lines; +} + +function resolveClawHubTrustAccent( + disposition: ClawHubTrustAssessment["disposition"], +): (value: string) => string { + switch (disposition) { + case "blocked": + return theme.error; + case "review-required": + return theme.warn; + case "review-recommended": + return theme.info; + case "clean": + return theme.success; + } + return theme.info; +} + +function formatClawHubEvidenceLine(params: { + label: string; + value: string; + accent: (value: string) => string; +}): string { + const label = sanitizeTerminalText(params.label).replace(/:$/u, ""); + return `${theme.muted(`• ${padRight(label, CLAWHUB_EVIDENCE_LABEL_WIDTH)}`)} ${params.accent(params.value)}`; +} + +function renderClawHubTrustBox( + title: string, + lines: string[], + disposition: ClawHubTrustAssessment["disposition"], +): string { + const accent = resolveClawHubTrustAccent(disposition); + const columns = Math.max(72, Math.min(process.stdout.columns ?? 88, 104)); + const innerWidth = Math.max(54, Math.min(columns - 4, 78)); + const totalWidth = innerWidth + 4; + const borderWidth = totalWidth - 2; + const titleSegment = `─ ${title} `; + const titleFillWidth = Math.max(0, borderWidth - visibleWidth(titleSegment)); + const top = accent(`╭${titleSegment}${"─".repeat(titleFillWidth)}╮`); + const bottom = accent(`╰${"─".repeat(borderWidth)}╯`); + const body = lines.flatMap((line) => { + if (line === "") { + return [`${accent("│")} ${" ".repeat(innerWidth)} ${accent("│")}`]; + } + return wrapWords(line, innerWidth).map( + (wrapped) => `${accent("│")} ${padRight(wrapped, innerWidth)} ${accent("│")}`, + ); + }); + return [top, ...body, bottom].join("\n"); +} + +function formatLinkedClawHubValue(params: { + label: string; + url: string; + terminalLinks?: boolean; +}): string { + const label = sanitizeTerminalText(params.label); + return formatTerminalLink(label, sanitizeTerminalText(params.url), { + fallback: label, + ...(params.terminalLinks !== undefined ? { force: params.terminalLinks } : {}), + }); +} + +function formatClawHubTrustEvidenceLines(params: { + trust: ClawHubPackageSecurityTrust; + assessment: ClawHubTrustAssessment; + links: ClawHubSecurityLinks; + terminalLinks?: boolean; +}): string[] { + const lines: string[] = []; + const accent = resolveClawHubTrustAccent(params.assessment.disposition); + const securityLink = "clawscan" in params.links ? params.links.clawscan : params.links.security; + const addLine = (label: string, value: string): void => { + lines.push(formatClawHubEvidenceLine({ label, value, accent })); + }; + const linked = (label: string, url: string): string => + formatLinkedClawHubValue({ label, url, terminalLinks: params.terminalLinks }); + const scanStatus = normalizeClawHubTrustToken(params.trust.scanStatus); + if (scanStatus) { + addLine("Security scan:", linked(scanStatus, securityLink)); + } + const moderationState = normalizeClawHubTrustToken(params.trust.moderationState); + if (moderationState && !CLAWHUB_SAFE_MODERATION_STATES.has(moderationState)) { + addLine("Moderation:", sanitizeTerminalText(moderationState)); + } + for (const reason of params.trust.reasons) { + const normalized = normalizeClawHubTrustToken(reason); + if (!normalized) { + continue; + } + if ( + params.assessment.disposition === "review-recommended" && + isNonRiskReason(params.trust, normalized) + ) { + continue; + } + switch (normalized) { + case "scan:malicious": + addLine("Scanner:", linked("malicious behavior detected", securityLink)); + break; + case "static:malicious": + addLine("Scanner:", linked("malicious behavior detected", securityLink)); + break; + case "payload_strings": + addLine("Finding:", linked("suspicious payload strings", securityLink)); + break; + default: + addLine("Finding:", sanitizeTerminalText(formatClawHubReasonCode(reason))); + break; + } + } + if (params.assessment.disposition === "review-recommended") { + for (const notice of params.assessment.notices) { + addLine("Status:", sanitizeTerminalText(notice)); + } + } + if (params.trust.blockedFromDownload) { + addLine("Finding:", "Download disabled by ClawHub for this release"); + } + if (lines.length === 0) { + for (const reason of params.assessment.riskReasons) { + addLine("Finding:", sanitizeTerminalText(reason)); + } + } + return lines; +} + +function formatClawHubRawLinkLine(label: string, url: string): string { + return ` ${theme.muted(padRight(label, CLAWHUB_RAW_LINK_LABEL_WIDTH))} ${theme.info(sanitizeTerminalText(url))}`; +} + +function formatClawHubRawLinks(params: { + subject: ClawHubTrustSubject; + links: ClawHubSecurityLinks; +}): string { + const subjectUrl = sanitizeTerminalText(params.links.subject); + if ("security" in params.links) { + return [ + "", + "Links:", + formatClawHubRawLinkLine("Skill", subjectUrl), + formatClawHubRawLinkLine("Security details", params.links.security), + ].join("\n"); + } + return [ + "", + "Links:", + formatClawHubRawLinkLine("Plugin", subjectUrl), + formatClawHubRawLinkLine("Security scan", params.links.clawscan), + ].join("\n"); +} + +function formatClawHubTrustWarning(params: { + baseUrl?: string; + subject: ClawHubTrustSubject; + version: string; + trust: ClawHubPackageSecurityTrust; + assessment: ClawHubTrustAssessment; + mode?: "install" | "update"; + terminalLinks?: boolean; + links?: { + subject?: string; + security?: string; + }; +}): string { + const links = resolveClawHubSecurityLinks({ + baseUrl: params.baseUrl, + subject: params.subject, + version: params.version, + links: params.links, + }); + const evidenceLines = formatClawHubTrustEvidenceLines({ + trust: params.trust, + assessment: params.assessment, + links, + terminalLinks: params.terminalLinks, + }); + const noun = params.subject.kind; + if (params.assessment.disposition === "blocked") { + const malicious = hasMaliciousClawHubTrustSignal(params.trust); + const blockedActionLines = + params.mode === "update" + ? malicious + ? [ + `Latest ${noun} version is marked malicious; OpenClaw will not download it.`, + `Uninstall the installed ${noun} unless you have independently reviewed it.`, + ] + : [`Latest ${noun} version is blocked by ClawHub; OpenClaw will not download it.`] + : [`OpenClaw will not install this ${noun} release from ClawHub.`]; + const blockedTitle = malicious + ? "BLOCKED - ClawHub flagged this release as malicious" + : "BLOCKED - ClawHub blocked this release"; + return [ + renderClawHubTrustBox( + blockedTitle, + [ + ...evidenceLines, + "", + ...blockedActionLines, + "Review the ClawHub security details or contact the package maintainer if you believe this is wrong.", + ], + params.assessment.disposition, + ), + formatClawHubRawLinks({ subject: params.subject, links }), + ].join("\n"); + } + if (params.assessment.disposition === "review-required") { + const riskContext = + params.subject.kind === "plugin" + ? "This plugin is not marked malicious, but ClawHub found security findings or a large local system blast radius." + : "This skill is not marked malicious, but ClawHub found security findings or a large instruction/tool-use blast radius."; + return [ + renderClawHubTrustBox( + "WARNING - ClawHub found security risks in this release", + [ + ...evidenceLines, + "", + riskContext, + `Review the ClawHub security details before ${params.mode === "update" ? "updating" : "installing"}.`, + ], + params.assessment.disposition, + ), + formatClawHubRawLinks({ subject: params.subject, links }), + ].join("\n"); + } + return [ + renderClawHubTrustBox( + "REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check", + [ + ...evidenceLines, + "", + `This does not mean the ${noun} is malicious, but ClawHub has not completed a clean security check for this release yet.`, + `Review the ClawHub security details before ${params.mode === "update" ? "updating" : "installing"}.`, + ], + params.assessment.disposition, + ), + formatClawHubRawLinks({ subject: params.subject, links }), + ].join("\n"); +} + +function formatClawHubReleaseLabel(packageName: string, version: string): string { + return `${sanitizeTerminalText(packageName)}@${sanitizeTerminalText(version)}`; +} + +function formatClawHubSubjectPackageName(subject: ClawHubTrustSubject): string { + return subject.kind === "skill" && subject.ownerHandle + ? `@${subject.ownerHandle}/${subject.packageName}` + : subject.packageName; +} + +function formatClawHubSubjectReleaseLabel(subject: ClawHubTrustSubject, version: string): string { + return formatClawHubReleaseLabel(formatClawHubSubjectPackageName(subject), version); +} + +function validateClawHubSecurityIdentity(params: { + security: ClawHubPackageSecurityResponse; + packageName: string; + packageLabel?: string; + version: string; +}): ClawHubTrustFailure | null { + const packageLabel = params.packageLabel ?? params.packageName; + const responsePackageName = normalizeOptionalString(params.security.package?.name); + if (responsePackageName !== params.packageName) { + return { + ok: false, + error: `ClawHub release trust check for "${formatClawHubReleaseLabel(packageLabel, params.version)}" returned package "${sanitizeTerminalText(responsePackageName ?? "unknown")}".`, + code: CLAWHUB_TRUST_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE, + version: params.version, + }; + } + const responseVersion = normalizeOptionalString(params.security.release?.version); + if (responseVersion !== params.version) { + return { + ok: false, + error: `ClawHub release trust check for "${formatClawHubReleaseLabel(packageLabel, params.version)}" returned version "${sanitizeTerminalText(responseVersion ?? "unknown")}".`, + code: CLAWHUB_TRUST_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE, + version: params.version, + }; + } + return null; +} + +function readSkillVerdictSecurityStatus(item: ClawHubSkillSecurityVerdictItem): string | undefined { + if (!item.security || typeof item.security !== "object") { + return undefined; + } + const security = item.security as { status?: unknown; rawStatus?: unknown }; + if (typeof security.status === "string") { + return security.status; + } + return typeof security.rawStatus === "string" ? security.rawStatus : undefined; +} + +function readSkillVerdictSecurityPassed( + item: ClawHubSkillSecurityVerdictItem, +): boolean | undefined { + if (!item.security || typeof item.security !== "object") { + return undefined; + } + const passed = (item.security as { passed?: unknown }).passed; + return typeof passed === "boolean" ? passed : undefined; +} + +function hasUsablePassingSkillVerdictSecurity(item: ClawHubSkillSecurityVerdictItem): boolean { + return ( + Boolean(readSkillVerdictSecurityStatus(item)) && readSkillVerdictSecurityPassed(item) === true + ); +} + +function hasSkillVerdictSecurityError(item: ClawHubSkillSecurityVerdictItem): boolean { + return Boolean(item.error?.code || item.error?.message || item.version === null); +} + +function isSkillVerdictPendingReason(reason: string): boolean { + const normalized = normalizeClawHubTrustToken(reason); + return normalized === "pending" || normalized === "pending_scan" || normalized === "scan_pending"; +} + +function isSkillVerdictStaleReason(reason: string): boolean { + const normalized = normalizeClawHubTrustToken(reason); + return normalized === "stale" || normalized === "scan:stale" || normalized === "stale_scan"; +} + +function isSkillVerdictBlockingReason(reason: string): boolean { + const normalized = normalizeClawHubTrustToken(reason); + return ( + normalized.includes("malicious") || + normalized.includes("malware") || + normalized.endsWith("_blocked") || + normalized.endsWith(".blocked") || + normalized === "blocked" + ); +} + +function mapSkillSecurityVerdictToPackageSecurity(params: { + item: ClawHubSkillSecurityVerdictItem; + packageName: string; + ownerHandle?: string; + version: string; +}): ClawHubPackageSecurityResponse { + const responseSlug = normalizeOptionalString(params.item.slug ?? params.item.requestedSlug); + if (responseSlug !== params.packageName) { + throw new Error( + `ClawHub skill trust check for "${formatClawHubReleaseLabel(params.packageName, params.version)}" returned skill "${sanitizeTerminalText(responseSlug ?? "unknown")}".`, + ); + } + const responsePublisher = normalizeOptionalString(params.item.publisherHandle); + if (params.ownerHandle && responsePublisher !== params.ownerHandle) { + throw new Error( + `ClawHub skill trust check for "${formatClawHubReleaseLabel(params.packageName, params.version)}" returned publisher "${sanitizeTerminalText(responsePublisher ?? "unknown")}", expected "${sanitizeTerminalText(params.ownerHandle)}".`, + ); + } + const responseVersion = normalizeOptionalString(params.item.version); + if (responseVersion !== params.version) { + const reason = params.item.error?.message + ? `: ${sanitizeTerminalText(params.item.error.message)}` + : ""; + throw new Error( + `ClawHub skill trust check for "${formatClawHubReleaseLabel(params.packageName, params.version)}" returned version "${sanitizeTerminalText(responseVersion ?? "unknown")}"${reason}.`, + ); + } + if (hasSkillVerdictSecurityError(params.item)) { + const reason = params.item.error?.message + ? `: ${sanitizeTerminalText(params.item.error.message)}` + : ""; + throw new Error( + `ClawHub skill trust check for "${formatClawHubReleaseLabel(params.packageName, params.version)}" did not return a usable security verdict${reason}.`, + ); + } + const decision = normalizeClawHubTrustToken(params.item.decision); + if (params.item.ok && decision === "pass" && !hasUsablePassingSkillVerdictSecurity(params.item)) { + throw new Error( + `ClawHub skill trust check for "${formatClawHubReleaseLabel(params.packageName, params.version)}" did not return a usable security verdict.`, + ); + } + + const securityStatus = normalizeClawHubTrustToken(readSkillVerdictSecurityStatus(params.item)); + const securityPassed = readSkillVerdictSecurityPassed(params.item); + const reasons = params.item.reasons + .map((reason) => normalizeOptionalString(reason)) + .filter((reason): reason is string => Boolean(reason)); + const securityPassedAllowsInstall = securityPassed ?? true; + const verdictPassed = params.item.ok && decision === "pass" && securityPassedAllowsInstall; + const scanStatus = verdictPassed + ? securityStatus || "clean" + : securityStatus && securityStatus !== "clean" + ? securityStatus + : "suspicious"; + if (!verdictPassed && reasons.length === 0) { + reasons.push(decision ? `decision:${decision}` : "decision:fail"); + } + const hasBlockingReason = reasons.some(isSkillVerdictBlockingReason); + const displayName = normalizeOptionalString(params.item.displayName); + return { + package: { + name: params.packageName, + family: "skill", + ...(displayName ? { displayName } : {}), + }, + release: { + version: params.version, + }, + trust: { + scanStatus, + moderationState: null, + blockedFromDownload: + decision === "blocked" || securityStatus === "malicious" || hasBlockingReason, + reasons, + pending: securityStatus === "pending" || reasons.some(isSkillVerdictPendingReason), + stale: securityStatus === "stale" || reasons.some(isSkillVerdictStaleReason), + }, + }; +} + +function resolveSkillSecurityLinks( + item: ClawHubSkillSecurityVerdictItem, +): ClawHubFetchedSubjectSecurity["links"] { + const subject = normalizeOptionalString(item.skillUrl); + const security = normalizeOptionalString(item.securityAuditUrl); + if (!subject && !security) { + return undefined; + } + return { + ...(subject ? { subject } : {}), + ...(security ? { security } : {}), + }; +} + +function readObject(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readOptionalStringField(value: unknown, field: string): string | undefined { + const record = readObject(value); + return normalizeOptionalString(record?.[field]); +} + +function readOptionalNumberField(value: unknown, field: string): number | undefined { + const record = readObject(value); + const raw = record?.[field]; + return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined; +} + +function mapSkillVerificationSecurityForVerdict( + verification: ClawHubSkillVerificationResponse, + opts?: { allowCleanCardOnlyPass?: boolean }, +): unknown { + const security = readObject(verification.security); + if (!security || Object.hasOwn(security, "passed")) { + return verification.security; + } + const status = + normalizeOptionalString(security.status) ?? normalizeOptionalString(security.rawStatus); + const decisionPass = + verification.ok && normalizeClawHubTrustToken(verification.decision) === "pass"; + if (!status || (!decisionPass && opts?.allowCleanCardOnlyPass !== true)) { + return verification.security; + } + // The owner-qualified fallback uses the older verify endpoint, whose pass + // decision plus concrete status predates the batched verdict `passed` flag. + return { ...security, passed: true }; +} + +function hasOnlyNonSecuritySkillVerifyReasons(reasons: readonly string[]): boolean { + return ( + reasons.length > 0 && + reasons.every((reason) => + CLAWHUB_NON_SECURITY_SKILL_VERIFY_REASONS.has(normalizeClawHubTrustToken(reason)), + ) + ); +} + +function isOwnerQualifiedSkillNotFoundVerdict(item: ClawHubSkillSecurityVerdictItem): boolean { + return item.error?.code === "skill_not_found"; +} + +function mapSkillVerificationToSecurityVerdictItem(params: { + verification: ClawHubSkillVerificationResponse; + slug: string; + ownerHandle: string; + version: string; +}): ClawHubSkillSecurityVerdictItem { + const skill = readObject(params.verification.skill); + const publisher = readObject(params.verification.publisher); + const versionRecord = readObject(params.verification.version); + const pageUrl = normalizeOptionalString(params.verification.pageUrl); + const reasons = params.verification.reasons + .map((reason) => normalizeOptionalString(reason)) + .filter((reason): reason is string => Boolean(reason)); + const securityStatus = normalizeClawHubTrustToken( + readOptionalStringField(params.verification.security, "status") ?? + readOptionalStringField(params.verification.security, "rawStatus"), + ); + const cardOnlyCleanFailure = + !params.verification.ok && + securityStatus === "clean" && + hasOnlyNonSecuritySkillVerifyReasons(reasons); + const verifiedVersion = + normalizeOptionalString(params.verification.version) ?? + readOptionalStringField(versionRecord, "version"); + return { + ok: cardOnlyCleanFailure ? true : params.verification.ok, + decision: cardOnlyCleanFailure ? "pass" : params.verification.decision, + reasons: cardOnlyCleanFailure ? [] : reasons, + requestedSlug: params.slug, + requestedVersion: params.version, + slug: + normalizeOptionalString(params.verification.slug) ?? readOptionalStringField(skill, "slug"), + version: verifiedVersion ?? (cardOnlyCleanFailure ? params.version : null), + displayName: + normalizeOptionalString(params.verification.displayName) ?? + readOptionalStringField(skill, "displayName"), + publisherHandle: + normalizeOptionalString(params.verification.publisherHandle) ?? + readOptionalStringField(publisher, "handle") ?? + params.ownerHandle, + publisherDisplayName: + normalizeOptionalString(params.verification.publisherDisplayName) ?? + readOptionalStringField(publisher, "displayName"), + createdAt: + params.verification.createdAt ?? readOptionalNumberField(versionRecord, "createdAt") ?? null, + checkedAt: readOptionalNumberField(params.verification.security, "checkedAt") ?? null, + ...(pageUrl ? { skillUrl: pageUrl } : {}), + ...(pageUrl + ? { + securityAuditUrl: `${pageUrl}/security-audit?version=${encodeURIComponent(params.version)}`, + } + : {}), + security: mapSkillVerificationSecurityForVerdict(params.verification, { + allowCleanCardOnlyPass: cardOnlyCleanFailure, + }), + }; +} + +async function fetchOwnerQualifiedSkillSecurityFallback(params: { + subject: { + kind: "skill"; + packageName: string; + ownerHandle?: string; + }; + version: string; + baseUrl?: string; + token?: string; + timeoutMs?: number; +}): Promise { + const ownerHandle = params.subject.ownerHandle; + if (!ownerHandle) { + throw new Error("owner-qualified skill fallback requires ownerHandle"); + } + const verification = await fetchClawHubSkillVerification({ + slug: params.subject.packageName, + ownerHandle, + version: params.version, + baseUrl: params.baseUrl, + token: params.token, + timeoutMs: params.timeoutMs, + }); + const item = mapSkillVerificationToSecurityVerdictItem({ + verification, + slug: params.subject.packageName, + ownerHandle, + version: params.version, + }); + return { + security: mapSkillSecurityVerdictToPackageSecurity({ + item, + packageName: params.subject.packageName, + ownerHandle, + version: params.version, + }), + links: resolveSkillSecurityLinks(item), + }; +} + +async function fetchClawHubSubjectSecurity(params: { + subject: ClawHubTrustSubject; + version: string; + baseUrl?: string; + token?: string; + timeoutMs?: number; +}): Promise { + if (params.subject.kind === "plugin") { + return { + security: await fetchClawHubPackageSecurity({ + name: params.subject.packageName, + version: params.version, + baseUrl: params.baseUrl, + token: params.token, + timeoutMs: params.timeoutMs, + }), + }; + } + const response = await fetchClawHubSkillSecurityVerdicts({ + items: [ + { + slug: params.subject.packageName, + ...(params.subject.ownerHandle ? { ownerHandle: params.subject.ownerHandle } : {}), + version: params.version, + }, + ], + baseUrl: params.baseUrl, + token: params.token, + timeoutMs: params.timeoutMs, + }); + if (response.items.length !== 1) { + throw new Error( + `ClawHub skill trust check for "${formatClawHubReleaseLabel(params.subject.packageName, params.version)}" returned ${response.items.length} verdicts.`, + ); + } + const item = response.items[0]; + if (!item) { + throw new Error( + `ClawHub skill trust check for "${formatClawHubReleaseLabel(params.subject.packageName, params.version)}" returned no verdict.`, + ); + } + if (params.subject.ownerHandle && isOwnerQualifiedSkillNotFoundVerdict(item)) { + return await fetchOwnerQualifiedSkillSecurityFallback({ + subject: { + kind: "skill", + packageName: params.subject.packageName, + ownerHandle: params.subject.ownerHandle, + }, + version: params.version, + baseUrl: params.baseUrl, + token: params.token, + timeoutMs: params.timeoutMs, + }); + } + return { + security: mapSkillSecurityVerdictToPackageSecurity({ + item, + packageName: params.subject.packageName, + ...(params.subject.ownerHandle ? { ownerHandle: params.subject.ownerHandle } : {}), + version: params.version, + }), + links: resolveSkillSecurityLinks(item), + }; +} + +export async function ensureClawHubPackageTrustAcknowledged(params: { + subject: ClawHubTrustSubject; + version: string; + baseUrl?: string; + token?: string; + timeoutMs?: number; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; + logger?: ClawHubInstallLogger; + mode?: "install" | "update"; +}): Promise { + let trust: ClawHubPackageSecurityTrust; + let warningLinks: ClawHubFetchedSubjectSecurity["links"]; + const packageLabel = formatClawHubSubjectPackageName(params.subject); + const releaseLabel = formatClawHubSubjectReleaseLabel(params.subject, params.version); + try { + const fetchedSecurity = await fetchClawHubSubjectSecurity({ + subject: params.subject, + version: params.version, + baseUrl: params.baseUrl, + token: params.token, + timeoutMs: params.timeoutMs, + }); + const identityFailure = validateClawHubSecurityIdentity({ + security: fetchedSecurity.security, + packageName: params.subject.packageName, + packageLabel, + version: params.version, + }); + if (identityFailure) { + return identityFailure; + } + trust = fetchedSecurity.security.trust; + warningLinks = fetchedSecurity.links; + } catch (error) { + return { + ok: false, + error: `ClawHub release trust check failed for "${releaseLabel}": ${sanitizeTerminalText(formatErrorMessage(error))}`, + code: CLAWHUB_TRUST_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE, + version: params.version, + }; + } + + const assessment = assessClawHubTrust(trust); + const checkedAt = new Date().toISOString(); + const acceptTrust = (opts?: { + acknowledgedAt?: string; + warning?: string; + }): ClawHubTrustAcceptedResult => ({ + ok: true, + trustInstallRecordFields: buildClawHubTrustInstallRecordFields({ + trust, + assessment, + checkedAt, + ...(opts?.acknowledgedAt ? { acknowledgedAt: opts.acknowledgedAt } : {}), + }), + ...(opts?.warning ? { warning: opts.warning } : {}), + }); + if (assessment.disposition === "clean") { + return acceptTrust(); + } + + const terminalWarning = formatClawHubTrustWarning({ + baseUrl: params.baseUrl, + subject: params.subject, + version: params.version, + trust, + assessment, + mode: params.mode, + terminalLinks: params.logger?.terminalLinks, + links: warningLinks, + }); + const warning = stripAnsi( + formatClawHubTrustWarning({ + baseUrl: params.baseUrl, + subject: params.subject, + version: params.version, + trust, + assessment, + mode: params.mode, + terminalLinks: false, + links: warningLinks, + }), + ); + params.logger?.warn?.(terminalWarning); + if (assessment.disposition === "review-recommended") { + return acceptTrust({ warning }); + } + if (assessment.disposition === "blocked") { + const blockedVerb = params.mode === "update" ? "update" : "install"; + return { + ok: false, + error: `ClawHub blocked this release; ${blockedVerb} was not started.`, + code: CLAWHUB_TRUST_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED, + warning, + version: params.version, + }; + } + if (params.acknowledgeClawHubRisk) { + return acceptTrust({ acknowledgedAt: new Date().toISOString(), warning }); + } + + const acknowledged = params.onClawHubRisk + ? await params.onClawHubRisk({ + packageName: packageLabel, + version: params.version, + trust, + acknowledgementKind: + assessment.disposition === "review-required" ? "type-package" : "confirm", + warning, + }) + : false; + if (acknowledged) { + return acceptTrust({ acknowledgedAt: new Date().toISOString(), warning }); + } + return { + ok: false, + error: `${params.mode === "update" ? "Update" : "Install"} cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.`, + code: CLAWHUB_TRUST_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED, + warning, + version: params.version, + }; +} diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index 6d228d5a5922..2fcaa0811134 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -16,6 +16,7 @@ import { fetchClawHubSkillCard, fetchClawHubSkillSecurityVerdicts, fetchClawHubPackageArtifact, + fetchClawHubPackageSecurity, fetchClawHubSkillVerification, normalizeClawHubSha256Integrity, normalizeClawHubSha256Hex, @@ -687,6 +688,85 @@ describe("clawhub helpers", () => { ); }); + it("fetches typed package security reports", async () => { + let requestedUrl = ""; + await expect( + fetchClawHubPackageSecurity({ + name: "@openclaw/diagnostics-otel", + version: "2026.3.22", + fetchImpl: async (input) => { + requestedUrl = input instanceof Request ? input.url : String(input); + return new Response( + JSON.stringify({ + package: { + name: "@openclaw/diagnostics-otel", + displayName: "Diagnostics", + family: "code-plugin", + }, + release: { + releaseId: "rel_demo", + version: "2026.3.22", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: true, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }, + }), + ).resolves.toEqual({ + package: { + name: "@openclaw/diagnostics-otel", + displayName: "Diagnostics", + family: "code-plugin", + }, + release: { + id: "rel_demo", + version: "2026.3.22", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: true, + }, + }); + expect(new URL(requestedUrl).pathname).toBe( + "/api/v1/packages/%40openclaw%2Fdiagnostics-otel/versions/2026.3.22/security", + ); + }); + + it("rejects malformed package security reports", async () => { + await expect( + fetchClawHubPackageSecurity({ + name: "@openclaw/diagnostics-otel", + version: "2026.3.22", + fetchImpl: async () => + new Response( + JSON.stringify({ + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: "clean", + pending: false, + stale: false, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + }), + ).rejects.toThrow("expected reasons to be a string array"); + }); + it("downloads package archives to sanitized temp paths and cleans them up", async () => { const archive = await downloadClawHubPackageArchive({ name: "@hyf/zai-external-alpha", diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index 48d9984f97bd..979bc522757f 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -76,6 +76,14 @@ export type ClawHubArtifactScanState = | "not-run" | (string & {}); export type ClawHubArtifactModerationState = "approved" | "quarantined" | "revoked" | (string & {}); +export type ClawHubPackageSecurityTrust = { + scanStatus?: ClawHubArtifactScanState | null; + moderationState?: ClawHubArtifactModerationState | null; + blockedFromDownload: boolean; + reasons: string[]; + pending: boolean; + stale: boolean; +}; export type ClawHubResolvedArtifact = | { source: "clawhub"; @@ -121,6 +129,25 @@ export type ClawHubPackageArtifactResolverResponse = { | null; artifact?: ClawHubResolvedArtifact | null; }; +export type ClawHubPackageSecurityResponse = { + package?: { + name?: string | null; + displayName?: string | null; + family?: ClawHubPackageFamily | (string & {}) | null; + } | null; + release?: { + id?: string | null; + version?: string | null; + } | null; + trust: ClawHubPackageSecurityTrust; +}; +export type ClawHubPackageReadiness = { + ok?: boolean; + ready?: boolean; + status?: string | null; + reasons?: string[]; + checks?: Record; +} & Record; export type ClawHubPackageClawPackSummary = { available: boolean; specVersion?: number | null; @@ -250,6 +277,8 @@ export type ClawHubSkillDetail = { displayName: string; summary?: string; tags?: Record; + channel?: string | null; + isOfficial?: boolean | null; createdAt: number; updatedAt: number; } | null; @@ -266,6 +295,9 @@ export type ClawHubSkillDetail = { handle?: string | null; displayName?: string | null; image?: string | null; + official?: boolean | null; + channel?: string | null; + isOfficial?: boolean | null; } | null; }; @@ -273,16 +305,23 @@ export type ClawHubSkillInstallResolutionResponse = | { ok: true; slug: string; + channel?: string | null; + isOfficial?: boolean | null; installKind: "archive"; archive: { version: string; downloadUrl: string; + channel?: string | null; + isOfficial?: boolean | null; }; } | { ok: true; slug: string; + channel?: string | null; + isOfficial?: boolean | null; installKind: "github"; + /** Commit-pinned source approved by ClawHub's install resolver policy. */ github: { repo: string; path: string; @@ -306,6 +345,12 @@ export type ClawHubSkillVerificationResponse = { ok: boolean; decision: ClawHubSkillVerificationDecision; reasons: string[]; + slug?: string | null; + displayName?: string | null; + pageUrl?: string | null; + publisherHandle?: string | null; + publisherDisplayName?: string | null; + createdAt?: number | null; skill: unknown; publisher: unknown; version: unknown; @@ -318,6 +363,7 @@ export type ClawHubSkillVerificationResponse = { export type ClawHubSkillSecurityVerdictRequestItem = { slug: string; + ownerHandle?: string; version: string; }; @@ -772,6 +818,128 @@ async function readClawHubResponseBytes(params: { }); } +function isJsonObject(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function optionalStringField( + source: Record, + field: string, + context: string, +): string | null | undefined { + const value = source[field]; + if (value === undefined || value === null || typeof value === "string") { + return value; + } + throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a string or null.`); +} + +function requiredBooleanField( + source: Record, + field: string, + context: string, +): boolean { + const value = source[field]; + if (typeof value === "boolean") { + return value; + } + throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a boolean.`); +} + +function requiredStringArrayField( + source: Record, + field: string, + context: string, +): string[] { + const value = source[field]; + if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) { + return value; + } + throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a string array.`); +} + +function parseOptionalSecurityPackage(value: unknown): ClawHubPackageSecurityResponse["package"] { + if (value === undefined || value === null) { + return value; + } + if (!isJsonObject(value)) { + throw new Error( + "Malformed ClawHub security response: expected package to be an object or null.", + ); + } + const result: NonNullable = {}; + const name = optionalStringField(value, "name", "security package"); + const displayName = optionalStringField(value, "displayName", "security package"); + const family = optionalStringField(value, "family", "security package"); + if (name !== undefined) { + result.name = name; + } + if (displayName !== undefined) { + result.displayName = displayName; + } + if (family !== undefined) { + result.family = family; + } + return result; +} + +function parseOptionalSecurityRelease(value: unknown): ClawHubPackageSecurityResponse["release"] { + if (value === undefined || value === null) { + return value; + } + if (!isJsonObject(value)) { + throw new Error( + "Malformed ClawHub security response: expected release to be an object or null.", + ); + } + const result: NonNullable = {}; + const releaseId = optionalStringField(value, "releaseId", "security release"); + const legacyId = optionalStringField(value, "id", "security release"); + const version = optionalStringField(value, "version", "security release"); + const id = releaseId ?? legacyId; + if (id !== undefined) { + result.id = id; + } + if (version !== undefined) { + result.version = version; + } + return result; +} + +function parseClawHubPackageSecurityResponse(value: unknown): ClawHubPackageSecurityResponse { + if (!isJsonObject(value)) { + throw new Error("Malformed ClawHub security response: expected an object."); + } + const trust = value.trust; + if (!isJsonObject(trust)) { + throw new Error("Malformed ClawHub security response: expected trust to be an object."); + } + const parsedTrust: ClawHubPackageSecurityTrust = { + blockedFromDownload: requiredBooleanField(trust, "blockedFromDownload", "security trust"), + reasons: requiredStringArrayField(trust, "reasons", "security trust"), + pending: requiredBooleanField(trust, "pending", "security trust"), + stale: requiredBooleanField(trust, "stale", "security trust"), + }; + const scanStatus = optionalStringField(trust, "scanStatus", "security trust"); + const moderationState = optionalStringField(trust, "moderationState", "security trust"); + if (scanStatus !== undefined) { + parsedTrust.scanStatus = scanStatus; + } + if (moderationState !== undefined) { + parsedTrust.moderationState = moderationState; + } + const result: ClawHubPackageSecurityResponse = { trust: parsedTrust }; + const parsedPackage = parseOptionalSecurityPackage(value.package); + const parsedRelease = parseOptionalSecurityRelease(value.release); + if (parsedPackage !== undefined) { + result.package = parsedPackage; + } + if (parsedRelease !== undefined) { + result.release = parsedRelease; + } + return result; +} + /** Resolves the configured ClawHub base URL, falling back to the default public host. */ export function resolveClawHubBaseUrl(baseUrl?: string): string { return normalizeBaseUrl(baseUrl); @@ -931,6 +1099,42 @@ export async function fetchClawHubPackageArtifact(params: { }); } +export async function fetchClawHubPackageSecurity(params: { + name: string; + version: string; + baseUrl?: string; + token?: string; + timeoutMs?: number; + fetchImpl?: FetchLike; +}): Promise { + const response = await fetchJson({ + baseUrl: params.baseUrl, + path: `/api/v1/packages/${encodeURIComponent(params.name)}/versions/${encodeURIComponent( + params.version, + )}/security`, + token: params.token, + timeoutMs: params.timeoutMs, + fetchImpl: params.fetchImpl, + }); + return parseClawHubPackageSecurityResponse(response); +} + +export async function fetchClawHubPackageReadiness(params: { + name: string; + baseUrl?: string; + token?: string; + timeoutMs?: number; + fetchImpl?: FetchLike; +}): Promise { + return await fetchJson({ + baseUrl: params.baseUrl, + path: `/api/v1/packages/${encodeURIComponent(params.name)}/readiness`, + token: params.token, + timeoutMs: params.timeoutMs, + fetchImpl: params.fetchImpl, + }); +} + export async function searchClawHubPackages(params: { query: string; family?: ClawHubPackageFamily; diff --git a/src/plugins/clawhub-error-codes.ts b/src/plugins/clawhub-error-codes.ts index f19099e9caf3..f39f680cb4b2 100644 --- a/src/plugins/clawhub-error-codes.ts +++ b/src/plugins/clawhub-error-codes.ts @@ -13,6 +13,9 @@ export const CLAWHUB_INSTALL_ERROR_CODE = { MISSING_ARCHIVE_INTEGRITY: "missing_archive_integrity", ARTIFACT_DOWNLOAD_UNAVAILABLE: "artifact_download_unavailable", ARCHIVE_INTEGRITY_MISMATCH: "archive_integrity_mismatch", + CLAWHUB_SECURITY_UNAVAILABLE: "clawhub_security_unavailable", + CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED: "clawhub_risk_acknowledgement_required", + CLAWHUB_DOWNLOAD_BLOCKED: "clawhub_download_blocked", } as const; /** Union of stable ClawHub install error code values. */ diff --git a/src/plugins/clawhub-install-records.ts b/src/plugins/clawhub-install-records.ts index 4577080fb7fa..46837be76eab 100644 --- a/src/plugins/clawhub-install-records.ts +++ b/src/plugins/clawhub-install-records.ts @@ -9,6 +9,14 @@ export type ClawHubPluginInstallRecordFields = { clawhubPackage: string; clawhubFamily: Exclude; clawhubChannel?: ClawHubPackageChannel; + clawhubTrustDisposition?: "clean" | "review-recommended" | "review-required" | "blocked"; + clawhubTrustScanStatus?: string; + clawhubTrustModerationState?: string; + clawhubTrustReasons?: string[]; + clawhubTrustPending?: boolean; + clawhubTrustStale?: boolean; + clawhubTrustCheckedAt?: string; + clawhubTrustAcknowledgedAt?: string; version?: string; integrity?: string; resolvedAt?: string; @@ -34,6 +42,14 @@ export function buildClawHubPluginInstallRecordFields( | "clawhubPackage" | "clawhubFamily" | "clawhubChannel" + | "clawhubTrustDisposition" + | "clawhubTrustScanStatus" + | "clawhubTrustModerationState" + | "clawhubTrustReasons" + | "clawhubTrustPending" + | "clawhubTrustStale" + | "clawhubTrustCheckedAt" + | "clawhubTrustAcknowledgedAt" | "version" | "integrity" | "resolvedAt" @@ -54,6 +70,28 @@ export function buildClawHubPluginInstallRecordFields( clawhubPackage: fields.clawhubPackage, clawhubFamily: fields.clawhubFamily, ...(fields.clawhubChannel ? { clawhubChannel: fields.clawhubChannel } : {}), + ...(fields.clawhubTrustDisposition + ? { clawhubTrustDisposition: fields.clawhubTrustDisposition } + : {}), + ...(fields.clawhubTrustScanStatus + ? { clawhubTrustScanStatus: fields.clawhubTrustScanStatus } + : {}), + ...(fields.clawhubTrustModerationState + ? { clawhubTrustModerationState: fields.clawhubTrustModerationState } + : {}), + ...(fields.clawhubTrustReasons ? { clawhubTrustReasons: fields.clawhubTrustReasons } : {}), + ...(fields.clawhubTrustPending !== undefined + ? { clawhubTrustPending: fields.clawhubTrustPending } + : {}), + ...(fields.clawhubTrustStale !== undefined + ? { clawhubTrustStale: fields.clawhubTrustStale } + : {}), + ...(fields.clawhubTrustCheckedAt + ? { clawhubTrustCheckedAt: fields.clawhubTrustCheckedAt } + : {}), + ...(fields.clawhubTrustAcknowledgedAt + ? { clawhubTrustAcknowledgedAt: fields.clawhubTrustAcknowledgedAt } + : {}), ...(fields.version ? { version: fields.version } : {}), ...(fields.integrity ? { integrity: fields.integrity } : {}), ...(fields.resolvedAt ? { resolvedAt: fields.resolvedAt } : {}), diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index 00a228a31dd7..41291141f0f0 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -11,6 +11,7 @@ import { createZipCentralDirectoryArchive } from "../test-utils/zip-central-dire const parseClawHubPluginSpecMock = vi.fn(); const fetchClawHubPackageDetailMock = vi.fn(); const fetchClawHubPackageArtifactMock = vi.fn(); +const fetchClawHubPackageSecurityMock = vi.fn(); const fetchClawHubPackageVersionMock = vi.fn(); const downloadClawHubPackageArchiveMock = vi.fn(); const archiveCleanupMock = vi.fn(); @@ -25,6 +26,7 @@ vi.mock("../infra/clawhub.js", async () => { parseClawHubPluginSpec: (...args: unknown[]) => parseClawHubPluginSpecMock(...args), fetchClawHubPackageDetail: (...args: unknown[]) => fetchClawHubPackageDetailMock(...args), fetchClawHubPackageArtifact: (...args: unknown[]) => fetchClawHubPackageArtifactMock(...args), + fetchClawHubPackageSecurity: (...args: unknown[]) => fetchClawHubPackageSecurityMock(...args), fetchClawHubPackageVersion: (...args: unknown[]) => fetchClawHubPackageVersionMock(...args), downloadClawHubPackageArchive: (...args: unknown[]) => downloadClawHubPackageArchiveMock(...args), @@ -54,6 +56,7 @@ vi.mock("../infra/archive.js", async () => { const { ClawHubRequestError } = await import("../infra/clawhub.js"); type ClawHubResolvedArtifact = import("../infra/clawhub.js").ClawHubResolvedArtifact; +type ClawHubRiskAcknowledgementRequest = import("./clawhub.js").ClawHubRiskAcknowledgementRequest; const { CLAWHUB_INSTALL_ERROR_CODE, formatClawHubSpecifier, installPluginFromClawHub } = await import("./clawhub.js"); @@ -108,10 +111,29 @@ function createLoggerSpies() { }; } +function mockCommunityClawHubPackageDetail() { + fetchClawHubPackageDetailMock.mockResolvedValue({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + channel: "community", + isOfficial: false, + createdAt: 0, + updatedAt: 0, + compatibility: { + pluginApiRange: ">=2026.3.22", + minGatewayVersion: "2026.3.0", + }, + }, + }); +} + function expectClawHubInstallFlow(params: { baseUrl: string; version: string; archivePath: string; + expectSecurityCall?: boolean; }) { expect(packageDetailCall().name).toBe("demo"); expect(packageDetailCall().baseUrl).toBe(params.baseUrl); @@ -119,17 +141,26 @@ function expectClawHubInstallFlow(params: { expect(packageVersionCall().version).toBe(params.version); expect(packageArtifactCall().name).toBe("demo"); expect(packageArtifactCall().version).toBe(params.version); + if (params.expectSecurityCall ?? true) { + expect(packageSecurityCall().name).toBe("demo"); + expect(packageSecurityCall().version).toBe(params.version); + } else { + expect(fetchClawHubPackageSecurityMock).not.toHaveBeenCalled(); + } expect(archiveInstallCall().archivePath).toBe(params.archivePath); } -function expectSuccessfulClawHubInstall(result: unknown) { +function expectSuccessfulClawHubInstall( + result: unknown, + expected: { clawhubChannel?: string } = {}, +) { const success = expectInstallSuccess(result); expect(success.pluginId).toBe("demo"); expect(success.version).toBe("2026.3.22"); expect(success.clawhub?.source).toBe("clawhub"); expect(success.clawhub?.clawhubPackage).toBe("demo"); expect(success.clawhub?.clawhubFamily).toBe("code-plugin"); - expect(success.clawhub?.clawhubChannel).toBe("official"); + expect(success.clawhub?.clawhubChannel).toBe(expected.clawhubChannel ?? "official"); expect(success.clawhub?.integrity).toBe(DEMO_ARCHIVE_INTEGRITY); } @@ -160,14 +191,18 @@ type ArchiveInstallCall = { type InstallSuccess = { clawhub?: Record; ok: true; + packageName?: string; pluginId?: string; version?: string; + warning?: string; }; type InstallFailure = { code?: string; error: string; ok: false; + version?: string; + warning?: string; }; function mockCallArg(mock: MockWithCalls, callIndex = 0, argIndex = 0): unknown { @@ -193,6 +228,10 @@ function packageArtifactCall(callIndex = 0): PackageLookupCall { return mockCallArg(fetchClawHubPackageArtifactMock, callIndex) as PackageLookupCall; } +function packageSecurityCall(callIndex = 0): PackageLookupCall { + return mockCallArg(fetchClawHubPackageSecurityMock, callIndex) as PackageLookupCall; +} + function archiveDownloadCall(callIndex = 0): PackageLookupCall { return mockCallArg(downloadClawHubPackageArchiveMock, callIndex) as PackageLookupCall; } @@ -232,6 +271,7 @@ describe("installPluginFromClawHub", () => { parseClawHubPluginSpecMock.mockReset(); fetchClawHubPackageDetailMock.mockReset(); fetchClawHubPackageArtifactMock.mockReset(); + fetchClawHubPackageSecurityMock.mockReset(); fetchClawHubPackageVersionMock.mockReset(); downloadClawHubPackageArchiveMock.mockReset(); archiveCleanupMock.mockReset(); @@ -271,6 +311,27 @@ describe("installPluginFromClawHub", () => { fetchClawHubPackageArtifactMock.mockImplementation((params) => fetchClawHubPackageVersionMock(params), ); + fetchClawHubPackageSecurityMock.mockImplementation( + (params: { name?: string; version?: string }) => + Promise.resolve({ + package: { + name: params.name ?? "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: params.version ?? "2026.3.22", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }), + ); downloadClawHubPackageArchiveMock.mockResolvedValue({ archivePath: "/tmp/clawhub-demo/archive.zip", integrity: DEMO_ARCHIVE_INTEGRITY, @@ -291,7 +352,7 @@ describe("installPluginFromClawHub", () => { expect(formatClawHubSpecifier({ name: "demo", version: "1.2.3" })).toBe("clawhub:demo@1.2.3"); }); - it("installs a ClawHub code plugin through the archive installer", async () => { + it("installs a ClawHub plugin through the archive installer", async () => { const logger = createLoggerSpies(); const result = await installPluginFromClawHub({ spec: "clawhub:demo", @@ -303,16 +364,22 @@ describe("installPluginFromClawHub", () => { baseUrl: "https://clawhub.ai", version: "2026.3.22", archivePath: "/tmp/clawhub-demo/archive.zip", + expectSecurityCall: false, }); expectSuccessfulClawHubInstall(result); expect(archiveInstallCall().installPolicyRequest).toEqual({ kind: "plugin-archive", requestedSpecifier: "clawhub:demo", - source: { kind: "clawhub", authority: "openclaw", mutable: false, network: true }, + source: { kind: "clawhub", authority: "official", mutable: false, network: true }, }); - expect(logger.info).toHaveBeenCalledWith("ClawHub code-plugin demo@2026.3.22 channel=official"); + expect(archiveInstallCall().trustedSourceLinkedOfficialInstall).toBe(true); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Package demo@2026.3.22")); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Type plugin")); expect(logger.info).toHaveBeenCalledWith( - "Compatibility: pluginApi=>=2026.3.22 minGateway=2026.3.0", + expect.stringContaining("Requires pluginApi >=2026.3.22"), + ); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining("ClawHub https://clawhub.ai/plugins/demo"), ); expect(logger.warn).not.toHaveBeenCalled(); expect(archiveCleanupMock).toHaveBeenCalledTimes(1); @@ -337,7 +404,640 @@ describe("installPluginFromClawHub", () => { }); }); - it("marks official source-linked OpenClaw packages as trusted for install scanning", async () => { + it("does not warn just because a ClawHub package is community channel", async () => { + fetchClawHubPackageDetailMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + channel: "community", + isOfficial: false, + createdAt: 0, + updatedAt: 0, + compatibility: { + pluginApiRange: ">=2026.3.22", + minGatewayVersion: "2026.3.0", + }, + }, + }); + const logger = { ...createLoggerSpies(), terminalLinks: true }; + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + }); + + const success = expectInstallSuccess(result); + expect(success.pluginId).toBe("demo"); + expect(success.clawhub?.clawhubChannel).toBe("community"); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("sanitizes the ClawHub package summary link before logging", async () => { + const logger = createLoggerSpies(); + + await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai/\u001b]8;;https://evil.example\u0007\ninjected", + logger, + }); + + const summary = logger.info.mock.calls.map(([message]) => message).join("\n"); + expect(summary).toContain("ClawHub"); + expect(summary).not.toContain("\u001b"); + expect(summary).not.toContain("\u0007"); + expect(summary).not.toContain("https://clawhub.ai/\ninjected"); + expect(summary).toContain("https://clawhub.ai/\\ninjected/plugins/demo"); + }); + + it("blocks malicious ClawHub releases even when risk is acknowledged", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "malicious", + moderationState: "quarantined", + blockedFromDownload: true, + reasons: ["manual_moderation"], + pending: false, + stale: false, + }, + }); + const logger = { ...createLoggerSpies(), terminalLinks: true }; + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + acknowledgeClawHubRisk: true, + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED); + expect(failure.error).toBe("ClawHub blocked this release; install was not started."); + expect(failure.warning).toContain("ClawHub flagged this release as malicious"); + expect(failure.warning).not.toContain("\u001b"); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("BLOCKED")); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("ClawHub flagged this release as malicious"), + ); + const warning = logger.warn.mock.calls[0]?.[0] ?? ""; + expect(warning).toContain("\u001b]8"); + expect(warning).toContain("• Security scan"); + expect(warning).toContain("malicious"); + expect(warning).toContain("• Moderation quarantined"); + expect(warning).toContain("• Finding manual_moderation"); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("manual_moderation")); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("explains that a malicious plugin update will not be downloaded", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "malicious", + moderationState: "quarantined", + blockedFromDownload: true, + reasons: ["scan:malicious"], + pending: false, + stale: false, + }, + }); + const logger = createLoggerSpies(); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + mode: "update", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED); + const warning = logger.warn.mock.calls[0]?.[0] ?? ""; + expect(warning).toContain( + "Latest plugin version is marked malicious; OpenClaw will not download it.", + ); + expect(warning).toContain( + "Uninstall the installed plugin unless you have independently reviewed it.", + ); + expect(warning).not.toContain("Choose a different version"); + expect(warning).not.toContain("/security/static-analysis"); + expect(warning).not.toContain("/security/virustotal"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("includes the blocked-download reason when other trust evidence exists", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: true, + reasons: [], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED); + expect(failure.warning).toContain("BLOCKED - ClawHub blocked this release"); + expect(failure.warning).not.toContain("flagged this release as malicious"); + expect(failure.warning).toContain("Security scan clean"); + expect(failure.warning).toContain("Download disabled by ClawHub for this release"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("requires acknowledgement before downloading non-clean ClawHub releases", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "not-run", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED); + expect(failure.warning).toContain("WARNING - ClawHub found security risks"); + expect(failure.warning).toContain("Security scan not-run"); + expect(failure.warning).toContain("large local system blast radius"); + expect(failure.warning).toContain("before installing"); + expect(failure.warning).not.toContain("blockedFromDownload=false"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("renders derived risk reasons when ClawHub trust evidence fields are missing", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: null, + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED); + expect(failure.warning).toContain("WARNING - ClawHub found security risks"); + expect(failure.warning).toContain("security scan status is missing"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("uses update wording in non-clean ClawHub release warnings during update", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "not-run", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + mode: "update", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED); + expect(failure.warning).toContain("before updating"); + expect(failure.warning).not.toContain("before installing"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("sanitizes ClawHub trust warning fields before logging", async () => { + mockCommunityClawHubPackageDetail(); + const logger = createLoggerSpies(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "clean\u001b[2K", + moderationState: null, + blockedFromDownload: false, + reasons: ["bad\nreason"], + pending: false, + stale: false, + }, + }); + + await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + }); + + const warning = logger.warn.mock.calls[0]?.[0]; + expect(warning).toContain("bad\\nreason"); + expect(warning).not.toContain("\u001b"); + expect(warning).not.toContain("bad\nreason"); + }); + + it("requires acknowledgement before downloading releases with unknown moderation state", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "clean", + moderationState: "manual-review", + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("stops when ClawHub security identity does not match the requested release", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.21", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE); + expect(failure.version).toBe("2026.3.22"); + expect(failure.error).toContain('returned version "2026.3.21"'); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("sanitizes ClawHub security identity mismatch labels before returning errors", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.21\nrewritten\u001b[2K", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE); + expect(failure.error).toContain('returned version "2026.3.21\\nrewritten"'); + expect(failure.error).not.toContain("\n"); + expect(failure.error).not.toContain("\u001b"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("sanitizes ClawHub security fetch failure labels before returning errors", async () => { + fetchClawHubPackageDetailMock.mockResolvedValueOnce({ + package: { + name: "demo\npkg", + displayName: "Demo", + family: "code-plugin", + channel: "community", + isOfficial: false, + createdAt: 0, + updatedAt: 0, + compatibility: { + pluginApiRange: ">=2026.3.22", + minGatewayVersion: "2026.3.0", + }, + }, + }); + fetchClawHubPackageSecurityMock.mockRejectedValueOnce(new Error("bad\nupstream\u001b[2K")); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE); + expect(failure.error).toContain('"demo\\npkg@2026.3.22"'); + expect(failure.error).toContain("bad\\nupstream"); + expect(failure.error).not.toContain("\n"); + expect(failure.error).not.toContain("\u001b"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("continues after a risky ClawHub release is acknowledged", async () => { + mockCommunityClawHubPackageDetail(); + const onClawHubRisk = vi.fn(async (_request: ClawHubRiskAcknowledgementRequest) => true); + const logger = { ...createLoggerSpies(), terminalLinks: true }; + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "suspicious", + moderationState: null, + blockedFromDownload: false, + reasons: ["payload_strings"], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + onClawHubRisk, + }); + + expectSuccessfulClawHubInstall(result, { clawhubChannel: "community" }); + const success = expectInstallSuccess(result); + expect(success.clawhub?.clawhubTrustDisposition).toBe("review-required"); + expect(success.clawhub?.clawhubTrustScanStatus).toBe("suspicious"); + expect(success.clawhub?.clawhubTrustReasons).toEqual(["payload_strings"]); + expect(success.clawhub?.clawhubTrustCheckedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u, + ); + expect(success.clawhub?.clawhubTrustAcknowledgedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u, + ); + expect(onClawHubRisk).toHaveBeenCalledWith( + expect.objectContaining({ + acknowledgementKind: "type-package", + packageName: "demo", + version: "2026.3.22", + warning: expect.stringContaining("payload strings"), + }), + ); + expect(onClawHubRisk.mock.calls[0]?.[0].warning).not.toContain("\u001b"); + expect(logger.warn.mock.calls.map(([message]) => message).join("\n")).toContain("\u001b]8"); + expect(downloadClawHubPackageArchiveMock).toHaveBeenCalled(); + }); + + it("warns for stale clean ClawHub trust without requiring acknowledgement", async () => { + mockCommunityClawHubPackageDetail(); + const onClawHubRisk = vi.fn(async () => false); + const logger = createLoggerSpies(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: true, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + onClawHubRisk, + }); + + expectSuccessfulClawHubInstall(result, { clawhubChannel: "community" }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("REVIEW RECOMMENDED")); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("scan data is stale")); + expect(onClawHubRisk).not.toHaveBeenCalled(); + }); + + it("warns for pending ClawHub scans without requiring acknowledgement", async () => { + mockCommunityClawHubPackageDetail(); + const onClawHubRisk = vi.fn(async () => false); + const logger = createLoggerSpies(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "pending", + moderationState: null, + blockedFromDownload: false, + reasons: ["scan:pending"], + pending: true, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + onClawHubRisk, + }); + + expectSuccessfulClawHubInstall(result, { clawhubChannel: "community" }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("REVIEW RECOMMENDED")); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("security scan is pending")); + expect(onClawHubRisk).not.toHaveBeenCalled(); + }); + + it("requires acknowledgement when pending reason codes appear without pending or stale trust", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "pending", + moderationState: null, + blockedFromDownload: false, + reasons: ["scan:pending"], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED); + expect(failure.warning).toContain("WARNING - ClawHub found security risks"); + expect(failure.warning).toContain("Security scan pending"); + expect(failure.warning).toContain("scan pending"); + expect(failure.warning).not.toContain("blockedFromDownload=false"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + }); + + it("stops when the ClawHub security response is unavailable", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockRejectedValueOnce( + new ClawHubRequestError({ + path: "/api/v1/packages/demo/versions/2026.3.22/security", + status: 404, + body: "not found", + }), + ); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE); + expect(failure.error).toContain("ClawHub release trust check failed"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + }); + + it("bypasses ClawHub trust checks for official packages", async () => { fetchClawHubPackageDetailMock.mockResolvedValueOnce({ package: { name: "demo", @@ -353,13 +1053,24 @@ describe("installPluginFromClawHub", () => { }, }, }); + fetchClawHubPackageSecurityMock.mockRejectedValueOnce(new Error("should not be called")); - await installPluginFromClawHub({ + const result = await installPluginFromClawHub({ spec: "clawhub:demo", baseUrl: "https://clawhub.ai", }); + const success = expectInstallSuccess(result); + expect(success.clawhub?.clawhubTrustDisposition).toBeUndefined(); + expect(success.clawhub?.clawhubTrustScanStatus).toBeUndefined(); + expect(fetchClawHubPackageSecurityMock).not.toHaveBeenCalled(); expect(archiveInstallCall().trustedSourceLinkedOfficialInstall).toBe(true); + expect(archiveInstallCall().installPolicyRequest?.source).toEqual({ + kind: "clawhub", + authority: "official", + mutable: false, + network: true, + }); }); it("resolves explicit ClawHub dist tags before fetching version metadata", async () => { @@ -761,6 +1472,87 @@ describe("installPluginFromClawHub", () => { expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); }); + it("treats blocked ClawHub ClawPack downloads as non-fallback trust failures", async () => { + fetchClawHubPackageVersionMock.mockResolvedValueOnce({ + version: { + version: "2026.3.22", + createdAt: 0, + changelog: "", + compatibility: { + pluginApiRange: ">=2026.3.22", + minGatewayVersion: "2026.3.0", + }, + artifact: { + kind: "npm-pack", + format: "tgz", + sha256: DEMO_CLAWPACK_SHA256, + }, + }, + }); + downloadClawHubPackageArchiveMock.mockRejectedValueOnce( + new ClawHubRequestError({ + path: "/api/v1/packages/demo/versions/2026.3.22/artifact/download", + status: 403, + body: "Blocked: this package release has been flagged as malicious and cannot be downloaded.", + }), + ); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.error).toBe( + 'ClawHub blocked artifact download for "demo@2026.3.22"; install was not started. ClawHub /api/v1/packages/demo/versions/2026.3.22/artifact/download failed (403): Blocked: this package release has been flagged as malicious and cannot be downloaded.', + ); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED); + expect(failure.version).toBe("2026.3.22"); + expect(archiveDownloadCall().artifact).toBe("clawpack"); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("keeps generic forbidden ClawHub ClawPack downloads fallback-eligible", async () => { + fetchClawHubPackageVersionMock.mockResolvedValueOnce({ + version: { + version: "2026.3.22", + createdAt: 0, + changelog: "", + compatibility: { + pluginApiRange: ">=2026.3.22", + minGatewayVersion: "2026.3.0", + }, + artifact: { + kind: "npm-pack", + format: "tgz", + sha256: DEMO_CLAWPACK_SHA256, + }, + }, + }); + downloadClawHubPackageArchiveMock.mockRejectedValueOnce( + new ClawHubRequestError({ + path: "/api/v1/packages/demo/versions/2026.3.22/artifact/download", + status: 403, + body: "Forbidden.", + }), + ); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_UNAVAILABLE); + expect(failure.error).toContain( + 'ClawHub artifact download for "demo@2026.3.22" is not available yet', + ); + expect(failure.error).toContain('Use "npm:demo@2026.3.22"'); + expect(failure.version).toBeUndefined(); + expect(archiveDownloadCall().artifact).toBe("clawpack"); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + it("does not persist package-level ClawPack metadata for version records without ClawPack facts", async () => { parseClawHubPluginSpecMock.mockReturnValueOnce({ name: "demo", version: "2026.3.21" }); fetchClawHubPackageDetailMock.mockResolvedValueOnce({ @@ -1267,6 +2059,10 @@ describe("installPluginFromClawHub", () => { expect(packageDetailCall().name).toBe("DemoAlias"); expect(packageVersionCall().name).toBe("demo"); expect(packageVersionCall().version).toBe("latest"); + expect(fetchClawHubPackageSecurityMock).not.toHaveBeenCalled(); + expect(archiveDownloadCall().name).toBe("demo"); + expect(success.packageName).toBe("demo"); + expect(success.clawhub?.clawhubPackage).toBe("demo"); expect(logger.warn).toHaveBeenCalledWith( 'ClawHub package "demo@2026.3.22" is missing sha256hash; falling back to files[] verification. Validated files: openclaw.plugin.json. Validated generated metadata files present in archive: _meta.json (JSON parse plus slug/version match only).', ); @@ -1331,6 +2127,51 @@ describe("installPluginFromClawHub", () => { expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); }); + it("checks trust before returning artifact-unavailable fallback errors", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageVersionMock.mockResolvedValueOnce({ + version: { + version: "2026.3.22", + createdAt: 0, + changelog: "", + compatibility: { + pluginApiRange: ">=2026.3.22", + minGatewayVersion: "2026.3.0", + }, + }, + }); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "malicious", + moderationState: "quarantined", + blockedFromDownload: true, + reasons: ["scan:malicious"], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + acknowledgeClawHubRisk: true, + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED); + expect(packageSecurityCall().name).toBe("demo"); + expect(failure.error).toBe("ClawHub blocked this release; install was not started."); + expect(failure.warning).toContain("BLOCKED - ClawHub flagged this release as malicious"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + }); + it("rejects ClawHub installs when the version metadata has no archive hash or fallback files[]", async () => { fetchClawHubPackageVersionMock.mockResolvedValueOnce({ version: { diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 76dc252a3efc..758965e1ac00 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -1,8 +1,14 @@ // Resolves ClawHub plugin catalog entries and install metadata. import { createHash } from "node:crypto"; import fs from "node:fs/promises"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "@openclaw/normalization-core/string-coerce"; import JSZip from "jszip"; +import { visibleWidth } from "../../packages/terminal-core/src/ansi.js"; +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import { formatTerminalLink } from "../../packages/terminal-core/src/terminal-link.js"; import { ARCHIVE_LIMIT_ERROR_CODE, ArchiveLimitError, @@ -12,6 +18,10 @@ import { DEFAULT_MAX_ENTRY_BYTES, loadZipArchiveWithPreflight, } from "../infra/archive.js"; +import { + ensureClawHubPackageTrustAcknowledged, + type ClawHubRiskAcknowledgementRequest, +} from "../infra/clawhub-install-trust.js"; import { ClawHubRequestError, downloadClawHubPackageArchive, @@ -43,17 +53,20 @@ import type { InstallSafetyOverrides } from "./install-security-scan.js"; import { installPluginFromArchive, type InstallPluginResult } from "./install.js"; export { CLAWHUB_INSTALL_ERROR_CODE }; -export type { ClawHubInstallErrorCode }; +export type { ClawHubInstallErrorCode, ClawHubRiskAcknowledgementRequest }; type PluginInstallLogger = { info?: (message: string) => void; warn?: (message: string) => void; + terminalLinks?: boolean; }; type ClawHubInstallFailure = { ok: false; error: string; code?: ClawHubInstallErrorCode; + warning?: string; + version?: string; }; type ClawHubFileEntryLike = { @@ -182,6 +195,16 @@ function isTrustedSourceLinkedOfficialPackage(pkg: NonNullable; +}): boolean { + return ( + isDefaultClawHubBaseUrl(params.baseUrl) && + (params.pkg.channel === "official" || params.pkg.isOfficial) + ); +} + function resolveClawHubClawPackArtifactSha256( clawpack: ClawHubPackageArtifactSummary | ClawHubPackageClawPackSummary | null | undefined, ): string | null { @@ -307,8 +330,16 @@ export function formatClawHubSpecifier(params: { name: string; version?: string function buildClawHubInstallFailure( error: string, code?: ClawHubInstallErrorCode, + warning?: string, + version?: string, ): ClawHubInstallFailure { - return { ok: false, error, code }; + return { + ok: false, + error, + ...(code ? { code } : {}), + ...(warning ? { warning } : {}), + ...(version ? { version } : {}), + }; } function isClawHubInstallFailure(value: unknown): value is ClawHubInstallFailure { @@ -340,6 +371,25 @@ function mapClawHubRequestError( return buildClawHubInstallFailure(formatErrorMessage(error)); } +function encodeClawHubPackagePath(packageName: string): string { + return packageName + .split("/") + .map((part) => encodeURIComponent(part).replaceAll("%40", "@")) + .join("/"); +} + +function resolveClawHubPluginUrl(params: { baseUrl?: string; packageName: string }): string { + return `${resolveClawHubBaseUrl(params.baseUrl)}/plugins/${encodeClawHubPackagePath(params.packageName)}`; +} + +function padRight(value: string, width: number): string { + return `${value}${" ".repeat(Math.max(0, width - visibleWidth(value)))}`; +} + +function formatClawHubReleaseLabel(packageName: string, version: string): string { + return `${sanitizeTerminalText(packageName)}@${sanitizeTerminalText(version)}`; +} + function isMissingArtifactResolverRoute(error: unknown): boolean { return ( error instanceof ClawHubRequestError && @@ -384,6 +434,32 @@ function formatClawHubClawPackDownloadError(params: { return `ClawHub artifact download for "${params.packageName}@${params.version}" is not available yet (${message}). Use "npm:${params.packageName}@${params.version}" for launch installs while ClawHub artifact routing is being rolled out.`; } +function isClawHubArtifactDownloadPolicyBlock(error: unknown): boolean { + if (!(error instanceof ClawHubRequestError)) { + return false; + } + const body = normalizeLowercaseStringOrEmpty(error.responseBody); + return ( + body.includes("blocked from download") || + body.includes("download disabled") || + body.includes("disabled download") || + body.includes("cannot be downloaded") || + body.includes("flagged as malicious") || + body.includes("malicious") || + body.includes("quarantined") || + body.includes("quarantine") || + body.includes("revoked") + ); +} + +function formatClawHubArtifactDownloadPolicyBlock(params: { + error: unknown; + packageName: string; + version: string; +}): string { + return `ClawHub blocked artifact download for "${params.packageName}@${params.version}"; install was not started. ${formatErrorMessage(params.error)}`; +} + function formatClawHubMissingArtifactMetadataError(params: { packageName: string; version: string; @@ -1043,32 +1119,42 @@ function logClawHubPackageSummary(params: { detail: ClawHubPackageDetail; version: string; compatibility?: ClawHubPackageCompatibility | null; + baseUrl?: string; logger?: PluginInstallLogger; }) { const pkg = params.detail.package; if (!pkg) { return; } - const verification = pkg.verification?.tier ? ` verification=${pkg.verification.tier}` : ""; - params.logger?.info?.( - `ClawHub ${pkg.family} ${pkg.name}@${params.version} channel=${pkg.channel}${verification}`, - ); + const familyLabel = pkg.family === "code-plugin" ? "plugin" : pkg.family; const compatibilityParts = [ params.compatibility?.pluginApiRange - ? `pluginApi=${params.compatibility.pluginApiRange}` + ? `pluginApi ${params.compatibility.pluginApiRange}` : null, params.compatibility?.minGatewayVersion - ? `minGateway=${params.compatibility.minGatewayVersion}` + ? `minGateway ${params.compatibility.minGatewayVersion}` : null, ].filter(Boolean); - if (compatibilityParts.length > 0) { - params.logger?.info?.(`Compatibility: ${compatibilityParts.join(" ")}`); - } - if (pkg.channel !== "official") { - params.logger?.warn?.( - `ClawHub package "${pkg.name}" is ${pkg.channel}; review source and verification before enabling.`, - ); - } + const pluginUrl = sanitizeTerminalText( + resolveClawHubPluginUrl({ baseUrl: params.baseUrl, packageName: pkg.name }), + ); + params.logger?.info?.( + [ + ` ${padRight("Package", 9)} ${formatClawHubReleaseLabel(pkg.name, params.version)}`, + ` ${padRight("Type", 9)} ${familyLabel}`, + compatibilityParts.length > 0 + ? ` ${padRight("Requires", 9)} ${compatibilityParts.join(" · ")}` + : null, + ` ${padRight("ClawHub", 9)} ${formatTerminalLink("view plugin", pluginUrl, { + fallback: pluginUrl, + ...(params.logger?.terminalLinks !== undefined + ? { force: params.logger.terminalLinks } + : {}), + })}`, + ] + .filter((line) => line !== null) + .join("\n"), + ); } export async function installPluginFromClawHub( @@ -1083,6 +1169,8 @@ export async function installPluginFromClawHub( dryRun?: boolean; expectedPluginId?: string; env?: RuntimeVersionEnv; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }, ): Promise< | ({ @@ -1138,6 +1226,32 @@ export async function installPluginFromClawHub( } const expectedClawPackSha256 = resolveClawHubClawPackArtifactSha256(versionState.clawpack); const canonicalPackageName = detail.package?.name ?? parsed.name; + const officialClawHubPackage = detail.package + ? isDefaultOfficialClawHubPackage({ baseUrl: params.baseUrl, pkg: detail.package }) + : false; + logClawHubPackageSummary({ + detail, + version: versionState.version, + compatibility: versionState.compatibility, + baseUrl: params.baseUrl, + logger: params.logger, + }); + const trustResult = officialClawHubPackage + ? null + : await ensureClawHubPackageTrustAcknowledged({ + subject: { kind: "plugin", packageName: canonicalPackageName }, + version: versionState.version, + baseUrl: params.baseUrl, + token: params.token, + timeoutMs: params.timeoutMs, + acknowledgeClawHubRisk: params.acknowledgeClawHubRisk, + onClawHubRisk: params.onClawHubRisk, + logger: params.logger, + mode: params.mode, + }); + if (trustResult && !trustResult.ok) { + return trustResult; + } if (!versionState.verification && !expectedClawPackSha256) { return buildClawHubInstallFailure( formatClawHubMissingArtifactMetadataError({ @@ -1147,17 +1261,12 @@ export async function installPluginFromClawHub( CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_UNAVAILABLE, ); } - logClawHubPackageSummary({ - detail, - version: versionState.version, - compatibility: versionState.compatibility, - logger: params.logger, - }); + const releaseLabel = formatClawHubReleaseLabel(canonicalPackageName, versionState.version); let archive; try { archive = await downloadClawHubPackageArchive({ - name: parsed.name, + name: canonicalPackageName, version: versionState.version, artifact: expectedClawPackSha256 ? "clawpack" : "archive", baseUrl: params.baseUrl, @@ -1165,6 +1274,18 @@ export async function installPluginFromClawHub( timeoutMs: params.timeoutMs, }); } catch (error) { + if (isClawHubArtifactDownloadPolicyBlock(error)) { + return buildClawHubInstallFailure( + formatClawHubArtifactDownloadPolicyBlock({ + error, + packageName: canonicalPackageName, + version: versionState.version, + }), + CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED, + undefined, + versionState.version, + ); + } // Fix-me(clawhub): remove this npm hint once ClawHub ClawPack artifact // routing is live for official package installs. return buildClawHubInstallFailure( @@ -1196,27 +1317,27 @@ export async function installPluginFromClawHub( archive.integrity !== expectedIntegrity ) { return buildClawHubInstallFailure( - `ClawHub ClawPack integrity mismatch for "${parsed.name}@${versionState.version}": expected ${expectedClawPackSha256}, got ${archive.sha256Hex}.`, + `ClawHub ClawPack integrity mismatch for "${releaseLabel}": expected ${expectedClawPackSha256}, got ${archive.sha256Hex}.`, CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH, ); } if (expectedNpmIntegrity && archive.npmIntegrity !== expectedNpmIntegrity) { return buildClawHubInstallFailure( - `ClawHub ClawPack npm integrity mismatch for "${parsed.name}@${versionState.version}": expected ${expectedNpmIntegrity}, got ${archive.npmIntegrity ?? "unknown"}.`, + `ClawHub ClawPack npm integrity mismatch for "${releaseLabel}": expected ${expectedNpmIntegrity}, got ${archive.npmIntegrity ?? "unknown"}.`, CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH, ); } const expectedNpmShasum = resolveClawHubNpmShasum(versionState.clawpack); if (expectedNpmShasum && archive.npmShasum !== expectedNpmShasum) { return buildClawHubInstallFailure( - `ClawHub ClawPack npm shasum mismatch for "${parsed.name}@${versionState.version}": expected ${expectedNpmShasum}, got ${archive.npmShasum ?? "unknown"}.`, + `ClawHub ClawPack npm shasum mismatch for "${releaseLabel}": expected ${expectedNpmShasum}, got ${archive.npmShasum ?? "unknown"}.`, CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH, ); } } else if (versionState.verification?.kind === "archive-integrity") { if (archive.integrity !== versionState.verification.integrity) { return buildClawHubInstallFailure( - `ClawHub archive integrity mismatch for "${parsed.name}@${versionState.version}": expected ${versionState.verification.integrity}, got ${archive.integrity}.`, + `ClawHub archive integrity mismatch for "${releaseLabel}": expected ${versionState.verification.integrity}, got ${archive.integrity}.`, CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH, ); } @@ -1239,18 +1360,19 @@ export async function installPluginFromClawHub( ? ` Validated generated metadata files present in archive: ${fallbackVerification.validatedGeneratedPaths.join(", ")} (JSON parse plus slug/version match only).` : ""; params.logger?.warn?.( - `ClawHub package "${canonicalPackageName}@${versionState.version}" is missing sha256hash; falling back to files[] verification. Validated files: ${validatedPaths}.${validatedGeneratedPaths}`, + `ClawHub package "${releaseLabel}" is missing sha256hash; falling back to files[] verification. Validated files: ${validatedPaths}.${validatedGeneratedPaths}`, ); } const clawhubRegistry = resolveClawHubBaseUrl(params.baseUrl); const clawhubAuthority = isDefaultClawHubBaseUrl(params.baseUrl) ? "openclaw" : "third-party"; params.logger?.info?.( - `Downloading ${detail.package?.family === "bundle-plugin" ? "bundle" : "plugin"} ${parsed.name}@${versionState.version} from ClawHub…`, + `Downloading ${detail.package?.family === "bundle-plugin" ? "bundle" : "plugin"} ${releaseLabel} from ClawHub…`, ); const installResult = await installPluginFromArchive({ archivePath: archive.archivePath, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - trustedSourceLinkedOfficialInstall: isTrustedSourceLinkedOfficialPackage(detail.package!), + trustedSourceLinkedOfficialInstall: + officialClawHubPackage || isTrustedSourceLinkedOfficialPackage(detail.package!), config: params.config, logger: params.logger, mode: params.mode, @@ -1261,7 +1383,12 @@ export async function installPluginFromClawHub( installPolicyRequest: { kind: "plugin-archive", requestedSpecifier: params.spec, - source: { kind: "clawhub", authority: clawhubAuthority, mutable: false, network: true }, + source: { + kind: "clawhub", + authority: officialClawHubPackage ? "official" : clawhubAuthority, + mutable: false, + network: true, + }, }, }); if (!installResult.ok) { @@ -1294,11 +1421,11 @@ export async function installPluginFromClawHub( } return { ...installResult, - packageName: parsed.name, + packageName: canonicalPackageName, clawhub: { source: "clawhub", clawhubUrl: clawhubRegistry, - clawhubPackage: parsed.name, + clawhubPackage: canonicalPackageName, clawhubFamily, clawhubChannel: pkg.channel, version: installResult.version ?? versionState.version, @@ -1308,6 +1435,7 @@ export async function installPluginFromClawHub( resolvedAt: new Date().toISOString(), ...clawpackFields, ...observedClawPackArtifactFields, + ...(trustResult ? trustResult.trustInstallRecordFields : {}), ...(expectedTarballName && !archive.npmTarballName ? { npmTarballName: expectedTarballName } : {}), diff --git a/src/plugins/install-security-scan.runtime.test.ts b/src/plugins/install-security-scan.runtime.test.ts new file mode 100644 index 000000000000..d7799aeb9997 --- /dev/null +++ b/src/plugins/install-security-scan.runtime.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const runInstallPolicyMock = vi.fn(); +const findBlockedManifestDependenciesMock = vi.fn(); +const findBlockedNodeModulesDirectoryMock = vi.fn(); +const findBlockedNodeModulesFileAliasMock = vi.fn(); +const findBlockedPackageDirectoryInPathMock = vi.fn(); +const findBlockedPackageFileAliasInPathMock = vi.fn(); +const getGlobalHookRunnerMock = vi.fn(); + +vi.mock("../security/install-policy.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runInstallPolicy: (...args: unknown[]) => runInstallPolicyMock(...args), + }; +}); + +vi.mock("./dependency-denylist.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findBlockedManifestDependencies: (...args: unknown[]) => + findBlockedManifestDependenciesMock(...args), + findBlockedNodeModulesDirectory: (...args: unknown[]) => + findBlockedNodeModulesDirectoryMock(...args), + findBlockedNodeModulesFileAlias: (...args: unknown[]) => + findBlockedNodeModulesFileAliasMock(...args), + findBlockedPackageDirectoryInPath: (...args: unknown[]) => + findBlockedPackageDirectoryInPathMock(...args), + findBlockedPackageFileAliasInPath: (...args: unknown[]) => + findBlockedPackageFileAliasInPathMock(...args), + }; +}); + +vi.mock("./hook-runner-global.js", () => ({ + getGlobalHookRunner: () => getGlobalHookRunnerMock(), +})); + +const { + evaluateSkillInstallPolicyRuntime, + preflightPluginNpmInstallPolicyRuntime, + scanBundleInstallSourceRuntime, +} = await import("./install-security-scan.runtime.js"); + +function expectOnlyOperatorPolicyRan() { + expect(runInstallPolicyMock).toHaveBeenCalledTimes(1); + expect(findBlockedManifestDependenciesMock).not.toHaveBeenCalled(); + expect(findBlockedNodeModulesDirectoryMock).not.toHaveBeenCalled(); + expect(findBlockedNodeModulesFileAliasMock).not.toHaveBeenCalled(); + expect(findBlockedPackageDirectoryInPathMock).not.toHaveBeenCalled(); + expect(findBlockedPackageFileAliasInPathMock).not.toHaveBeenCalled(); + expect(getGlobalHookRunnerMock).not.toHaveBeenCalled(); +} + +describe("install security scan official bypass", () => { + beforeEach(() => { + runInstallPolicyMock.mockReset(); + findBlockedManifestDependenciesMock.mockReset(); + findBlockedNodeModulesDirectoryMock.mockReset(); + findBlockedNodeModulesFileAliasMock.mockReset(); + findBlockedPackageDirectoryInPathMock.mockReset(); + findBlockedPackageFileAliasInPathMock.mockReset(); + getGlobalHookRunnerMock.mockReset(); + }); + + it("bypasses plugin install friction for bundled OpenClaw sources", async () => { + const result = await scanBundleInstallSourceRuntime({ + logger: {}, + pluginId: "openclaw/kitchen-sink", + sourceDir: "/tmp/openclaw-bundled-plugin", + source: { kind: "bundled", authority: "openclaw", mutable: false, network: false }, + }); + + expect(result).toBeUndefined(); + expectOnlyOperatorPolicyRan(); + }); + + it("bypasses plugin install friction for official ClawHub sources", async () => { + const result = await scanBundleInstallSourceRuntime({ + logger: {}, + pluginId: "@openclaw/matrix", + sourceDir: "/tmp/openclaw-official-clawhub-plugin", + source: { kind: "clawhub", authority: "official", mutable: false, network: true }, + }); + + expect(result).toBeUndefined(); + expectOnlyOperatorPolicyRan(); + }); + + it("bypasses skill install friction for bundled OpenClaw sources", async () => { + const result = await evaluateSkillInstallPolicyRuntime({ + installId: "node", + logger: {}, + origin: { + type: "openclaw-bundled", + skillName: "peekaboo", + installId: "node", + }, + source: { kind: "bundled", authority: "openclaw", mutable: false, network: false }, + skillName: "peekaboo", + sourceDir: "/tmp/openclaw-bundled-skill/peekaboo", + }); + + expect(result).toBeUndefined(); + expectOnlyOperatorPolicyRan(); + }); + + it("runs only operator policy for official immutable npm sources", async () => { + const result = await preflightPluginNpmInstallPolicyRuntime({ + logger: {}, + packageName: "@openclaw/matrix", + requestedSpecifier: "@openclaw/matrix@latest", + source: { kind: "npm", authority: "official", mutable: false, network: true }, + sourcePath: "/tmp/openclaw-official-npm", + sourcePathKind: "directory", + }); + + expect(result).toBeUndefined(); + expectOnlyOperatorPolicyRan(); + }); + + it("lets operator policy block official sources", async () => { + runInstallPolicyMock.mockResolvedValueOnce({ + blocked: { + code: "security_scan_blocked", + reason: "blocked by operator policy", + }, + }); + + const result = await scanBundleInstallSourceRuntime({ + logger: {}, + pluginId: "@openclaw/matrix", + sourceDir: "/tmp/openclaw-official-clawhub-plugin", + source: { kind: "clawhub", authority: "official", mutable: false, network: true }, + }); + + expect(result).toEqual({ + blocked: { + code: "security_scan_blocked", + reason: "blocked by operator policy", + }, + }); + expectOnlyOperatorPolicyRan(); + }); + + it("still runs install policy for mutable workspace skill sources", async () => { + runInstallPolicyMock.mockResolvedValueOnce({ + blocked: { + code: "security_scan_blocked", + reason: "blocked by operator policy", + }, + }); + + const result = await evaluateSkillInstallPolicyRuntime({ + installId: "node", + logger: {}, + origin: { + type: "workspace", + skillName: "local-skill", + installId: "node", + }, + source: { kind: "workspace", authority: "user", mutable: true, network: false }, + skillName: "local-skill", + sourceDir: "/tmp/local-skill", + }); + + expect(result).toEqual({ + blocked: { + code: "security_scan_blocked", + reason: "blocked by operator policy", + }, + }); + expect(runInstallPolicyMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index ced0fc6b8205..3dfd27877c24 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -829,6 +829,25 @@ function resolvePolicySource(params: { return { kind: "local-path", authority: "unknown", mutable: true, network: false }; } +function shouldBypassOpenClawInstallFriction(params: { + source?: InstallPolicySource; + trustedSourceLinkedOfficialInstall?: boolean; +}): boolean { + if (params.trustedSourceLinkedOfficialInstall === true) { + return true; + } + const source = params.source; + if (!source || source.mutable) { + return false; + } + if (source.authority === "official") { + return source.kind === "clawhub" || source.kind === "git" || source.kind === "npm"; + } + return ( + source.authority === "openclaw" && (source.kind === "bundled" || source.kind === "managed") + ); +} + async function runOperatorInstallPolicy(params: { config?: OpenClawConfig; logger: InstallScanLogger; @@ -853,6 +872,7 @@ async function runOperatorInstallPolicy(params: { version?: string; extensions?: string[]; }; + trustedSourceLinkedOfficialInstall?: boolean; }): Promise { const result = await runInstallPolicy({ config: params.config, @@ -897,6 +917,30 @@ export async function scanBundleInstallSourceRuntime( source?: InstallPolicySource; }, ): Promise { + const runPolicy = () => + runOperatorInstallPolicy({ + config: params.config, + logger: params.logger, + origin: { type: "plugin-bundle", ...(params.version ? { version: params.version } : {}) }, + source: + params.source ?? resolvePolicySource({ requestKind: params.requestKind ?? "plugin-dir" }), + sourcePath: params.sourceDir, + sourcePathKind: "directory", + targetName: params.pluginId, + targetType: "plugin", + requestKind: params.requestKind ?? "plugin-dir", + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + plugin: { + contentType: "bundle", + pluginId: params.pluginId, + manifestId: params.pluginId, + ...(params.version ? { version: params.version } : {}), + }, + }); + if (shouldBypassOpenClawInstallFriction({ source: params.source })) { + return await runPolicy(); + } const dependencyBlocked = await scanPluginDependencyDenylist({ logger: params.logger, packageDir: params.sourceDir, @@ -906,26 +950,7 @@ export async function scanBundleInstallSourceRuntime( return dependencyBlocked; } - const policyResult = await runOperatorInstallPolicy({ - config: params.config, - logger: params.logger, - origin: { type: "plugin-bundle", ...(params.version ? { version: params.version } : {}) }, - source: - params.source ?? resolvePolicySource({ requestKind: params.requestKind ?? "plugin-dir" }), - sourcePath: params.sourceDir, - sourcePathKind: "directory", - targetName: params.pluginId, - targetType: "plugin", - requestKind: params.requestKind ?? "plugin-dir", - requestMode: params.mode ?? "install", - requestedSpecifier: params.requestedSpecifier, - plugin: { - contentType: "bundle", - pluginId: params.pluginId, - manifestId: params.pluginId, - ...(params.version ? { version: params.version } : {}), - }, - }); + const policyResult = await runPolicy(); if (policyResult?.blocked) { return policyResult; } @@ -966,8 +991,44 @@ export async function scanPackageInstallSourceRuntime( manifestId?: string; version?: string; source?: InstallPolicySource; + trustedSourceLinkedOfficialInstall?: boolean; }, ): Promise { + const runPolicy = () => + runOperatorInstallPolicy({ + config: params.config, + logger: params.logger, + origin: { + type: "plugin-package", + ...(params.packageName ? { packageName: params.packageName } : {}), + ...(params.version ? { version: params.version } : {}), + }, + source: + params.source ?? resolvePolicySource({ requestKind: params.requestKind ?? "plugin-dir" }), + sourcePath: params.packageDir, + sourcePathKind: "directory", + targetName: params.pluginId, + targetType: "plugin", + requestKind: params.requestKind ?? "plugin-dir", + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + plugin: { + contentType: "package", + pluginId: params.pluginId, + ...(params.packageName ? { packageName: params.packageName } : {}), + ...(params.manifestId ? { manifestId: params.manifestId } : {}), + ...(params.version ? { version: params.version } : {}), + extensions: params.extensions.slice(), + }, + }); + if ( + shouldBypassOpenClawInstallFriction({ + source: params.source, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + }) + ) { + return await runPolicy(); + } const dependencyBlocked = await scanPluginDependencyDenylist({ logger: params.logger, packageDir: params.packageDir, @@ -977,32 +1038,7 @@ export async function scanPackageInstallSourceRuntime( return dependencyBlocked; } - const policyResult = await runOperatorInstallPolicy({ - config: params.config, - logger: params.logger, - origin: { - type: "plugin-package", - ...(params.packageName ? { packageName: params.packageName } : {}), - ...(params.version ? { version: params.version } : {}), - }, - source: - params.source ?? resolvePolicySource({ requestKind: params.requestKind ?? "plugin-dir" }), - sourcePath: params.packageDir, - sourcePathKind: "directory", - targetName: params.pluginId, - targetType: "plugin", - requestKind: params.requestKind ?? "plugin-dir", - requestMode: params.mode ?? "install", - requestedSpecifier: params.requestedSpecifier, - plugin: { - contentType: "package", - pluginId: params.pluginId, - ...(params.packageName ? { packageName: params.packageName } : {}), - ...(params.manifestId ? { manifestId: params.manifestId } : {}), - ...(params.version ? { version: params.version } : {}), - extensions: params.extensions.slice(), - }, - }); + const policyResult = await runPolicy(); if (policyResult?.blocked) { return policyResult; } @@ -1045,6 +1081,34 @@ export async function scanInstalledPackageDependencyTreeRuntime(params: { source?: InstallPolicySource; trustedSourceLinkedOfficialInstall?: boolean; }): Promise { + const requestKind = params.requestKind ?? "plugin-npm"; + const runPolicy = () => + runOperatorInstallPolicy({ + config: params.config, + logger: params.logger, + origin: { type: "plugin-dependency-tree" }, + source: params.source ?? resolvePolicySource({ requestKind }), + sourcePath: params.dependencyScanRootDir ?? params.packageDir, + sourcePathKind: "directory", + targetName: params.pluginId, + targetType: "plugin", + requestKind, + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + plugin: { + contentType: "dependency-tree", + pluginId: params.pluginId, + }, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + }); + if ( + shouldBypassOpenClawInstallFriction({ + source: params.source, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + }) + ) { + return await runPolicy(); + } const scanRoots = await collectInstalledPackageScanRoots({ ...(params.additionalPackageDirs ? { additionalPackageDirs: params.additionalPackageDirs } @@ -1066,24 +1130,7 @@ export async function scanInstalledPackageDependencyTreeRuntime(params: { } } - const requestKind = params.requestKind ?? "plugin-npm"; - return await runOperatorInstallPolicy({ - config: params.config, - logger: params.logger, - origin: { type: "plugin-dependency-tree" }, - source: params.source ?? resolvePolicySource({ requestKind }), - sourcePath: params.dependencyScanRootDir ?? params.packageDir, - sourcePathKind: "directory", - targetName: params.pluginId, - targetType: "plugin", - requestKind, - requestMode: params.mode ?? "install", - requestedSpecifier: params.requestedSpecifier, - plugin: { - contentType: "dependency-tree", - pluginId: params.pluginId, - }, - }); + return await runPolicy(); } export async function scanFileInstallSourceRuntime( @@ -1211,24 +1258,30 @@ export async function evaluateSkillInstallPolicyRuntime(params: { skillName: string; sourceDir: string; }): Promise { - const policyResult = await runOperatorInstallPolicy({ - config: params.config, - logger: params.logger, - origin: params.origin, - source: - params.source ?? resolvePolicySource({ requestKind: "skill-install", origin: params.origin }), - sourcePath: params.sourceDir, - sourcePathKind: "directory", - targetName: params.skillName, - targetType: "skill", - requestKind: "skill-install", - requestMode: params.mode ?? "install", - requestedSpecifier: params.requestedSpecifier, - skill: { - installId: params.installId, - ...(params.installSpec ? { installSpec: params.installSpec } : {}), - }, - }); + const runPolicy = () => + runOperatorInstallPolicy({ + config: params.config, + logger: params.logger, + origin: params.origin, + source: + params.source ?? + resolvePolicySource({ requestKind: "skill-install", origin: params.origin }), + sourcePath: params.sourceDir, + sourcePathKind: "directory", + targetName: params.skillName, + targetType: "skill", + requestKind: "skill-install", + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + skill: { + installId: params.installId, + ...(params.installSpec ? { installSpec: params.installSpec } : {}), + }, + }); + if (shouldBypassOpenClawInstallFriction({ source: params.source })) { + return await runPolicy(); + } + const policyResult = await runPolicy(); if (policyResult?.blocked) { return policyResult; } diff --git a/src/plugins/install-security-scan.ts b/src/plugins/install-security-scan.ts index 04100421966a..9236fd6b0f68 100644 --- a/src/plugins/install-security-scan.ts +++ b/src/plugins/install-security-scan.ts @@ -86,6 +86,7 @@ export async function scanPackageInstallSource( manifestId?: string; version?: string; source?: InstallPolicySource; + trustedSourceLinkedOfficialInstall?: boolean; }, ): Promise { const { scanPackageInstallSourceRuntime } = await loadInstallSecurityScanRuntime(); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 5cb315a56872..305fafa1dc89 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -3093,6 +3093,12 @@ export async function installPluginFromNpmSpec( if (compatibilityError) { return compatibilityError; } + const npmInstallPolicySource = { + kind: "npm", + authority: params.trustedSourceLinkedOfficialInstall ? "official" : "third-party", + mutable: false, + network: true, + } as const; const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({ spec, expectedIntegrity: params.expectedIntegrity, @@ -3162,7 +3168,7 @@ export async function installPluginFromNpmSpec( packageName: parsedSpec.name, ...(expectedPluginId ? { pluginId: expectedPluginId } : {}), requestedSpecifier: spec, - source: { kind: "npm", authority: "third-party", mutable: false, network: true }, + source: npmInstallPolicySource, sourcePath: policyMetadataPath, sourcePathKind: "file", }), @@ -3187,7 +3193,7 @@ export async function installPluginFromNpmSpec( installPolicyRequest: { kind: "plugin-npm", requestedSpecifier: spec, - source: { kind: "npm", authority: "third-party", mutable: false, network: true }, + source: npmInstallPolicySource, }, extensionsDir: params.extensionsDir, npmDir: params.npmDir, diff --git a/src/plugins/installed-plugin-index-install-records.ts b/src/plugins/installed-plugin-index-install-records.ts index f1085ff7cd60..2ff865c7015e 100644 --- a/src/plugins/installed-plugin-index-install-records.ts +++ b/src/plugins/installed-plugin-index-install-records.ts @@ -29,6 +29,31 @@ function setInstallNumberField>( + target: InstalledPluginInstallRecordInfo, + key: Key, + value: PluginInstallRecord[Key], +): void { + if (typeof value === "boolean") { + target[key] = value as InstalledPluginInstallRecordInfo[Key]; + } +} + +function setInstallStringArrayField< + Key extends keyof Omit, +>(target: InstalledPluginInstallRecordInfo, key: Key, value: PluginInstallRecord[Key]): void { + if (!Array.isArray(value)) { + return; + } + const normalized = value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (normalized.length > 0) { + target[key] = normalized as InstalledPluginInstallRecordInfo[Key]; + } +} + function normalizeInstallRecord( record: PluginInstallRecord | undefined, ): InstalledPluginInstallRecordInfo | undefined { @@ -53,6 +78,22 @@ function normalizeInstallRecord( setInstallStringField(normalized, "clawhubPackage", record.clawhubPackage); setInstallStringField(normalized, "clawhubFamily", record.clawhubFamily); setInstallStringField(normalized, "clawhubChannel", record.clawhubChannel); + setInstallStringField(normalized, "clawhubTrustDisposition", record.clawhubTrustDisposition); + setInstallStringField(normalized, "clawhubTrustScanStatus", record.clawhubTrustScanStatus); + setInstallStringField( + normalized, + "clawhubTrustModerationState", + record.clawhubTrustModerationState, + ); + setInstallStringArrayField(normalized, "clawhubTrustReasons", record.clawhubTrustReasons); + setInstallBooleanField(normalized, "clawhubTrustPending", record.clawhubTrustPending); + setInstallBooleanField(normalized, "clawhubTrustStale", record.clawhubTrustStale); + setInstallStringField(normalized, "clawhubTrustCheckedAt", record.clawhubTrustCheckedAt); + setInstallStringField( + normalized, + "clawhubTrustAcknowledgedAt", + record.clawhubTrustAcknowledgedAt, + ); setInstallStringField(normalized, "artifactKind", record.artifactKind); setInstallStringField(normalized, "artifactFormat", record.artifactFormat); setInstallStringField(normalized, "npmIntegrity", record.npmIntegrity); diff --git a/src/plugins/installed-plugin-index-records.test.ts b/src/plugins/installed-plugin-index-records.test.ts index ec7eb05f25eb..a95326834986 100644 --- a/src/plugins/installed-plugin-index-records.test.ts +++ b/src/plugins/installed-plugin-index-records.test.ts @@ -594,6 +594,12 @@ describe("plugin index install records store", () => { clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", clawpackSize: 4096, + clawhubTrustDisposition: "review-required", + clawhubTrustScanStatus: "suspicious", + clawhubTrustReasons: ["payload_strings"], + clawhubTrustPending: true, + clawhubTrustCheckedAt: "2026-05-14T18:00:00.000Z", + clawhubTrustAcknowledgedAt: "2026-05-14T18:00:03.000Z", }, }, { stateDir, candidates: [candidate] }, @@ -612,6 +618,12 @@ describe("plugin index install records store", () => { clawpackSpecVersion: 1, clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", clawpackSize: 4096, + clawhubTrustDisposition: "review-required", + clawhubTrustScanStatus: "suspicious", + clawhubTrustReasons: ["payload_strings"], + clawhubTrustPending: true, + clawhubTrustCheckedAt: "2026-05-14T18:00:00.000Z", + clawhubTrustAcknowledgedAt: "2026-05-14T18:00:03.000Z", }); }); diff --git a/src/plugins/installed-plugin-index-types.ts b/src/plugins/installed-plugin-index-types.ts index e22f1b9c3a6d..e2bfa302c722 100644 --- a/src/plugins/installed-plugin-index-types.ts +++ b/src/plugins/installed-plugin-index-types.ts @@ -68,6 +68,14 @@ export type InstalledPluginInstallRecordInfo = Pick< | "clawhubPackage" | "clawhubFamily" | "clawhubChannel" + | "clawhubTrustDisposition" + | "clawhubTrustScanStatus" + | "clawhubTrustModerationState" + | "clawhubTrustReasons" + | "clawhubTrustPending" + | "clawhubTrustStale" + | "clawhubTrustCheckedAt" + | "clawhubTrustAcknowledgedAt" | "artifactKind" | "artifactFormat" | "npmIntegrity" diff --git a/src/plugins/installs.test.ts b/src/plugins/installs.test.ts index ac8ea5c82438..f699c2ddc65e 100644 --- a/src/plugins/installs.test.ts +++ b/src/plugins/installs.test.ts @@ -146,4 +146,50 @@ describe("recordPluginInstall", () => { expectRecordedInstall("demo", next); }); + + it("clears stale ClawHub trust metadata when a later install omits it", () => { + const existing = recordPluginInstall( + {}, + { + pluginId: "demo", + source: "clawhub", + spec: "clawhub:demo@1.0.0", + installPath: "/tmp/openclaw/plugins/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubTrustDisposition: "review-required", + clawhubTrustScanStatus: "suspicious", + clawhubTrustReasons: ["payload_strings"], + clawhubTrustPending: true, + clawhubTrustCheckedAt: "2026-05-14T18:00:00.000Z", + clawhubTrustAcknowledgedAt: "2026-05-14T18:00:03.000Z", + }, + ); + + const next = recordPluginInstall(existing, { + pluginId: "demo", + source: "clawhub", + spec: "clawhub:demo@1.1.0", + installPath: "/tmp/openclaw/plugins/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubTrustDisposition: "clean", + clawhubTrustCheckedAt: "2026-05-15T00:00:00.000Z", + installedAt: "2026-05-15T00:00:03.000Z", + }); + + expect(next.plugins?.installs?.demo).toEqual({ + source: "clawhub", + spec: "clawhub:demo@1.1.0", + installPath: "/tmp/openclaw/plugins/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubTrustDisposition: "clean", + clawhubTrustCheckedAt: "2026-05-15T00:00:00.000Z", + installedAt: "2026-05-15T00:00:03.000Z", + }); + }); }); diff --git a/src/plugins/installs.ts b/src/plugins/installs.ts index bacdf38faa9a..73a4f7bb934a 100644 --- a/src/plugins/installs.ts +++ b/src/plugins/installs.ts @@ -7,6 +7,17 @@ import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; /** Plugin install record update with the target plugin id attached. */ export type PluginInstallUpdate = PluginInstallRecord & { pluginId: string }; +const CLAWHUB_TRUST_INSTALL_RECORD_FIELDS = [ + "clawhubTrustDisposition", + "clawhubTrustScanStatus", + "clawhubTrustModerationState", + "clawhubTrustReasons", + "clawhubTrustPending", + "clawhubTrustStale", + "clawhubTrustCheckedAt", + "clawhubTrustAcknowledgedAt", +] as const satisfies readonly (keyof PluginInstallRecord)[]; + /** Builds install record fields from resolved npm package metadata. */ export function buildNpmResolutionInstallFields( resolution?: NpmSpecResolution, @@ -40,10 +51,11 @@ export function recordPluginInstall( update: PluginInstallUpdate, ): OpenClawConfig { const { pluginId, ...record } = update; + const previous = clearStaleInstallRecordFields(cfg.plugins?.installs?.[pluginId]); const installs = { ...cfg.plugins?.installs, [pluginId]: { - ...cfg.plugins?.installs?.[pluginId], + ...previous, ...record, installedAt: record.installedAt ?? new Date().toISOString(), }, @@ -60,3 +72,14 @@ export function recordPluginInstall( }, }; } + +function clearStaleInstallRecordFields(record: PluginInstallRecord | undefined) { + if (!record) { + return undefined; + } + const next: PluginInstallRecord = { ...record }; + for (const field of CLAWHUB_TRUST_INSTALL_RECORD_FIELDS) { + delete next[field]; + } + return next; +} diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 8a54f026d7e5..b86641a44c63 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -66,6 +66,9 @@ vi.mock("./clawhub.js", () => ({ ARTIFACT_UNAVAILABLE: "artifact_unavailable", ARCHIVE_INTEGRITY_MISMATCH: "archive_integrity_mismatch", ARTIFACT_DOWNLOAD_UNAVAILABLE: "artifact_download_unavailable", + CLAWHUB_SECURITY_UNAVAILABLE: "clawhub_security_unavailable", + CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED: "clawhub_risk_acknowledgement_required", + CLAWHUB_DOWNLOAD_BLOCKED: "clawhub_download_blocked", }, installPluginFromClawHub: (...args: unknown[]) => installPluginFromClawHubMock(...args), })); @@ -217,6 +220,35 @@ function createClawHubInstallConfig(params: { }; } +function createEnabledDemoClawHubInstallConfig(): OpenClawConfig { + const installPath = createInstalledPackageDir({ + name: "demo", + version: "1.2.3", + }); + const config = createClawHubInstallConfig({ + pluginId: "demo", + installPath, + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }); + config.plugins = { + ...config.plugins, + entries: { + demo: { + enabled: true, + config: { preserved: true }, + }, + }, + allow: ["demo"], + slots: { + memory: "demo", + }, + }; + return config; +} + function createGitInstallConfig(params: { pluginId: string; spec: string; @@ -2578,6 +2610,12 @@ describe("updateNpmInstalledPlugins", () => { installPath: "/tmp/demo", }, }, + allow: ["demo", "other"], + deny: ["blocked"], + slots: { + memory: "demo", + contextEngine: "demo", + }, }, } satisfies OpenClawConfig; @@ -2598,6 +2636,12 @@ describe("updateNpmInstalledPlugins", () => { enabled: false, config: { preserved: true }, }); + expect(result.config.plugins?.allow).toEqual(["other"]); + expect(result.config.plugins?.deny).toEqual(["blocked"]); + expect(result.config.plugins?.slots).toEqual({ + memory: "memory-core", + contextEngine: "legacy", + }); expect(result.config.plugins?.installs?.demo).toEqual(config.plugins.installs.demo); expect(result.outcomes).toEqual([ { @@ -2608,55 +2652,249 @@ describe("updateNpmInstalledPlugins", () => { ]); }); - it("clears stale plugin policy and slot references when disabling failed updates", async () => { - const warn = vi.fn(); - installPluginFromNpmSpecMock.mockResolvedValue({ + it("keeps an existing ClawHub plugin enabled when a risky update is not acknowledged", async () => { + installPluginFromClawHubMock.mockResolvedValue({ ok: false, - error: "security scan blocked install", + code: "clawhub_risk_acknowledgement_required", + error: + "Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.", + warning: + "╭─ WARNING - ClawHub found security risks in this release ─╮\n│ • Finding: suspicious payload strings │\n╰───────────────────────────────────────────────────────────────────────╯", }); - const config = { - plugins: { - allow: ["demo", "keep"], - deny: ["demo", "blocked"], - slots: { - memory: "demo", - contextEngine: "demo", - }, - entries: { - demo: { - enabled: true, - }, - }, - installs: { - demo: { - source: "npm" as const, - spec: "@acme/demo", - installPath: "/tmp/demo", - }, - }, - }, - } satisfies OpenClawConfig; + const config = createEnabledDemoClawHubInstallConfig(); const result = await updateNpmInstalledPlugins({ config, + pluginIds: ["demo"], + disableOnFailure: true, + }); + + expect(clawHubInstallCall()?.spec).toBe("clawhub:demo"); + expect(result.changed).toBe(false); + expect(result.config).toBe(config); + expect(result.config.plugins?.entries?.demo).toEqual({ + enabled: true, + config: { preserved: true }, + }); + expect(result.config.plugins?.allow).toEqual(["demo"]); + expect(result.config.plugins?.slots?.memory).toBe("demo"); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_risk_acknowledgement_required", + currentVersion: "1.2.3", + warning: + "╭─ WARNING - ClawHub found security risks in this release ─╮\n│ • Finding: suspicious payload strings │\n╰───────────────────────────────────────────────────────────────────────╯", + message: + "Skipped demo ClawHub update: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. Existing installed plugin left unchanged.", + }, + ]); + }); + + it("does not skip a risk-gated ClawHub update when the installed package is missing", async () => { + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: + "Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.", + warning: + "╭─ WARNING - ClawHub found security risks in this release ─╮\n│ • Finding: suspicious payload strings │\n╰───────────────────────────────────────────────────────────────────────╯", + }); + const installPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-missing-")); + tempDirs.push(installPath); + const config = createClawHubInstallConfig({ + pluginId: "demo", + installPath, + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }); + config.plugins = { + ...config.plugins, + entries: { + demo: { + enabled: true, + config: { preserved: true }, + }, + }, + allow: ["demo"], + slots: { + memory: "demo", + }, + }; + + const result = await updateNpmInstalledPlugins({ + config, + pluginIds: ["demo"], + disableOnFailure: true, + }); + + expect(clawHubInstallCall()?.spec).toBe("clawhub:demo"); + const message = + 'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo).'; + expect(result.changed).toBe(true); + expect(result.config.plugins?.entries?.demo).toEqual({ + enabled: false, + config: { preserved: true }, + }); + expect(result.config.plugins?.allow).toBeUndefined(); + expect(result.config.plugins?.slots?.memory).toBe("memory-core"); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "skipped", + message, + }, + ]); + }); + + it("keeps an existing ClawHub plugin enabled when a newer target release is blocked", async () => { + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_download_blocked", + version: "1.2.4", + error: "ClawHub blocked this release; update was not started.", + warning: + "╭─ BLOCKED - ClawHub flagged this release as malicious ─╮\n│ • Security scan: malicious │\n╰────────────────────────────────────────────────────────╯", + }); + const config = createEnabledDemoClawHubInstallConfig(); + + const result = await updateNpmInstalledPlugins({ + config, + pluginIds: ["demo"], + disableOnFailure: true, + }); + + expect(clawHubInstallCall()?.spec).toBe("clawhub:demo"); + expect(result.changed).toBe(false); + expect(result.config).toBe(config); + expect(result.config.plugins?.entries?.demo).toEqual({ + enabled: true, + config: { preserved: true }, + }); + expect(result.config.plugins?.allow).toEqual(["demo"]); + expect(result.config.plugins?.slots?.memory).toBe("demo"); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_download_blocked", + currentVersion: "1.2.3", + warning: + "╭─ BLOCKED - ClawHub flagged this release as malicious ─╮\n│ • Security scan: malicious │\n╰────────────────────────────────────────────────────────╯", + message: + "Skipped demo ClawHub update: ClawHub blocked this release; update was not started. Existing installed plugin left unchanged.", + }, + ]); + }); + + it("keeps an existing ClawHub plugin enabled when newer target security data is unavailable", async () => { + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_security_unavailable", + version: "1.2.4", + error: + 'ClawHub release "demo@1.2.4" could not be checked because ClawHub security data is unavailable. Try again later or choose a different version.', + }); + const config = createEnabledDemoClawHubInstallConfig(); + + const result = await updateNpmInstalledPlugins({ + config, + pluginIds: ["demo"], + disableOnFailure: true, + }); + + expect(clawHubInstallCall()?.spec).toBe("clawhub:demo"); + expect(result.changed).toBe(false); + expect(result.config).toBe(config); + expect(result.config.plugins?.entries?.demo).toEqual({ + enabled: true, + config: { preserved: true }, + }); + expect(result.config.plugins?.allow).toEqual(["demo"]); + expect(result.config.plugins?.slots?.memory).toBe("demo"); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_security_unavailable", + currentVersion: "1.2.3", + message: + 'Skipped demo ClawHub update: ClawHub release "demo@1.2.4" could not be checked because ClawHub security data is unavailable. Try again later or choose a different version. Existing installed plugin left unchanged.', + }, + ]); + }); + + it("keeps an existing ClawHub plugin enabled when current target security data is unavailable", async () => { + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_security_unavailable", + version: "1.2.3", + error: + 'ClawHub release "demo@1.2.3" could not be checked because ClawHub security data is unavailable. Try again later or choose a different version.', + }); + const config = createEnabledDemoClawHubInstallConfig(); + + const result = await updateNpmInstalledPlugins({ + config, + pluginIds: ["demo"], + disableOnFailure: true, + }); + + expect(clawHubInstallCall()?.spec).toBe("clawhub:demo"); + expect(result.changed).toBe(false); + expect(result.config).toBe(config); + expect(result.config.plugins?.entries?.demo).toEqual({ + enabled: true, + config: { preserved: true }, + }); + expect(result.config.plugins?.allow).toEqual(["demo"]); + expect(result.config.plugins?.slots?.memory).toBe("demo"); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_security_unavailable", + currentVersion: "1.2.3", + message: + 'Skipped demo ClawHub update: ClawHub release "demo@1.2.3" could not be checked because ClawHub security data is unavailable. Try again later or choose a different version. Existing installed plugin left unchanged.', + }, + ]); + }); + + it("disables an existing ClawHub plugin when its current release is blocked", async () => { + const warn = vi.fn(); + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_download_blocked", + version: "1.2.3", + error: "ClawHub blocked this release; update was not started.", + warning: + "╭─ BLOCKED - ClawHub flagged this release as malicious ─╮\n│ • Security scan: malicious │\n╰────────────────────────────────────────────────────────╯", + }); + const config = createEnabledDemoClawHubInstallConfig(); + + const result = await updateNpmInstalledPlugins({ + config, + pluginIds: ["demo"], disableOnFailure: true, logger: { warn }, }); - const message = - 'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: security scan blocked install'; - expect(warn).toHaveBeenCalledWith(message); expect(result.changed).toBe(true); expect(result.config.plugins?.entries?.demo).toEqual({ enabled: false, + config: { preserved: true }, }); - expect(result.config.plugins?.installs?.demo).toEqual(config.plugins.installs.demo); - expect(result.config.plugins?.allow).toEqual(["keep"]); - expect(result.config.plugins?.deny).toEqual(["blocked"]); + expect(result.config.plugins?.allow).toBeUndefined(); expect(result.config.plugins?.slots).toEqual({ memory: "memory-core", - contextEngine: "legacy", }); + const message = + 'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: ClawHub blocked this release; update was not started. (ClawHub clawhub:demo).'; + expect(warn).toHaveBeenCalledWith(message); expect(result.outcomes).toEqual([ { pluginId: "demo", @@ -3321,7 +3559,7 @@ describe("updateNpmInstalledPlugins", () => { ); }); - it("falls back to npm for trusted official ClawHub artifact blocks", async () => { + it("does not fall back to npm for blocked official ClawHub artifact downloads", async () => { const warnMessages: string[] = []; const installPath = createInstalledPackageDir({ name: "@openclaw/discord", @@ -3329,22 +3567,11 @@ describe("updateNpmInstalledPlugins", () => { }); installPluginFromClawHubMock.mockResolvedValueOnce({ ok: false, - code: "artifact_unavailable", + code: "clawhub_download_blocked", error: - 'ClawHub artifact download for "@openclaw/discord@2026.5.16-beta.5" is not available yet (ClawHub /api/v1/packages/%40openclaw%2Fdiscord/versions/2026.5.16-beta.5/artifact/download failed (403): Blocked: this package release has been flagged as malicious and cannot be downloaded.). Use "npm:@openclaw/discord@2026.5.16-beta.5" for launch installs while ClawHub artifact routing is being rolled out.', + 'ClawHub blocked artifact download for "@openclaw/discord@2026.5.16-beta.5"; install was not started. ClawHub /api/v1/packages/%40openclaw%2Fdiscord/versions/2026.5.16-beta.5/artifact/download failed (403): Blocked: this package release has been flagged as malicious and cannot be downloaded.', + version: "2026.5.16-beta.5", }); - installPluginFromNpmSpecMock.mockResolvedValueOnce( - createSuccessfulNpmUpdateResult({ - pluginId: "discord", - targetDir: "/tmp/openclaw-plugins/discord", - version: "2026.5.16-beta.5", - npmResolution: { - name: "@openclaw/discord", - version: "2026.5.16-beta.5", - resolvedSpec: "@openclaw/discord@2026.5.16-beta.5", - }, - }), - ); const result = await updateNpmInstalledPlugins({ config: createClawHubInstallConfig({ @@ -3363,32 +3590,25 @@ describe("updateNpmInstalledPlugins", () => { }); expect(clawHubInstallCall()?.spec).toBe("clawhub:@openclaw/discord@beta"); - expect(npmInstallCall()?.spec).toBe("@openclaw/discord@beta"); - expect(npmInstallCall()?.expectedPluginId).toBe("discord"); - expect(npmInstallCall()?.trustedSourceLinkedOfficialInstall).toBe(true); + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); expect(result.config.plugins?.entries?.discord?.enabled).toBeUndefined(); expectRecordFields(result.config.plugins?.installs?.discord, { - source: "npm", - spec: "@openclaw/discord@2026.5.16-beta.5", - installPath: "/tmp/openclaw-plugins/discord", - version: "2026.5.16-beta.5", + source: "clawhub", + spec: "clawhub:@openclaw/discord", + installPath, + clawhubPackage: "@openclaw/discord", }); - expect(result.config.plugins?.installs?.discord?.clawhubPackage).toBeUndefined(); - expect(result.config.plugins?.installs?.discord?.clawhubUrl).toBeUndefined(); - expect(result.config.plugins?.installs?.discord?.artifactKind).toBeUndefined(); expect(result.outcomes).toEqual([ { pluginId: "discord", - status: "updated", + status: "skipped", + code: "clawhub_download_blocked", currentVersion: "2026.5.12", - nextVersion: "2026.5.16-beta.5", message: - "Updated discord: 2026.5.12 -> 2026.5.16-beta.5. (warning: official ClawHub artifact fallback used @openclaw/discord@beta).", + 'Skipped discord ClawHub update: ClawHub blocked artifact download for "@openclaw/discord@2026.5.16-beta.5"; install was not started. ClawHub /api/v1/packages/%40openclaw%2Fdiscord/versions/2026.5.16-beta.5/artifact/download failed (403): Blocked: this package release has been flagged as malicious and cannot be downloaded. Existing installed plugin left unchanged.', }, ]); - expect(warnMessages).toEqual([ - 'Plugin "discord" could not download official ClawHub artifact for clawhub:@openclaw/discord@beta; using npm @openclaw/discord@beta instead. Core update can still complete.', - ]); + expect(warnMessages).toStrictEqual([]); }); it("uses the default npm spec when beta ClawHub falls back before an artifact block", async () => { @@ -3705,6 +3925,57 @@ describe("updateNpmInstalledPlugins", () => { }); }); + it("forwards ClawHub risk acknowledgement inputs without dry-run prompts", async () => { + const onClawHubRisk = vi.fn(async () => true); + const config = createClawHubInstallConfig({ + pluginId: "demo", + installPath: "/tmp/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }); + installPluginFromClawHubMock.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: "/tmp/demo", + version: "1.2.4", + clawhub: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + integrity: "sha256-next", + resolvedAt: "2026-03-22T00:00:00.000Z", + }, + }); + + for (const dryRun of [true, false]) { + installPluginFromClawHubMock.mockClear(); + + await updateNpmInstalledPlugins({ + config, + pluginIds: ["demo"], + acknowledgeClawHubRisk: true, + onClawHubRisk, + ...(dryRun ? { dryRun: true } : {}), + }); + + expect(installPluginFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo", + acknowledgeClawHubRisk: true, + ...(dryRun ? { dryRun: true } : {}), + ...(!dryRun ? { onClawHubRisk } : {}), + }), + ); + if (dryRun) { + expect(clawHubInstallCall()?.onClawHubRisk).toBeUndefined(); + } + } + }); + it("migrates legacy unscoped install keys when a scoped npm package updates", async () => { installPluginFromNpmSpecMock.mockResolvedValue({ ok: true, @@ -4283,9 +4554,12 @@ describe("syncPluginsForUpdateChannel", () => { clawhubPackage: "legacy-chat", }), ); + const onClawHubRisk = vi.fn(async () => true); const result = await syncPluginsForUpdateChannel({ channel: "stable", + acknowledgeClawHubRisk: true, + onClawHubRisk, externalizedBundledPluginBridges: [ { bundledPluginId: "legacy-chat", @@ -4319,6 +4593,8 @@ describe("syncPluginsForUpdateChannel", () => { expect(clawHubInstallCall()?.baseUrl).toBe("https://clawhub.ai"); expect(clawHubInstallCall()?.mode).toBe("update"); expect(clawHubInstallCall()?.expectedPluginId).toBe("legacy-chat"); + expect(clawHubInstallCall()?.acknowledgeClawHubRisk).toBe(true); + expect(clawHubInstallCall()?.onClawHubRisk).toBe(onClawHubRisk); expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); expect(result.changed).toBe(true); expect(result.summary.switchedToClawHub).toEqual(["legacy-chat"]); @@ -4569,6 +4845,7 @@ describe("syncPluginsForUpdateChannel", () => { ok: false, code: "archive_integrity_mismatch", error: "ClawHub ClawPack integrity mismatch.", + warning: "WARNING\nSecurity scan: suspicious", }); const config: OpenClawConfig = { channels: { @@ -4605,6 +4882,7 @@ describe("syncPluginsForUpdateChannel", () => { expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); expect(result.changed).toBe(false); expect(result.config).toBe(config); + expect(result.summary.warnings).toEqual(["WARNING\nSecurity scan: suspicious"]); expect(result.summary.errors).toEqual([ "Failed to update legacy-chat: ClawHub ClawPack integrity mismatch. (ClawHub clawhub:legacy-chat@2026.5.1-beta.2).", ]); diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 39f1105d6e56..26590eeccda8 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; +import type { ClawHubTrustErrorCode } from "../infra/clawhub-install-trust.js"; import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; import { satisfiesPluginApiRange } from "../infra/clawhub.js"; import { unscopedPackageName } from "../infra/install-safe-path.js"; @@ -28,8 +29,9 @@ import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; import { resolveCompatibilityHostVersion } from "../version.js"; import { resolveBundledPluginSources } from "./bundled-sources.js"; +import { CLAWHUB_INSTALL_ERROR_CODE } from "./clawhub-error-codes.js"; import { buildClawHubPluginInstallRecordFields } from "./clawhub-install-records.js"; -import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "./clawhub.js"; +import { installPluginFromClawHub, type ClawHubRiskAcknowledgementRequest } from "./clawhub.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { getExternalizedBundledPluginLegacyPathSuffix, @@ -74,6 +76,7 @@ export type PluginUpdateLogger = { info?: (message: string) => void; warn?: (message: string) => void; error?: (message: string) => void; + terminalLinks?: boolean; }; /** Outcome status for one plugin update attempt. */ @@ -88,15 +91,25 @@ export type PluginUpdateChannelFallback = { message: string; }; -export type PluginUpdateOutcome = { +type BasePluginUpdateOutcome = { pluginId: string; - status: PluginUpdateStatus; message: string; currentVersion?: string; nextVersion?: string; channelFallback?: PluginUpdateChannelFallback; + warning?: string; }; +export type PluginUpdateOutcome = + | (BasePluginUpdateOutcome & { + status: "skipped"; + code?: ClawHubTrustErrorCode; + }) + | (BasePluginUpdateOutcome & { + status: Exclude; + code?: string; + }); + export type PluginUpdateSummary = { config: OpenClawConfig; changed: boolean; @@ -198,6 +211,76 @@ function formatClawHubInstallFailure(params: { return `Failed to ${params.phase} ${params.pluginId}: ${params.error} (ClawHub ${params.spec}).`; } +function isClawHubRiskAcknowledgementRequired(result: { ok: false; code?: string }): boolean { + return result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED; +} + +function isClawHubDownloadBlocked(result: { ok: false; code?: string }): boolean { + return result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED; +} + +function isClawHubSecurityUnavailable(result: { ok: false; code?: string }): boolean { + return result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE; +} + +function readClawHubTrustErrorCode(result: { code?: string }): ClawHubTrustErrorCode | undefined { + if ( + result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED || + result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED || + result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE + ) { + return result.code; + } + return undefined; +} + +function shouldSkipClawHubTrustFailureForExistingInstall(params: { + result: { ok: false; code?: string; version?: string }; + currentVersion: string | undefined; +}): boolean { + if (isClawHubRiskAcknowledgementRequired(params.result)) { + return Boolean(params.currentVersion); + } + if (isClawHubSecurityUnavailable(params.result)) { + return Boolean(params.currentVersion); + } + if (!isClawHubDownloadBlocked(params.result)) { + return false; + } + return Boolean( + params.result.version && + params.currentVersion && + params.result.version !== params.currentVersion, + ); +} + +function buildClawHubTrustSkippedOutcome(params: { + pluginId: string; + phase: "check" | "update"; + error: string; + code: ClawHubTrustErrorCode; + warning?: string; + currentVersion?: string; +}): PluginUpdateOutcome { + return { + pluginId: params.pluginId, + status: "skipped", + ...(params.code ? { code: params.code } : {}), + ...(params.currentVersion ? { currentVersion: params.currentVersion } : {}), + ...(params.warning ? { warning: params.warning } : {}), + message: `Skipped ${params.pluginId} ClawHub ${params.phase}: ${params.error} Existing installed plugin left unchanged.`, + }; +} + +export function isClawHubTrustSkippedOutcome(outcome: { status: string; code?: string }): boolean { + return ( + outcome.status === "skipped" && + (outcome.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED || + outcome.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED || + outcome.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE) + ); +} + function formatGitInstallFailure(params: { pluginId: string; spec: string; @@ -1242,6 +1325,8 @@ export async function updateNpmInstalledPlugins(params: { dangerouslyForceUnsafeInstall?: boolean; specOverrides?: Record; onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }): Promise { const logger = params.logger ?? {}; const installs = params.config.plugins?.installs ?? {}; @@ -1260,6 +1345,10 @@ export async function updateNpmInstalledPlugins(params: { ranNpmInstaller = true; return await installPluginFromNpmSpec(installParams); }; + const clawHubRiskAcknowledgementOptions = { + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(!params.dryRun && params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), + }; const recordFailure = ( pluginId: string, @@ -1633,6 +1722,7 @@ export async function updateNpmInstalledPlugins(params: { dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, + ...clawHubRiskAcknowledgementOptions, logger, }) : record.source === "git" @@ -1733,6 +1823,7 @@ export async function updateNpmInstalledPlugins(params: { dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, + ...clawHubRiskAcknowledgementOptions, logger, }); activeClawHubInstallSpec = clawhubSpecs.fallbackSpec; @@ -1769,6 +1860,29 @@ export async function updateNpmInstalledPlugins(params: { }); } if (!probe.ok) { + if ( + record.source === "clawhub" && + shouldSkipClawHubTrustFailureForExistingInstall({ + result: probe, + currentVersion, + }) + ) { + const code = readClawHubTrustErrorCode(probe); + if (!code) { + continue; + } + outcomes.push( + buildClawHubTrustSkippedOutcome({ + pluginId, + phase: "check", + error: probe.error, + code, + ...("warning" in probe && probe.warning ? { warning: probe.warning } : {}), + ...(currentVersion ? { currentVersion } : {}), + }), + ); + continue; + } recordFailure( pluginId, record.source === "npm" || usedOfficialNpmFallback @@ -1894,6 +2008,7 @@ export async function updateNpmInstalledPlugins(params: { timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, + ...clawHubRiskAcknowledgementOptions, logger, }) : record.source === "git" @@ -1991,6 +2106,7 @@ export async function updateNpmInstalledPlugins(params: { timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, + ...clawHubRiskAcknowledgementOptions, logger, }); activeClawHubInstallSpec = clawhubSpecs.fallbackSpec; @@ -2028,6 +2144,29 @@ export async function updateNpmInstalledPlugins(params: { }); } if (!result.ok) { + if ( + record.source === "clawhub" && + shouldSkipClawHubTrustFailureForExistingInstall({ + result, + currentVersion, + }) + ) { + const code = readClawHubTrustErrorCode(result); + if (!code) { + continue; + } + outcomes.push( + buildClawHubTrustSkippedOutcome({ + pluginId, + phase: "update", + error: result.error, + code, + ...("warning" in result && result.warning ? { warning: result.warning } : {}), + ...(currentVersion ? { currentVersion } : {}), + }), + ); + continue; + } recordFailure( pluginId, resultSource === "npm" @@ -2183,6 +2322,8 @@ export async function syncPluginsForUpdateChannel(params: { env?: NodeJS.ProcessEnv; logger?: PluginUpdateLogger; externalizedBundledPluginBridges?: readonly ExternalizedBundledPluginBridge[]; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }): Promise { const env = params.env ?? process.env; const logger = params.logger ?? {}; @@ -2202,6 +2343,10 @@ export async function syncPluginsForUpdateChannel(params: { const loadHelpers = buildLoadPathHelpers(next.plugins?.load?.paths ?? [], env); let installs = next.plugins?.installs ?? {}; let changed = false; + const clawHubRiskAcknowledgementOptions = { + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), + }; if (params.channel === "dev") { for (const [pluginId, record] of Object.entries(installs)) { @@ -2315,6 +2460,7 @@ export async function syncPluginsForUpdateChannel(params: { ...(bridge.clawhubUrl ? { baseUrl: bridge.clawhubUrl } : {}), mode: "update", expectedPluginId: targetPluginId, + ...clawHubRiskAcknowledgementOptions, logger, }); if (!result.ok && npmSpec && shouldFallbackClawHubBridgeToNpm({ result, npmSpec })) { @@ -2344,6 +2490,16 @@ export async function syncPluginsForUpdateChannel(params: { } if (!result.ok) { + const clawHubTrustWarning = + installSource === "clawhub" && + "warning" in result && + typeof result.warning === "string" && + result.warning.trim().length > 0 + ? result.warning + : null; + if (clawHubTrustWarning) { + summary.warnings.push(clawHubTrustWarning); + } const message = installSource === "clawhub" ? formatClawHubInstallFailure({ diff --git a/src/skills/lifecycle/clawhub.test.ts b/src/skills/lifecycle/clawhub.test.ts index ae029a404bde..a77ceb03f578 100644 --- a/src/skills/lifecycle/clawhub.test.ts +++ b/src/skills/lifecycle/clawhub.test.ts @@ -9,6 +9,7 @@ import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; const fetchClawHubSkillDetailMock = vi.fn(); const fetchClawHubSkillInstallResolutionMock = vi.fn(); const fetchClawHubSkillVerificationMock = vi.fn(); +const fetchClawHubSkillSecurityVerdictsMock = vi.fn(); const downloadClawHubSkillArchiveMock = vi.fn(); const downloadClawHubSkillArchiveUrlMock = vi.fn(); const downloadClawHubGitHubSkillArchiveMock = vi.fn(); @@ -27,6 +28,7 @@ vi.mock("../../infra/clawhub.js", () => ({ fetchClawHubSkillDetail: fetchClawHubSkillDetailMock, fetchClawHubSkillInstallResolution: fetchClawHubSkillInstallResolutionMock, fetchClawHubSkillVerification: fetchClawHubSkillVerificationMock, + fetchClawHubSkillSecurityVerdicts: fetchClawHubSkillSecurityVerdictsMock, downloadClawHubSkillArchive: downloadClawHubSkillArchiveMock, downloadClawHubSkillArchiveUrl: downloadClawHubSkillArchiveUrlMock, downloadClawHubGitHubSkillArchive: downloadClawHubGitHubSkillArchiveMock, @@ -179,6 +181,7 @@ describe("skills-clawhub", () => { fetchClawHubSkillDetailMock.mockReset(); fetchClawHubSkillInstallResolutionMock.mockReset(); fetchClawHubSkillVerificationMock.mockReset(); + fetchClawHubSkillSecurityVerdictsMock.mockReset(); downloadClawHubSkillArchiveMock.mockReset(); downloadClawHubSkillArchiveUrlMock.mockReset(); downloadClawHubGitHubSkillArchiveMock.mockReset(); @@ -195,7 +198,9 @@ describe("skills-clawhub", () => { resolveClawHubBaseUrlMock.mockImplementation((baseUrl?: string) => (baseUrl ?? "https://clawhub.ai").replace(/\/+$/, ""), ); - isDefaultClawHubBaseUrlMock.mockImplementation((baseUrl?: string) => !baseUrl); + isDefaultClawHubBaseUrlMock.mockImplementation( + (baseUrl?: string) => !baseUrl || baseUrl.replace(/\/+$/, "") === "https://clawhub.ai", + ); pathExistsMock.mockImplementation(async (input: string) => input.endsWith("SKILL.md")); fetchClawHubSkillDetailMock.mockResolvedValue({ skill: { @@ -229,6 +234,28 @@ describe("skills-clawhub", () => { security: { status: "clean", signals: { staticScan: { engineVersion: "v2.4.24" } } }, signature: { status: "unsigned" }, }); + fetchClawHubSkillSecurityVerdictsMock.mockImplementation( + async (params: { + items: Array<{ slug: string; ownerHandle?: string; version: string }>; + }) => ({ + schema: "clawhub.skill.security-verdicts.v1", + items: params.items.map((item) => ({ + ok: true, + decision: "pass", + reasons: [], + requestedSlug: item.slug, + requestedVersion: item.version, + slug: item.slug, + version: item.version, + displayName: "Agent Receipt", + ...(item.ownerHandle ? { publisherHandle: item.ownerHandle } : {}), + security: { + status: "clean", + passed: true, + }, + })), + }), + ); downloadClawHubSkillArchiveMock.mockResolvedValue({ archivePath: "/tmp/agentreceipt.zip", integrity: "sha256-test", @@ -310,8 +337,491 @@ describe("skills-clawhub", () => { ]); }); + it("bypasses ClawHub trust checks for official skill install resolutions", async () => { + fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({ + ok: true, + slug: "agentreceipt", + channel: "official", + isOfficial: true, + installKind: "archive", + archive: { + version: "1.0.0", + downloadUrl: "https://clawhub.ai/api/v1/download?slug=agentreceipt&version=1.0.0", + }, + }); + fetchClawHubSkillSecurityVerdictsMock.mockRejectedValueOnce(new Error("should not be called")); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + }); + + expectInstalledSkill(result, { + slug: "agentreceipt", + version: "1.0.0", + targetDir: "/tmp/workspace/skills/agentreceipt", + }); + expect(fetchClawHubSkillSecurityVerdictsMock).not.toHaveBeenCalled(); + expect(installPolicyInput()).toMatchObject({ + origin: { registry: "https://clawhub.ai" }, + source: { kind: "clawhub", authority: "official", mutable: false, network: true }, + }); + }); + + it("bypasses ClawHub trust checks when skill detail has an official owner", async () => { + fetchClawHubSkillDetailMock.mockResolvedValueOnce({ + skill: { + slug: "tao-setup-nvidia-gpu-host", + displayName: "TAO Setup NVIDIA GPU Host", + createdAt: 1, + updatedAt: 2, + }, + owner: { + handle: "nvidia", + displayName: "NVIDIA", + official: true, + }, + latestVersion: { + version: "1.0.0", + createdAt: 3, + }, + }); + fetchClawHubSkillSecurityVerdictsMock.mockRejectedValueOnce(new Error("should not be called")); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "tao-setup-nvidia-gpu-host", + }); + + expectInstalledSkill(result, { + slug: "tao-setup-nvidia-gpu-host", + version: "1.0.0", + targetDir: "/tmp/workspace/skills/tao-setup-nvidia-gpu-host", + }); + expect(fetchClawHubSkillDetailMock).toHaveBeenCalledWith({ + slug: "tao-setup-nvidia-gpu-host", + baseUrl: undefined, + }); + expect(fetchClawHubSkillSecurityVerdictsMock).not.toHaveBeenCalled(); + expect(installPolicyInput()).toMatchObject({ + origin: { registry: "https://clawhub.ai" }, + source: { kind: "clawhub", authority: "official", mutable: false, network: true }, + }); + }); + + it("blocks ClawHub skill installs when release trust is malicious", async () => { + const warnings: string[] = []; + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["scan:malicious"], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + publisherHandle: "acme", + security: { + status: "malicious", + passed: false, + }, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "@acme/agentreceipt", + logger: { + warn: (message) => warnings.push(message), + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected malicious skill install failure"); + } + expect(result.error).toBe("ClawHub blocked this release; install was not started."); + expect(result.code).toBe("clawhub_download_blocked"); + expect(result.warning).toContain("BLOCKED - ClawHub flagged this release as malicious"); + expect(warnings.join("\n")).toContain("BLOCKED - ClawHub flagged this release as malicious"); + expect(warnings.join("\n")).toContain("OpenClaw will not install this skill release"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled(); + }); + + it("requires acknowledgement before installing suspicious ClawHub skill releases", async () => { + const warnings: string[] = []; + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["security.status_not_clean"], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + skillUrl: "https://clawhub.ai/acme/skills/agentreceipt", + securityAuditUrl: + "https://clawhub.ai/acme/skills/agentreceipt/security-audit?version=1.0.0", + security: { + status: "suspicious", + passed: false, + }, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + logger: { + warn: (message) => warnings.push(message), + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected suspicious skill install failure"); + } + expect(result.error).toContain("--acknowledge-clawhub-risk"); + expect(result.version).toBe("1.0.0"); + expect(result.warning).toContain("WARNING - ClawHub found security risks"); + expect(result.warning).toContain( + "https://clawhub.ai/acme/skills/agentreceipt/security-audit?version=1.0.0", + ); + expect(warnings.join("\n")).toContain("WARNING - ClawHub found security risks"); + expect(warnings.join("\n")).toContain( + "https://clawhub.ai/acme/skills/agentreceipt/security-audit?version=1.0.0", + ); + expect(warnings.join("\n")).toContain("large instruction/tool-use blast radius"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + }); + + it("returns review-recommended warnings with successful ClawHub skill installs", async () => { + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: true, + decision: "pass", + reasons: ["scan:pending"], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + security: { + status: "pending", + passed: true, + }, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + }); + + expectInstalledSkill(result, { + slug: "agentreceipt", + version: "1.0.0", + }); + if (!result.ok) { + throw new Error("expected review-recommended skill install success"); + } + expect(result.warning).toContain( + "REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check", + ); + expect(result.warning).toContain("security scan is pending"); + }); + + it("fails closed when ClawHub skill trust checks are unavailable", async () => { + fetchClawHubSkillSecurityVerdictsMock.mockRejectedValueOnce( + new Error("security verdicts unavailable"), + ); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected unavailable skill trust failure"); + } + expect(result.code).toBe("clawhub_security_unavailable"); + expect(result.error).toContain("ClawHub release trust check failed"); + expect(result.error).toContain("security verdicts unavailable"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled(); + }); + + it("fails closed when ClawHub returns no skill trust verdict", async () => { + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected missing skill trust verdict failure"); + } + expect(result.code).toBe("clawhub_security_unavailable"); + expect(result.error).toContain("returned 0 verdicts"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "omitted security", + verdict: {}, + }, + { + name: "null security", + verdict: { security: null }, + }, + ])("fails closed when a passing skill verdict has $name", async ({ verdict }) => { + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: true, + decision: "pass", + reasons: [], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + ...verdict, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected unusable skill trust verdict failure"); + } + expect(result.code).toBe("clawhub_security_unavailable"); + expect(result.error).toContain("did not return a usable security verdict"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled(); + }); + + it("blocks ClawHub skill installs when moderation marks the release as malware-blocked", async () => { + const warnings: string[] = []; + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["moderation.malware_blocked"], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + security: { + status: "clean", + passed: false, + }, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + acknowledgeClawHubRisk: true, + logger: { + warn: (message) => warnings.push(message), + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected moderation-blocked skill install failure"); + } + expect(result.error).toBe("ClawHub blocked this release; install was not started."); + expect(result.code).toBe("clawhub_download_blocked"); + expect(warnings.join("\n")).toContain("BLOCKED - ClawHub blocked this release"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + }); + + it("requires acknowledgement when ClawHub returns a failed skill verdict with clean nested scan status", async () => { + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: [], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + security: { + status: "clean", + passed: false, + }, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected failed skill verdict install failure"); + } + expect(result.error).toContain("--acknowledge-clawhub-risk"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + }); + + it("uses the owner-qualified skill name for suspicious ClawHub acknowledgements", async () => { + const onClawHubRisk = vi.fn< + NonNullable[0]["onClawHubRisk"]> + >(async () => false); + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["security.status_not_clean"], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + publisherHandle: "acme", + security: { + status: "suspicious", + passed: false, + }, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "@acme/agentreceipt", + onClawHubRisk, + }); + + expect(result.ok).toBe(false); + expect(onClawHubRisk).toHaveBeenCalledWith( + expect.objectContaining({ + packageName: "@acme/agentreceipt", + version: "1.0.0", + }), + ); + const acknowledgementRequest = onClawHubRisk.mock.calls[0]?.[0]; + expect(acknowledgementRequest?.warning).toContain( + "https://clawhub.ai/acme/skills/agentreceipt", + ); + expect(acknowledgementRequest?.warning).toContain( + "https://clawhub.ai/acme/skills/agentreceipt/security-audit?version=1.0.0", + ); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + }); + + it("continues after explicit acknowledgement for suspicious ClawHub skill releases", async () => { + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["security.status_not_clean"], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + security: { + status: "suspicious", + passed: false, + }, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + acknowledgeClawHubRisk: true, + }); + + expectInstalledSkill(result, { + slug: "agentreceipt", + version: "1.0.0", + }); + if (!result.ok) { + throw new Error("expected acknowledged suspicious skill install success"); + } + expect(result.warning).toContain("WARNING - ClawHub found security risks"); + expect(downloadClawHubSkillArchiveUrlMock).toHaveBeenCalledWith({ + url: "https://clawhub.ai/api/v1/download?slug=agentreceipt&version=1.0.0", + baseUrl: undefined, + }); + }); + it("installs owner-qualified ClawHub skills without using owner as a local path", async () => { const workspaceDir = await tempDirs.make("openclaw-owner-skill-"); + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["skill.not_found"], + requestedSlug: "weather", + requestedVersion: "1.0.0", + slug: "weather", + version: null, + security: null, + error: { + code: "skill_not_found", + message: "Skill not found", + }, + }, + ], + }); + fetchClawHubSkillVerificationMock.mockResolvedValueOnce({ + schema: "clawhub.skill.verify.v1", + ok: true, + decision: "pass", + reasons: [], + slug: "weather", + displayName: "Weather", + pageUrl: "https://clawhub.ai/demo-owner/skills/weather", + publisherHandle: "demo-owner", + publisherDisplayName: "Demo Owner", + version: "1.0.0", + createdAt: 123, + card: { available: true, sha256: "card-sha" }, + artifact: { sourceFingerprint: "source-fp" }, + provenance: { source: "unavailable" }, + security: { status: "clean" }, + signature: { status: "unsigned" }, + }); installPackageDirMock.mockImplementationOnce(async (params: { targetDir: string }) => { await fs.mkdir(params.targetDir, { recursive: true }); await fs.writeFile(path.join(params.targetDir, "SKILL.md"), "# Weather\n", "utf8"); @@ -328,6 +838,26 @@ describe("skills-clawhub", () => { ownerHandle: "demo-owner", baseUrl: undefined, }); + expect(fetchClawHubSkillSecurityVerdictsMock).toHaveBeenCalledWith({ + items: [ + { + slug: "weather", + ownerHandle: "demo-owner", + version: "1.0.0", + }, + ], + baseUrl: undefined, + token: undefined, + timeoutMs: undefined, + }); + expect(fetchClawHubSkillVerificationMock).toHaveBeenNthCalledWith(1, { + slug: "weather", + ownerHandle: "demo-owner", + version: "1.0.0", + baseUrl: undefined, + token: undefined, + timeoutMs: undefined, + }); expectInstallPackageSourceDir("/tmp/extracted-skill"); expect(installPolicyInput()).toMatchObject({ origin: { @@ -367,6 +897,122 @@ describe("skills-clawhub", () => { }); }); + it("does not require acknowledgement for owner-qualified clean skills missing only cards", async () => { + const workspaceDir = await tempDirs.make("openclaw-owner-card-missing-"); + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["skill.not_found"], + requestedSlug: "weather", + requestedVersion: "1.0.0", + slug: "weather", + version: null, + security: null, + error: { + code: "skill_not_found", + message: "Skill not found", + }, + }, + ], + }); + fetchClawHubSkillVerificationMock.mockResolvedValueOnce({ + schema: "clawhub.skill.verify.v1", + ok: false, + decision: "fail", + reasons: ["card.missing"], + slug: "weather", + displayName: "Weather", + pageUrl: "https://clawhub.ai/demo-owner/skills/weather", + publisherHandle: "demo-owner", + publisherDisplayName: "Demo Owner", + version: "1.0.0", + createdAt: 123, + card: { available: false }, + artifact: { sourceFingerprint: "source-fp" }, + provenance: { source: "unavailable" }, + security: { status: "clean" }, + signature: { status: "unsigned" }, + }); + const onClawHubRisk = vi.fn(async () => false); + installPackageDirMock.mockImplementationOnce(async (params: { targetDir: string }) => { + await fs.mkdir(params.targetDir, { recursive: true }); + await fs.writeFile(path.join(params.targetDir, "SKILL.md"), "# Weather\n", "utf8"); + return { ok: true, targetDir: params.targetDir }; + }); + + const result = await installSkillFromClawHub({ + workspaceDir, + slug: "@demo-owner/weather", + onClawHubRisk, + }); + + expectInstalledSkill(result, { + slug: "weather", + version: "1.0.0", + targetDir: path.join(workspaceDir, "skills", "weather"), + }); + expect(onClawHubRisk).not.toHaveBeenCalled(); + expect(downloadClawHubSkillArchiveUrlMock).toHaveBeenCalled(); + }); + + it("does not let owner-qualified fallback acknowledgement mask missing exact versions", async () => { + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["skill.not_found"], + requestedSlug: "weather", + requestedVersion: "1.0.0", + slug: "weather", + version: null, + security: null, + error: { + code: "skill_not_found", + message: "Skill not found", + }, + }, + ], + }); + fetchClawHubSkillVerificationMock.mockResolvedValueOnce({ + schema: "clawhub.skill.verify.v1", + ok: false, + decision: "fail", + reasons: ["version.not_found"], + slug: "weather", + displayName: "Weather", + pageUrl: "https://clawhub.ai/demo-owner/skills/weather", + publisherHandle: "demo-owner", + publisherDisplayName: "Demo Owner", + version: null, + createdAt: 123, + card: { available: true, sha256: "card-sha" }, + artifact: { sourceFingerprint: "source-fp" }, + provenance: { source: "unavailable" }, + security: { status: "clean" }, + signature: { status: "unsigned" }, + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "@demo-owner/weather", + acknowledgeClawHubRisk: true, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected owner-qualified missing version failure"); + } + expect(result.code).toBe("clawhub_security_unavailable"); + expect(result.error).toContain('returned version "unknown"'); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled(); + }); + it("formats ambiguous ClawHub slug responses with owner-qualified guidance", async () => { fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({ ok: false, @@ -755,6 +1401,9 @@ describe("skills-clawhub", () => { slug: "aiq-deploy", baseUrl: undefined, }); + // GitHub-backed skills are approved by the install resolver before it + // returns this pinned commit; they do not have a ClawHub release version. + expect(fetchClawHubSkillSecurityVerdictsMock).not.toHaveBeenCalled(); expect(downloadClawHubGitHubSkillArchiveMock).toHaveBeenCalledWith({ repo: "NVIDIA/skills", commit, @@ -809,6 +1458,8 @@ describe("skills-clawhub", () => { baseUrl: undefined, forceInstall: true, }); + // forceInstall is a resolver policy input for GitHub-backed skills. + expect(fetchClawHubSkillSecurityVerdictsMock).not.toHaveBeenCalled(); expectInstalledSkill(result, { slug: "aiq-deploy", version: commit, @@ -919,6 +1570,129 @@ describe("skills-clawhub", () => { expect(lock.skills.weather?.ownerHandle).toBe("demo-owner"); }); + it("updates official publisher ClawHub skills without fetching security verdicts", async () => { + const workspaceDir = await tempDirs.make("openclaw-official-owner-update-"); + await writeClawHubOriginFixture({ + workspaceDir, + slug: "tao-setup-nvidia-gpu-host", + ownerHandle: "nvidia", + registry: "https://clawhub.ai", + installedVersion: "0.9.0", + }); + fetchClawHubSkillDetailMock.mockResolvedValueOnce({ + skill: { + slug: "tao-setup-nvidia-gpu-host", + displayName: "TAO Setup NVIDIA GPU Host", + createdAt: 1, + updatedAt: 2, + }, + owner: { + handle: "nvidia", + displayName: "NVIDIA", + official: true, + }, + latestVersion: { + version: "1.0.0", + createdAt: 3, + }, + }); + fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({ + ok: true, + slug: "tao-setup-nvidia-gpu-host", + installKind: "archive", + archive: { + version: "1.0.0", + downloadUrl: + "https://clawhub.ai/api/v1/download?slug=tao-setup-nvidia-gpu-host&ownerHandle=nvidia&version=1.0.0", + }, + }); + fetchClawHubSkillSecurityVerdictsMock.mockRejectedValueOnce(new Error("should not be called")); + installPackageDirMock.mockImplementationOnce(async (params: { targetDir: string }) => { + await fs.mkdir(params.targetDir, { recursive: true }); + await fs.writeFile(path.join(params.targetDir, "SKILL.md"), "# NVIDIA\n", "utf8"); + return { ok: true, targetDir: params.targetDir }; + }); + + const results = await updateSkillsFromClawHub({ + workspaceDir, + slug: "tao-setup-nvidia-gpu-host", + }); + + expect(fetchClawHubSkillDetailMock).toHaveBeenCalledWith({ + slug: "tao-setup-nvidia-gpu-host", + ownerHandle: "nvidia", + baseUrl: "https://clawhub.ai", + }); + expect(fetchClawHubSkillSecurityVerdictsMock).not.toHaveBeenCalled(); + expect(results).toEqual([ + { + ok: true, + slug: "tao-setup-nvidia-gpu-host", + previousVersion: "0.9.0", + version: "1.0.0", + changed: true, + targetDir: path.join(workspaceDir, "skills", "tao-setup-nvidia-gpu-host"), + }, + ]); + expect(installPolicyInput()).toMatchObject({ + origin: { registry: "https://clawhub.ai", ownerHandle: "nvidia" }, + source: { kind: "clawhub", authority: "official", mutable: false, network: true }, + }); + }); + + it("explains that a malicious skill update will not be downloaded", async () => { + const workspaceDir = await tempDirs.make("openclaw-skill-malicious-update-"); + const warnings: string[] = []; + await writeClawHubOriginFixture({ + workspaceDir, + slug: "agentreceipt", + installedVersion: "0.9.0", + }); + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["scan:malicious"], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + security: { + status: "malicious", + passed: false, + }, + }, + ], + }); + + const results = await updateSkillsFromClawHub({ + workspaceDir, + slug: "agentreceipt", + logger: { + warn: (message) => warnings.push(message), + }, + }); + + expect(results).toEqual([ + expect.objectContaining({ + ok: false, + code: "clawhub_download_blocked", + error: "ClawHub blocked this release; update was not started.", + }), + ]); + expect(warnings.join("\n")).toContain( + "Latest skill version is marked malicious; OpenClaw will not download it.", + ); + expect(warnings.join("\n")).toContain( + "Uninstall the installed skill unless you have independently reviewed it.", + ); + expect(warnings.join("\n")).not.toContain("Choose a different version"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled(); + }); + it("updates owner-qualified ClawHub skills when the requested owner matches tracking", async () => { const workspaceDir = await tempDirs.make("openclaw-owner-update-request-"); await writeClawHubOriginFixture({ diff --git a/src/skills/lifecycle/clawhub.ts b/src/skills/lifecycle/clawhub.ts index cb8eadb0b29c..b94ddff1f1d6 100644 --- a/src/skills/lifecycle/clawhub.ts +++ b/src/skills/lifecycle/clawhub.ts @@ -4,6 +4,11 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + type ClawHubTrustErrorCode, + ensureClawHubPackageTrustAcknowledged, + type ClawHubRiskAcknowledgementRequest, +} from "../../infra/clawhub-install-trust.js"; import { downloadClawHubGitHubSkillArchive, downloadClawHubSkillArchive, @@ -139,8 +144,9 @@ type InstallClawHubSkillResult = version: string; targetDir: string; detail?: ClawHubSkillDetail; + warning?: string; } - | { ok: false; error: string }; + | { ok: false; error: string; code?: ClawHubTrustErrorCode; version?: string; warning?: string }; type UpdateClawHubSkillResult = | { @@ -150,11 +156,14 @@ type UpdateClawHubSkillResult = version: string; changed: boolean; targetDir: string; + warning?: string; } - | { ok: false; error: string }; + | { ok: false; error: string; code?: ClawHubTrustErrorCode; version?: string; warning?: string }; type Logger = { info?: (message: string) => void; + warn?: (message: string) => void; + terminalLinks?: boolean; }; type ClawHubSkillRef = { @@ -229,10 +238,61 @@ type ClawHubInstallParams = { baseUrl?: string; force?: boolean; forceInstall?: boolean; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; logger?: Logger; config?: OpenClawConfig; }; +type ClawHubOfficialFlagContainer = { + channel?: unknown; + official?: unknown; + isOfficial?: unknown; +}; + +function hasOfficialClawHubFlag(value: ClawHubOfficialFlagContainer | null | undefined): boolean { + return value?.channel === "official" || value?.official === true || value?.isOfficial === true; +} + +function isDefaultOfficialClawHubSkillSource(params: { + baseUrl?: string; + detail?: ClawHubSkillDetail; + resolution?: Extract; +}): boolean { + if (!isDefaultClawHubBaseUrl(params.baseUrl)) { + return false; + } + return ( + hasOfficialClawHubFlag(params.detail?.skill) || + hasOfficialClawHubFlag(params.detail?.owner) || + hasOfficialClawHubFlag(params.resolution) || + (params.resolution?.installKind === "archive" && + hasOfficialClawHubFlag(params.resolution.archive)) + ); +} + +async function fetchDefaultClawHubSkillDetailIfOfficial(params: { + baseUrl?: string; + slug: string; + ownerHandle?: string; +}): Promise { + if (!isDefaultClawHubBaseUrl(params.baseUrl)) { + return undefined; + } + try { + const detail = await fetchClawHubSkillDetail({ + slug: params.slug, + ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), + baseUrl: params.baseUrl, + }); + return isDefaultOfficialClawHubSkillSource({ baseUrl: params.baseUrl, detail }) + ? detail + : undefined; + } catch { + return undefined; + } +} + type TrackedUpdateTarget = | { ok: true; @@ -1107,7 +1167,7 @@ async function installArchiveResolution(params: { version: string; archivePath: string; registry: string; - authority: "openclaw" | "third-party"; + authority: "official" | "openclaw" | "third-party"; force?: boolean; logger?: Logger; config?: OpenClawConfig; @@ -1154,6 +1214,7 @@ async function installGitHubResolution(params: { sourcePath: string; archivePath: string; registry: string; + authority: "official" | "third-party"; repo: string; commit: string; force?: boolean; @@ -1186,7 +1247,7 @@ async function installGitHubResolution(params: { }, source: { kind: "git", - authority: "third-party", + authority: params.authority, mutable: false, network: true, }, @@ -1212,6 +1273,41 @@ function assertInstallResolutionAllowed( throw new Error(resolution.message || `Skill "${resolution.slug}" is not installable.`); } +async function ensureClawHubSkillTrustAcknowledged( + params: ClawHubInstallParams & { + version: string; + skipClawHubTrustCheck?: boolean; + }, +): Promise< + | { ok: true; warning?: string } + | { ok: false; error: string; code?: ClawHubTrustErrorCode; warning?: string } +> { + if (params.skipClawHubTrustCheck) { + return { ok: true }; + } + const result = await ensureClawHubPackageTrustAcknowledged({ + subject: { + kind: "skill", + packageName: params.slug, + ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), + }, + version: params.version, + baseUrl: params.baseUrl, + acknowledgeClawHubRisk: params.acknowledgeClawHubRisk, + onClawHubRisk: params.onClawHubRisk, + logger: params.logger, + mode: params.force ? "update" : "install", + }); + return result.ok + ? { ok: true, ...(result.warning ? { warning: result.warning } : {}) } + : { + ok: false, + error: result.error, + ...(result.code ? { code: result.code } : {}), + ...(result.warning ? { warning: result.warning } : {}), + }; +} + async function performClawHubSkillInstall( params: ClawHubInstallParams, ): Promise { @@ -1230,49 +1326,96 @@ async function performClawHubSkillInstall( let detail: ClawHubSkillDetail | undefined; let latestResolution: Extract | undefined; let install: Awaited>; + let trustWarning: string | undefined; + let officialClawHubSkill = false; - const archive = params.version - ? await (async () => { - const resolved = await resolveInstallVersion({ + let archive: ClawHubDownloadResult; + if (params.version) { + const resolved = await resolveInstallVersion({ + slug: params.slug, + ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), + version: params.version, + baseUrl: params.baseUrl, + }); + detail = resolved.detail; + version = resolved.version; + officialClawHubSkill = isDefaultOfficialClawHubSkillSource({ + baseUrl: params.baseUrl, + detail, + }); + const trust = await ensureClawHubSkillTrustAcknowledged({ + ...params, + version, + skipClawHubTrustCheck: officialClawHubSkill, + }); + if (!trust.ok) { + return { ...trust, version }; + } + trustWarning = trust.warning; + params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`); + archive = await downloadClawHubSkillArchive({ + slug: params.slug, + ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), + version, + baseUrl: params.baseUrl, + }); + } else { + latestResolution = assertInstallResolutionAllowed( + await fetchClawHubSkillInstallResolution({ + slug: params.slug, + ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), + baseUrl: params.baseUrl, + ...(params.forceInstall ? { forceInstall: true } : {}), + }), + ); + const resolutionOfficialClawHubSkill = isDefaultOfficialClawHubSkillSource({ + baseUrl: params.baseUrl, + resolution: latestResolution, + }); + detail = resolutionOfficialClawHubSkill + ? undefined + : await fetchDefaultClawHubSkillDetailIfOfficial({ + baseUrl: params.baseUrl, slug: params.slug, ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), - version: params.version, - baseUrl: params.baseUrl, }); - detail = resolved.detail; - version = resolved.version; - params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`); - return await downloadClawHubSkillArchive({ - slug: params.slug, - ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), - version, - baseUrl: params.baseUrl, - }); - })() - : await (async () => { - latestResolution = assertInstallResolutionAllowed( - await fetchClawHubSkillInstallResolution({ - slug: params.slug, - ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), - baseUrl: params.baseUrl, - ...(params.forceInstall ? { forceInstall: true } : {}), - }), - ); - if (latestResolution.installKind === "github") { - version = latestResolution.github.commit; - params.logger?.info?.(`Downloading ${params.slug}@${version} from GitHub…`); - return await downloadClawHubGitHubSkillArchive({ - repo: latestResolution.github.repo, - commit: latestResolution.github.commit, - }); - } - version = latestResolution.archive.version; - params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`); - return await downloadClawHubSkillArchiveUrl({ - url: latestResolution.archive.downloadUrl, - baseUrl: params.baseUrl, - }); - })(); + if (latestResolution.installKind === "github") { + version = latestResolution.github.commit; + officialClawHubSkill = isDefaultOfficialClawHubSkillSource({ + baseUrl: params.baseUrl, + detail, + resolution: latestResolution, + }); + // GitHub-backed ClawHub skills are commit resolutions, not ClawHub skill + // release versions; the install resolver owns their scan/force policy. + params.logger?.info?.(`Downloading ${params.slug}@${version} from GitHub…`); + archive = await downloadClawHubGitHubSkillArchive({ + repo: latestResolution.github.repo, + commit: latestResolution.github.commit, + }); + } else { + version = latestResolution.archive.version; + officialClawHubSkill = isDefaultOfficialClawHubSkillSource({ + baseUrl: params.baseUrl, + detail, + resolution: latestResolution, + }); + const trust = await ensureClawHubSkillTrustAcknowledged({ + ...params, + version, + skipClawHubTrustCheck: officialClawHubSkill, + }); + if (!trust.ok) { + return { ...trust, version }; + } + trustWarning = trust.warning; + params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`); + archive = await downloadClawHubSkillArchiveUrl({ + url: latestResolution.archive.downloadUrl, + baseUrl: params.baseUrl, + }); + } + } try { if (!params.version) { if (!latestResolution) { @@ -1287,6 +1430,7 @@ async function performClawHubSkillInstall( sourcePath: latestResolution.github.path, archivePath: archive.archivePath, registry, + authority: officialClawHubSkill ? "official" : "third-party", repo: latestResolution.github.repo, commit: latestResolution.github.commit, force: params.force, @@ -1300,7 +1444,7 @@ async function performClawHubSkillInstall( version, archivePath: archive.archivePath, registry, - authority: clawhubAuthority, + authority: officialClawHubSkill ? "official" : clawhubAuthority, force: params.force, logger: params.logger, config: params.config, @@ -1313,7 +1457,7 @@ async function performClawHubSkillInstall( version, archivePath: archive.archivePath, registry, - authority: clawhubAuthority, + authority: officialClawHubSkill ? "official" : clawhubAuthority, force: params.force, logger: params.logger, config: params.config, @@ -1374,6 +1518,7 @@ async function performClawHubSkillInstall( version, targetDir: install.targetDir, ...(detail ? { detail } : {}), + ...(trustWarning ? { warning: trustWarning } : {}), }; } finally { await archive.cleanup().catch(() => undefined); @@ -1453,6 +1598,8 @@ export async function installSkillFromClawHub(params: { baseUrl?: string; force?: boolean; forceInstall?: boolean; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; logger?: Logger; config?: OpenClawConfig; }): Promise { @@ -1464,6 +1611,8 @@ export async function updateSkillsFromClawHub(params: { slug?: string; baseUrl?: string; forceInstall?: boolean; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; logger?: Logger; config?: OpenClawConfig; }): Promise { @@ -1499,6 +1648,8 @@ export async function updateSkillsFromClawHub(params: { baseUrl: tracked.baseUrl, force: true, forceInstall: params.forceInstall, + acknowledgeClawHubRisk: params.acknowledgeClawHubRisk, + onClawHubRisk: params.onClawHubRisk, logger: params.logger, config: params.config, }); @@ -1513,6 +1664,7 @@ export async function updateSkillsFromClawHub(params: { version: install.version, changed: tracked.previousVersion !== install.version, targetDir: install.targetDir, + ...(install.warning ? { warning: install.warning } : {}), }); } return results; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 5e0b8113151d..54782b0573ff 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -3557,7 +3557,8 @@ export function renderApp(state: AppViewState) { }, onClawHubDetailOpen: (slug) => void loadClawHubDetail(state, slug), onClawHubDetailClose: () => closeClawHubDetail(state), - onClawHubInstall: (slug) => void installFromClawHub(state, slug), + onClawHubInstall: (slug, acknowledgeClawHubRisk, version) => + void installFromClawHub(state, slug, acknowledgeClawHubRisk, version), }), ) : nothing} diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 5051ba5c2701..2ecc245a850d 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -4,8 +4,8 @@ import type { ChatAbortOptions, ChatSendOptions } from "./app-chat.ts"; import type { EventLogEntry } from "./app-events.ts"; import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts"; import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./chat/input-history.ts"; -import type { RealtimeTalkConversationEntry } from "./chat/realtime-talk-conversation.ts"; import type { RealtimeTalkCatalogProvider } from "./chat/realtime-talk-catalog.ts"; +import type { RealtimeTalkConversationEntry } from "./chat/realtime-talk-conversation.ts"; import type { RealtimeTalkStatus } from "./chat/realtime-talk.ts"; import type { ChatRunUiStatus } from "./chat/run-lifecycle.ts"; import type { ChatMessageCache } from "./chat/session-message-cache.ts"; @@ -427,7 +427,13 @@ export type AppViewState = { clawhubDetailLoading: boolean; clawhubDetailError: string | null; clawhubInstallSlug: string | null; - clawhubInstallMessage: { kind: "success" | "error"; text: string } | null; + clawhubInstallMessage: { + kind: "success" | "error"; + text: string; + acknowledgeSlug?: string; + acknowledgeVersion?: string; + acknowledgeLabel?: string; + } | null; clawhubVerdicts: Record; clawhubVerdictsLoading: boolean; clawhubVerdictsError: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index ede73ca0ed5d..8512eb42034a 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -77,16 +77,16 @@ import type { AppViewState } from "./app-view-state.ts"; import { normalizeAssistantIdentity } from "./assistant-identity.ts"; import { restoreChatComposerState } from "./chat/composer-persistence.ts"; import { exportChatMarkdown } from "./chat/export.ts"; +import { + reconcileRealtimeTalkCatalogSelection, + type RealtimeTalkCatalogProvider, +} from "./chat/realtime-talk-catalog.ts"; import { createRealtimeTalkConversationState, updateRealtimeTalkConversation, type RealtimeTalkConversationEntry, type RealtimeTalkConversationState, } from "./chat/realtime-talk-conversation.ts"; -import { - reconcileRealtimeTalkCatalogSelection, - type RealtimeTalkCatalogProvider, -} from "./chat/realtime-talk-catalog.ts"; import { RealtimeTalkSession, type RealtimeTalkLaunchOptions, @@ -645,7 +645,12 @@ export class OpenClawApp extends LitElement { @state() clawhubDetailLoading = false; @state() clawhubDetailError: string | null = null; @state() clawhubInstallSlug: string | null = null; - @state() clawhubInstallMessage: { kind: "success" | "error"; text: string } | null = null; + @state() clawhubInstallMessage: { + kind: "success" | "error"; + text: string; + acknowledgeSlug?: string; + acknowledgeVersion?: string; + } | null = null; @state() clawhubVerdicts: Record = {}; @state() clawhubVerdictsLoading = false; @state() clawhubVerdictsError: string | null = null; diff --git a/ui/src/ui/controllers/skills.test.ts b/ui/src/ui/controllers/skills.test.ts index 0a3aa662d864..de9c74d9b7c2 100644 --- a/ui/src/ui/controllers/skills.test.ts +++ b/ui/src/ui/controllers/skills.test.ts @@ -766,6 +766,103 @@ describe("skill mutations", () => { }); }); + it("shows ClawHub trust warnings returned by successful skill installs", async () => { + const { state, request } = createState(); + request.mockImplementation(async (method: string) => { + if (method === "skills.install") { + return { + message: "Installed github@1.2.3", + warning: "REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check", + }; + } + return {}; + }); + + await installFromClawHub(state, "github"); + + expect(state.clawhubInstallMessage).toEqual({ + kind: "success", + text: + "Installed github@1.2.3\n\n" + + "REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check", + }); + }); + + it("shows ClawHub trust warnings from failed skill install error details", async () => { + const { state, request } = createState(); + const error = new Error("ClawHub blocked this release; install was not started.") as Error & { + details?: unknown; + }; + error.details = { + warning: "BLOCKED - ClawHub flagged this release as malicious", + }; + request.mockRejectedValue(error); + + await installFromClawHub(state, "github"); + + expect(state.clawhubInstallMessage).toEqual({ + kind: "error", + text: + "ClawHub blocked this release; install was not started.\n\n" + + "BLOCKED - ClawHub flagged this release as malicious", + }); + }); + + it("allows retrying acknowledgement-required ClawHub skill installs", async () => { + const { state, request } = createState(); + const error = new Error("ClawHub requires acknowledgement before installing.") as Error & { + details?: unknown; + }; + error.details = { + clawhubTrustCode: "clawhub_risk_acknowledgement_required", + version: "1.2.3", + warning: "REVIEW REQUIRED - ClawHub found suspicious behavior.", + }; + request.mockImplementation(async (method: string) => { + if (method === "skills.install" && request.mock.calls.length === 1) { + throw error; + } + if (method === "skills.install") { + return { message: "Installed github@1.2.3" }; + } + return { + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + }; + }); + + await installFromClawHub(state, "github"); + + expect(state.clawhubInstallMessage).toEqual({ + kind: "error", + text: + "Review the ClawHub warning before installing this skill.\n\n" + + "REVIEW REQUIRED - ClawHub found suspicious behavior.", + acknowledgeSlug: "github", + acknowledgeVersion: "1.2.3", + acknowledgeLabel: "Acknowledge risk and install", + }); + + await installFromClawHub( + state, + "github", + true, + state.clawhubInstallMessage!.acknowledgeVersion, + ); + + expect(request).toHaveBeenNthCalledWith(2, "skills.install", { + source: "clawhub", + slug: "github", + version: "1.2.3", + acknowledgeClawHubRisk: true, + }); + expect(state.clawhubInstallMessage).toEqual({ + kind: "success", + text: "Installed github@1.2.3", + }); + }); + it.each([ { name: "legacy install", diff --git a/ui/src/ui/controllers/skills.ts b/ui/src/ui/controllers/skills.ts index 88234499401d..60663eba3cda 100644 --- a/ui/src/ui/controllers/skills.ts +++ b/ui/src/ui/controllers/skills.ts @@ -1,4 +1,8 @@ // Control UI controller manages skills gateway state. +import { + ClawHubTrustErrorCodes, + readClawHubTrustErrorDetails, +} from "../../../../packages/gateway-protocol/src/clawhub-trust-error-details.js"; import type { GatewayBrowserClient } from "../gateway.ts"; import type { AgentsListResult, @@ -22,6 +26,8 @@ export type ClawHubSkillDetail = { displayName: string; summary?: string; tags?: Record; + channel?: string | null; + isOfficial?: boolean | null; createdAt: number; updatedAt: number; } | null; @@ -38,6 +44,9 @@ export type ClawHubSkillDetail = { handle?: string | null; displayName?: string | null; image?: string | null; + official?: boolean | null; + channel?: string | null; + isOfficial?: boolean | null; } | null; }; @@ -87,7 +96,13 @@ export type SkillsState = { clawhubDetailLoading: boolean; clawhubDetailError: string | null; clawhubInstallSlug: string | null; - clawhubInstallMessage: { kind: "success" | "error"; text: string } | null; + clawhubInstallMessage: { + kind: "success" | "error"; + text: string; + acknowledgeSlug?: string; + acknowledgeVersion?: string; + acknowledgeLabel?: string; + } | null; clawhubVerdicts: Record; clawhubVerdictsLoading: boolean; clawhubVerdictsError: string | null; @@ -113,6 +128,38 @@ function setSkillMessage(state: SkillsState, key: string, message: SkillMessage) const getErrorMessage = (err: unknown) => (err instanceof Error ? err.message : String(err)); +function getClawHubTrustWarningFromError(err: unknown): string | undefined { + if (!err || typeof err !== "object" || !("details" in err)) { + return undefined; + } + return readClawHubTrustErrorDetails((err as { details?: unknown }).details)?.warning; +} + +function getClawHubTrustCodeFromError(err: unknown) { + if (!err || typeof err !== "object" || !("details" in err)) { + return undefined; + } + return readClawHubTrustErrorDetails((err as { details?: unknown }).details)?.clawhubTrustCode; +} + +function getClawHubTrustVersionFromError(err: unknown): string | undefined { + if (!err || typeof err !== "object" || !("details" in err)) { + return undefined; + } + return readClawHubTrustErrorDetails((err as { details?: unknown }).details)?.version; +} + +function formatClawHubInstallMessage(message: string, warning?: string): string { + return warning ? `${message}\n\n${warning}` : message; +} + +function formatClawHubAcknowledgementMessage(warning?: string): string { + return formatClawHubInstallMessage( + "Review the ClawHub warning before installing this skill.", + warning, + ); +} + export function clawhubVerdictKey(target: { registry: string; slug: string; @@ -555,7 +602,12 @@ export function closeClawHubDetail(state: SkillsState) { state.clawhubDetailLoading = false; } -export async function installFromClawHub(state: SkillsState, slug: string) { +export async function installFromClawHub( + state: SkillsState, + slug: string, + acknowledgeClawHubRisk = false, + version?: string, +) { if (!state.client || !state.connected) { return; } @@ -563,11 +615,16 @@ export async function installFromClawHub(state: SkillsState, slug: string) { state.clawhubInstallSlug = slug; state.clawhubInstallMessage = null; try { - await state.client.request("skills.install", { - ...skillsAgentParams(state), - source: "clawhub", - slug, - }); + const result = await state.client.request<{ message?: string; warning?: string }>( + "skills.install", + { + ...skillsAgentParams(state), + source: "clawhub", + slug, + ...(version ? { version } : {}), + ...(acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + }, + ); if (!isSkillsAgentScopeCurrent(state, agentScope)) { return; } @@ -575,10 +632,24 @@ export async function installFromClawHub(state: SkillsState, slug: string) { if (!isSkillsAgentScopeCurrent(state, agentScope)) { return; } - state.clawhubInstallMessage = { kind: "success", text: `Installed ${slug}` }; + state.clawhubInstallMessage = { + kind: "success", + text: formatClawHubInstallMessage(result?.message ?? `Installed ${slug}`, result?.warning), + }; } catch (err) { if (isSkillsAgentScopeCurrent(state, agentScope)) { - state.clawhubInstallMessage = { kind: "error", text: getErrorMessage(err) }; + const needsAcknowledgement = + getClawHubTrustCodeFromError(err) === ClawHubTrustErrorCodes.RISK_ACKNOWLEDGEMENT_REQUIRED; + const acknowledgeVersion = getClawHubTrustVersionFromError(err); + state.clawhubInstallMessage = { + kind: "error", + text: needsAcknowledgement + ? formatClawHubAcknowledgementMessage(getClawHubTrustWarningFromError(err)) + : formatClawHubInstallMessage(getErrorMessage(err), getClawHubTrustWarningFromError(err)), + ...(needsAcknowledgement ? { acknowledgeSlug: slug } : {}), + ...(needsAcknowledgement && acknowledgeVersion ? { acknowledgeVersion } : {}), + ...(needsAcknowledgement ? { acknowledgeLabel: "Acknowledge risk and install" } : {}), + }; } } finally { if (isSkillsAgentScopeCurrent(state, agentScope) && state.clawhubInstallSlug === slug) { diff --git a/ui/src/ui/views/skills.test.ts b/ui/src/ui/views/skills.test.ts index 9f6775058d43..bef55ad3201b 100644 --- a/ui/src/ui/views/skills.test.ts +++ b/ui/src/ui/views/skills.test.ts @@ -382,6 +382,38 @@ describe("renderSkills", () => { expect(onClawHubInstall).toHaveBeenCalledWith("github"); }); + it("renders ClawHub acknowledgement retry actions", async () => { + const container = document.createElement("div"); + document.body.append(container); + dialogRestores.push(() => container.remove()); + const onClawHubInstall = vi.fn(); + + render( + renderSkills( + createProps({ + clawhubInstallMessage: { + kind: "error", + text: "REVIEW REQUIRED - ClawHub found suspicious behavior.", + acknowledgeSlug: "github", + acknowledgeVersion: "1.2.3", + }, + onClawHubInstall, + }), + ), + container, + ); + + const retryButton = container.querySelector(".callout button"); + expect(normalizeText(container.querySelector(".callout")!)).toBe( + "REVIEW REQUIRED - ClawHub found suspicious behavior. Acknowledge risk and install", + ); + expect(retryButton).toBeInstanceOf(HTMLButtonElement); + retryButton!.click(); + + expect(onClawHubInstall).toHaveBeenCalledTimes(1); + expect(onClawHubInstall).toHaveBeenCalledWith("github", true, "1.2.3"); + }); + it("renders installed ClawHub verdicts and the local Skill Card tab", async () => { const container = document.createElement("div"); document.body.append(container); diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index cdacc6ea31ac..9f97b0b3c353 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -78,7 +78,13 @@ export type SkillsProps = { clawhubDetailLoading: boolean; clawhubDetailError: string | null; clawhubInstallSlug: string | null; - clawhubInstallMessage: { kind: "success" | "error"; text: string } | null; + clawhubInstallMessage: { + kind: "success" | "error"; + text: string; + acknowledgeSlug?: string; + acknowledgeVersion?: string; + acknowledgeLabel?: string; + } | null; onFilterChange: (next: string) => void; onAgentChange: (agentId: string) => void; onStatusFilterChange: (next: SkillsStatusFilter) => void; @@ -93,7 +99,7 @@ export type SkillsProps = { onClawHubQueryChange: (query: string) => void; onClawHubDetailOpen: (slug: string) => void; onClawHubDetailClose: () => void; - onClawHubInstall: (slug: string) => void; + onClawHubInstall: (slug: string, acknowledgeClawHubRisk?: boolean, version?: string) => void; }; type StatusTabDef = { id: SkillsStatusFilter; label: string }; @@ -318,7 +324,29 @@ export function renderSkills(props: SkillsProps) { class="callout ${props.clawhubInstallMessage.kind === "error" ? "danger" : "success"}" style="margin-top: 8px;" > - ${props.clawhubInstallMessage.text} +
+ ${props.clawhubInstallMessage.text} +
+ ${props.clawhubInstallMessage.acknowledgeSlug + ? html`` + : nothing} ` : nothing} ${renderClawHubResults(props)}