From 26f2241a87fc47527d132bc53cc372da97ee0457 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 3 May 2026 03:05:48 -0700 Subject: [PATCH] fix(onboarding): limit ClawHub npm fallback --- CHANGELOG.md | 1 + .../onboarding-plugin-install.test.ts | 96 +++++++++++++++++++ src/commands/onboarding-plugin-install.ts | 10 +- 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3c4ef94a61..8208ee9acba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - CLI/doctor: keep missing-plugin repair from overriding official catalog metadata with runtime fallbacks, so ACPX repairs preserve the beta npm spec during the externalization rollout. Thanks @vincentkoc. - Plugins/catalog: preserve ClawHub install specs when generating the packaged channel catalog so future storepack-first channel plugins keep their remote source instead of becoming npm-only. Thanks @vincentkoc. - Plugins/update: treat catalog-matched official npm updates and OpenClaw-authored externalized-bundled npm bridges as trusted official installs so launch-code plugins can update or migrate out of the bundled tree without scanner false positives. Thanks @vincentkoc. +- Plugins/onboarding: fall back from ClawHub to npm only for missing package/version errors, keeping integrity and verification failures fail-closed during storepack rollout. Thanks @vincentkoc. - Control UI/Talk: fix Talk (OpenAI Realtime WebRTC) CORS failure by stripping server-side-only attribution headers (`originator`, `version`, `User-Agent`) from browser offer headers; `api.openai.com/v1/realtime/calls` only allows `authorization` and `content-type` in its CORS preflight, so forwarding these headers caused the browser SDP exchange to fail. Fixes #76435. Thanks @hclsys. - CLI/logs: auto-reconnect `openclaw logs --follow` on transient gateway disconnects (WebSocket close, timeout, connection drop) with bounded exponential backoff (up to 8 retries, capped at 30 s) and stderr retry warnings, while still exiting immediately on non-recoverable auth or configuration errors. Fixes #74782. (#75059) Thanks @shashank-poola. - Plugins/onboarding: trust optional official plugin and web-search installs selected from the official catalog so npm security scanning treats them like other source-linked official install paths. Thanks @vincentkoc. diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index 5e456a2bb0d..5459d44f608 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -33,6 +33,10 @@ vi.mock("../plugins/install.js", () => ({ const installPluginFromClawHub = vi.hoisted(() => vi.fn()); vi.mock("../plugins/clawhub.js", () => ({ + CLAWHUB_INSTALL_ERROR_CODE: { + PACKAGE_NOT_FOUND: "package_not_found", + VERSION_NOT_FOUND: "version_not_found", + }, installPluginFromClawHub, })); @@ -391,6 +395,98 @@ describe("ensureOnboardingPluginInstalled", () => { expect(captured?.initialValue).toBe("clawhub"); }); + it("falls back from ClawHub to npm when the ClawHub package is unavailable", async () => { + installPluginFromClawHub.mockResolvedValueOnce({ + ok: false, + code: "package_not_found", + error: "Package not found on ClawHub.", + }); + installPluginFromNpmSpec.mockResolvedValueOnce({ + ok: true, + pluginId: "demo-plugin", + targetDir: "/tmp/demo-plugin", + version: "2026.5.2", + npmResolution: { + name: "@openclaw/demo-plugin", + version: "2026.5.2", + resolvedSpec: "@openclaw/demo-plugin@2026.5.2", + resolvedAt: "2026-05-01T00:00:00.000Z", + }, + }); + + const result = await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Plugin", + install: { + clawhubSpec: "clawhub:demo-plugin@2026.5.2", + npmSpec: "@openclaw/demo-plugin@2026.5.2", + defaultChoice: "clawhub", + }, + }, + prompter: { + select: vi.fn(async () => "clawhub"), + confirm: vi.fn(async () => true), + note: vi.fn(async () => {}), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + } as never, + runtime: {} as never, + promptInstall: false, + }); + + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/demo-plugin@2026.5.2", + expectedPluginId: "demo-plugin", + }), + ); + expect(result.installed).toBe(true); + }); + + it("does not fall back from ClawHub to npm when ClawHub verification fails", async () => { + const confirm = vi.fn(async () => true); + const runtimeError = vi.fn(); + installPluginFromClawHub.mockResolvedValueOnce({ + ok: false, + code: "archive_integrity_mismatch", + error: "ClawHub ClawPack integrity mismatch.", + }); + + const result = await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Plugin", + install: { + clawhubSpec: "clawhub:demo-plugin@2026.5.2", + npmSpec: "@openclaw/demo-plugin@2026.5.2", + defaultChoice: "clawhub", + }, + }, + prompter: { + select: vi.fn(async () => "clawhub"), + confirm, + note: vi.fn(async () => {}), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + } as never, + runtime: { error: runtimeError } as never, + promptInstall: false, + }); + + expect(confirm).not.toHaveBeenCalled(); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(runtimeError).toHaveBeenCalledWith( + "Plugin install failed: ClawHub ClawPack integrity mismatch.", + ); + expect(result).toEqual({ + cfg: {}, + installed: false, + pluginId: "demo-plugin", + status: "failed", + }); + }); + it("does not offer local installs when the workspace only has a spoofed .git marker", async () => { await withTempDir({ prefix: "openclaw-onboarding-install-spoofed-git-" }, async (temp) => { const workspaceDir = path.join(temp, "workspace"); diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index 16ae90f243b..983ba7721e8 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -9,6 +9,7 @@ import { resolveBundledPluginSources, } from "../plugins/bundled-sources.js"; import { buildClawHubPluginInstallRecordFields } from "../plugins/clawhub-install-records.js"; +import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.js"; import { enablePluginInConfig, type PluginEnableResult } from "../plugins/enable.js"; import { resolveDefaultPluginExtensionsDir } from "../plugins/install-paths.js"; import { installPluginFromNpmSpec } from "../plugins/install.js"; @@ -42,6 +43,13 @@ export type OnboardingPluginInstallResult = { status: OnboardingPluginInstallStatus; }; +function shouldFallbackClawHubToNpm(result: { ok: false; code?: string }): boolean { + return ( + result.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND || + result.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND + ); +} + function resolveRealDirectory(dir: string): string | null { try { const resolved = fs.realpathSync(dir); @@ -852,7 +860,7 @@ export async function ensureOnboardingPluginInstalled(params: { "Plugin install", ); - if (!npmSpec) { + if (!npmSpec || !shouldFallbackClawHubToNpm(result)) { runtime.error?.(`Plugin install failed: ${sanitizeTerminalText(result.error)}`); return { cfg: next,