diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 9ed2dac93751..8408852995b0 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -33,7 +33,7 @@ Manage Gateway plugins, hook packs, and compatible bundles. ```bash openclaw plugins list [--enabled] [--verbose] [--json] openclaw plugins search [--limit ] [--json] -openclaw plugins install [--link] [--force] [--pin] [--marketplace ] +openclaw plugins install [--link] [--force] [--pin] [--marketplace ] [--acknowledge-non-clawhub-install] openclaw plugins inspect [--runtime] [--json] openclaw plugins inspect --all [--runtime] [--json] openclaw plugins info # alias for inspect @@ -134,6 +134,7 @@ openclaw plugins install --marketplace # marketplace (expli openclaw plugins install --force # overwrite existing install openclaw plugins install --pin # pin resolved npm version openclaw plugins install clawhub: --acknowledge-clawhub-risk +openclaw plugins install npm: --acknowledge-non-clawhub-install openclaw plugins install --dangerously-force-unsafe-install ``` @@ -145,6 +146,16 @@ sources with guarded environment variables. See Bare package names install from npm by default during the launch cutover, unless they match a bundled or official plugin id, in which case OpenClaw uses that local/official copy instead of hitting the npm registry. Use `npm:` when you deliberately want an external npm package instead. Use `clawhub:` for ClawHub. Treat plugin installs like running code; prefer pinned versions. + +ClawHub installs carry ClawHub package trust metadata. Installs from npm, +`npm-pack:`, git, local paths or archives, and marketplace sources are outside +ClawHub review. Interactive installs warn and ask before continuing. +Noninteractive installs must pass `--acknowledge-non-clawhub-install` after you +review and trust the source. This acknowledgement is separate from +`--acknowledge-clawhub-risk`, which only applies to risky ClawHub release trust +warnings. + + `plugins search` queries ClawHub for installable `code-plugin` and `bundle-plugin` packages (not skills; use `openclaw skills search` for those). Default `--limit` is 20, capped at 100. It only reads the remote catalog: no diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index e277df818ba8..57343f712a13 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -324,6 +324,7 @@ Plugins run in-process with the Gateway - treat them as trusted code. - Only install from sources you trust; prefer explicit `plugins.allow` allowlists; review plugin config before enabling; restart the Gateway after plugin changes. - Installing/updating (`openclaw plugins install `, `openclaw plugins update `) runs untrusted code: - The install path is the per-plugin directory under the active plugin install root. + - ClawHub installs include ClawHub package trust metadata. npm, `npm-pack:`, git, local path/archive, and marketplace installs are outside ClawHub review; noninteractive installs from those sources require `--acknowledge-non-clawhub-install` after you review and trust the source. - OpenClaw does not run built-in local dangerous-code blocking during install/update. Use `security.installPolicy` for operator-owned local allow/block decisions and `openclaw security audit --deep` for diagnostic scanning. - npm and git plugin installs run package-manager dependency convergence only during the explicit install/update flow. Local paths and archives are treated as self-contained packages; OpenClaw copies/references them without running `npm install`. - Prefer pinned exact versions (`@scope/pkg@1.2.3`) and inspect the unpacked code before enabling. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 8f1beacc4a3a..216c5c5d19fb 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -51,18 +51,21 @@ bundled, official external, and source-only plugins, see openclaw plugins install clawhub: # From npm. - openclaw plugins install npm: + openclaw plugins install npm: --acknowledge-non-clawhub-install # From git. - openclaw plugins install git:github.com//@ + openclaw plugins install git:github.com//@ --acknowledge-non-clawhub-install # From a local development checkout. - openclaw plugins install ./my-plugin - openclaw plugins install --link ./my-plugin + openclaw plugins install ./my-plugin --acknowledge-non-clawhub-install + openclaw plugins install --link ./my-plugin --acknowledge-non-clawhub-install ``` Treat plugin installs like running code. Prefer pinned versions for - reproducible production installs. + reproducible production installs. Sources outside ClawHub are not + ClawHub-reviewed; noninteractive installs from npm, git, local paths or + archives, `npm-pack:`, or marketplace sources require + `--acknowledge-non-clawhub-install` after you review and trust the source. @@ -112,13 +115,13 @@ bundled, official external, and source-only plugins, see ### Choose an install source -| Source | Use when | Example | -| ----------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------- | -| ClawHub | You want OpenClaw-native discovery, scans, version metadata, and install hints | `openclaw plugins install clawhub:` | -| npm | You need direct npm registry or dist-tag workflows | `openclaw plugins install npm:` | -| git | You need a branch, tag, or commit from a repository | `openclaw plugins install git:github.com//@` | -| local path | You are developing or testing a plugin on the same machine | `openclaw plugins install --link ./my-plugin` | -| marketplace | You are installing a Claude-compatible marketplace plugin | `openclaw plugins install --marketplace ` | +| Source | Use when | Example | +| ----------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| ClawHub | You want OpenClaw-native discovery, scans, version metadata, and install hints | `openclaw plugins install clawhub:` | +| npm | You need direct npm registry or dist-tag workflows | `openclaw plugins install npm: --acknowledge-non-clawhub-install` | +| git | You need a branch, tag, or commit from a repository | `openclaw plugins install git:github.com//@ --acknowledge-non-clawhub-install` | +| local path | You are developing or testing a plugin on the same machine | `openclaw plugins install --link ./my-plugin --acknowledge-non-clawhub-install` | +| marketplace | You are installing a Claude-compatible marketplace plugin | `openclaw plugins install --marketplace --acknowledge-non-clawhub-install` | Bare package specs have special compatibility behavior: a bare name that matches a bundled plugin id uses that bundled source; a bare name that matches diff --git a/src/cli/hooks-cli.test.ts b/src/cli/hooks-cli.test.ts index 2e70aca57a39..748c73f1f782 100644 --- a/src/cli/hooks-cli.test.ts +++ b/src/cli/hooks-cli.test.ts @@ -1,9 +1,21 @@ // Hooks CLI tests cover hook command registration and output behavior. -import { describe, expect, it } from "vitest"; +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { HookStatusReport } from "../hooks/hooks-status.js"; -import { formatHookInfo, formatHooksCheck, formatHooksList } from "./hooks-cli.js"; +import { + formatHookInfo, + formatHooksCheck, + formatHooksList, + registerHooksCli, +} from "./hooks-cli.js"; import { createEmptyInstallChecks } from "./requirements-test-fixtures.js"; +const runPluginInstallCommandMock = vi.hoisted(() => vi.fn()); + +vi.mock("./plugins-install-command.js", () => ({ + runPluginInstallCommand: runPluginInstallCommandMock, +})); + const report: HookStatusReport = { workspaceDir: "/tmp/workspace", managedHooksDir: "/tmp/hooks", @@ -32,6 +44,10 @@ const report: HookStatusReport = { ], }; +beforeEach(() => { + runPluginInstallCommandMock.mockReset(); +}); + function createPluginManagedHookReport(): HookStatusReport { return { workspaceDir: "/tmp/workspace", @@ -106,4 +122,25 @@ describe("hooks cli formatting", () => { expect(output).toContain("voice-call"); expect(output).toContain("Managed by plugin"); }); + + it("forwards non-ClawHub install acknowledgement through deprecated install alias", async () => { + runPluginInstallCommandMock.mockResolvedValueOnce(undefined); + const program = new Command().exitOverride(); + registerHooksCli(program); + + await program.parseAsync( + ["hooks", "install", "npm:demo-hooks", "--acknowledge-non-clawhub-install"], + { + from: "user", + }, + ); + + expect(runPluginInstallCommandMock).toHaveBeenCalledWith({ + raw: "npm:demo-hooks", + opts: expect.objectContaining({ + acknowledgeNonClawHubInstall: true, + }), + invalidateRuntimeCache: false, + }); + }); }); diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index 2dedb263200c..48ba6c9a77fc 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -47,6 +47,17 @@ type HooksUpdateOptions = { dryRun?: boolean; }; +type HooksInstallOptions = { + acknowledgeNonClawHubInstall?: boolean; + acknowledgeNonClawhubInstall?: boolean; + link?: boolean; + pin?: boolean; +}; + +function normalizeHooksNonClawHubInstallOption(opts: HooksInstallOptions): boolean { + return opts.acknowledgeNonClawhubInstall === true || opts.acknowledgeNonClawHubInstall === true; +} + function mergeHookEntries(pluginEntries: HookEntry[], workspaceEntries: HookEntry[]): HookEntry[] { return resolveHookEntries([...pluginEntries, ...workspaceEntries]); } @@ -565,11 +576,23 @@ export function registerHooksCli(program: Command): void { .argument("", "Path to a hook pack or npm package spec") .option("-l, --link", "Link a local path instead of copying", false) .option("--pin", "Record npm installs as exact resolved @", false) - .action(async (raw: string, opts: { link?: boolean; pin?: boolean }) => { + .option( + "--acknowledge-non-clawhub-install", + "Acknowledge non-ClawHub hook pack install provenance without prompting", + false, + ) + .action(async (raw: string, opts: HooksInstallOptions) => { defaultRuntime.log( theme.warn("`openclaw hooks install` is deprecated; use `openclaw plugins install`."), ); - await runPluginInstallCommand({ raw, opts, invalidateRuntimeCache: false }); + await runPluginInstallCommand({ + raw, + opts: { + ...opts, + acknowledgeNonClawHubInstall: normalizeHooksNonClawHubInstallOption(opts), + }, + invalidateRuntimeCache: false, + }); }); hooks diff --git a/src/cli/non-clawhub-install-acknowledgement.ts b/src/cli/non-clawhub-install-acknowledgement.ts new file mode 100644 index 000000000000..58b106f27c58 --- /dev/null +++ b/src/cli/non-clawhub-install-acknowledgement.ts @@ -0,0 +1,71 @@ +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import { theme } from "../../packages/terminal-core/src/theme.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { promptYesNo } from "./prompt.js"; + +export const NON_CLAWHUB_INSTALL_ACK_FLAG = "--acknowledge-non-clawhub-install"; + +export type NonClawHubInstallSourceClass = + | "git" + | "local-archive" + | "local-path" + | "marketplace" + | "npm" + | "npm-pack"; + +export type NonClawHubInstallAcknowledgementOptions = { + acknowledgeNonClawHubInstall?: boolean; +}; + +const sourceClassLabels: Record = { + git: "Git repository", + "local-archive": "local archive", + "local-path": "local path", + marketplace: "marketplace source", + npm: "npm registry", + "npm-pack": "local npm-pack archive", +}; + +function canPromptForNonClawHubInstall(): boolean { + return process.stdin.isTTY && process.stdout.isTTY; +} + +function formatSourceClass(sourceClass: NonClawHubInstallSourceClass): string { + return sourceClassLabels[sourceClass]; +} + +export function formatNonClawHubInstallWarning(params: { + sourceClass: NonClawHubInstallSourceClass; + spec: string; +}): string { + const sourceLabel = formatSourceClass(params.sourceClass); + const spec = sanitizeTerminalText(params.spec); + return [ + `WARNING - Installing plugin from ${sourceLabel}: ${spec}`, + "This source is outside ClawHub review and trust metadata. Only continue if you trust the publisher, package contents, and install source.", + ].join("\n"); +} + +export async function confirmNonClawHubInstall(params: { + acknowledged?: boolean; + runtime: RuntimeEnv; + sourceClass: NonClawHubInstallSourceClass; + spec: string; +}): Promise { + const warning = formatNonClawHubInstallWarning({ + sourceClass: params.sourceClass, + spec: params.spec, + }); + if (params.acknowledged) { + params.runtime.log(theme.warn(warning)); + return true; + } + if (canPromptForNonClawHubInstall()) { + params.runtime.log(theme.warn(warning)); + return await promptYesNo("Install this non-ClawHub plugin source?"); + } + params.runtime.error( + `${warning}\nInstall cancelled; rerun with ${NON_CLAWHUB_INSTALL_ACK_FLAG} after reviewing the source.`, + ); + return false; +} diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index cc3f075c3f60..16f23afe20ad 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -30,6 +30,7 @@ import { readConfigFileSnapshot, readConfigFileSnapshotForWrite, parseClawHubPluginSpec, + promptYesNo, recordHookInstall, recordPluginInstall, resetPluginsCliTestState, @@ -44,6 +45,8 @@ import { const CLI_STATE_ROOT = "/tmp/openclaw-state"; const ORIGINAL_OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR; 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"); const PROFILE_STATE_ROOT = "/tmp/openclaw-ledger-profile"; const OFFICIAL_EXTERNAL_NPM_INSTALLS_WITHOUT_INTEGRITY = listOfficialExternalPluginCatalogEntries() @@ -224,6 +227,21 @@ function primeNpmPluginFallback(pluginId = "demo") { return { cfg, enabledCfg }; } +function primeSuccessfulPluginPersistence(pluginId = "demo") { + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig(pluginId); + + loadConfig.mockReturnValue(cfg); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + return { cfg, enabledCfg }; +} + function createPathHookPackInstalledConfig(tmpRoot: string): OpenClawConfig { return { hooks: { @@ -431,6 +449,43 @@ function runtimeLogsContain(fragment: string): boolean { return runtimeLogs.some((line) => line.includes(fragment)); } +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"); + } +} + +const NON_CLAWHUB_INSTALL_ACK_FLAG = "--acknowledge-non-clawhub-install"; + +function withNonClawHubInstallAcknowledgement(args: string[]): string[] { + if (args.includes(NON_CLAWHUB_INSTALL_ACK_FLAG)) { + return args; + } + return [...args, NON_CLAWHUB_INSTALL_ACK_FLAG]; +} + +async function runAcknowledgedPluginsInstallCommand(args: string[]): Promise { + await runPluginsCommand(withNonClawHubInstallAcknowledgement(args)); +} + function primeBlockedPluginConfigMutation( params: { blockHooks?: boolean; config?: OpenClawConfig } = {}, ): void { @@ -595,6 +650,7 @@ describe("plugins cli install", () => { } else { process.env.OPENCLAW_NIX_MODE = ORIGINAL_OPENCLAW_NIX_MODE; } + restoreTty(); }); it("shows the force overwrite option in install help", async () => { @@ -608,6 +664,7 @@ describe("plugins cli install", () => { const helpText = installCommand?.helpInformation() ?? ""; expect(helpText).toContain("--force"); + expect(helpText).toContain("--acknowledge-non-clawhub-install"); expect(helpText).toContain("Overwrite an existing installed plugin or"); expect(helpText).toContain("hook pack"); }); @@ -615,9 +672,9 @@ describe("plugins cli install", () => { it("refuses plugin installs in Nix mode before installer side effects", async () => { process.env.OPENCLAW_NIX_MODE = "1"; - await expect(runPluginsCommand(["plugins", "install", "@acme/demo"])).rejects.toThrow( - "OPENCLAW_NIX_MODE=1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "@acme/demo"]), + ).rejects.toThrow("OPENCLAW_NIX_MODE=1"); expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); expect(installPluginFromPath).not.toHaveBeenCalled(); @@ -634,7 +691,9 @@ describe("plugins cli install", () => { error: "package.json missing openclaw.hooks", }); - await expect(runPluginsCommand(["plugins", "install", spec])).rejects.toThrow("__exit__:1"); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", spec]), + ).rejects.toThrow("__exit__:1"); expect(installHooksFromNpmSpec).toHaveBeenCalledTimes(1); expect(hookNpmInstallCall().inspection).toBe("package-kind"); @@ -676,7 +735,7 @@ describe("plugins cli install", () => { }); recordHookInstall.mockReturnValue(installedCfg); - await runPluginsCommand(["plugins", "install", "@acme/demo-hooks"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "@acme/demo-hooks"]); expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); expect(installHooksFromNpmSpec).toHaveBeenCalledTimes(2); @@ -698,9 +757,9 @@ describe("plugins cli install", () => { }, }); - await expect(runPluginsCommand(["plugins", "install", "@acme/demo-hooks"])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "@acme/demo-hooks"]), + ).rejects.toThrow("__exit__:1"); expect(installHooksFromNpmSpec).not.toHaveBeenCalled(); expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); @@ -722,9 +781,9 @@ describe("plugins cli install", () => { }, }); - await expect(runPluginsCommand(["plugins", "install", "@acme/demo-hooks"])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "@acme/demo-hooks"]), + ).rejects.toThrow("__exit__:1"); expect(installHooksFromNpmSpec).toHaveBeenCalledTimes(1); expect(hookNpmInstallCall().inspection).toBe("package-kind"); @@ -746,9 +805,9 @@ describe("plugins cli install", () => { }); try { - await expect(runPluginsCommand(["plugins", "install", localPath])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", localPath]), + ).rejects.toThrow("__exit__:1"); } finally { fs.rmSync(localPath, { recursive: true, force: true }); } @@ -766,9 +825,9 @@ describe("plugins cli install", () => { installHooksFromPath.mockResolvedValue(createHookPackInstallResult(localPath)); try { - await expect(runPluginsCommand(["plugins", "install", localPath])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", localPath]), + ).rejects.toThrow("__exit__:1"); } finally { fs.rmSync(localPath, { recursive: true, force: true }); } @@ -810,7 +869,11 @@ describe("plugins cli install", () => { recordHookInstall.mockReturnValue(installedCfg); try { - await runPluginsCommand(["plugins", "install", path.basename(localPath)]); + await runAcknowledgedPluginsInstallCommand([ + "plugins", + "install", + path.basename(localPath), + ]); } finally { fs.rmSync(localPath, { recursive: true, force: true }); } @@ -831,9 +894,9 @@ describe("plugins cli install", () => { error: "package.json missing openclaw.hooks", }); - await expect(runPluginsCommand(["plugins", "install", "@acme/demo-plugin"])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "@acme/demo-plugin"]), + ).rejects.toThrow("__exit__:1"); expect(installHooksFromNpmSpec).not.toHaveBeenCalled(); expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); @@ -850,9 +913,9 @@ describe("plugins cli install", () => { }); try { - await expect(runPluginsCommand(["plugins", "install", localPath])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", localPath]), + ).rejects.toThrow("__exit__:1"); } finally { fs.rmSync(localPath, { recursive: true, force: true }); } @@ -873,9 +936,9 @@ describe("plugins cli install", () => { }); try { - await expect(runPluginsCommand(["plugins", "install", archivePath])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", archivePath]), + ).rejects.toThrow("__exit__:1"); } finally { fs.rmSync(archivePath, { force: true }); } @@ -896,9 +959,9 @@ describe("plugins cli install", () => { packageKind: "plugin-capable", }); - await expect(runPluginsCommand(["plugins", "install", "@acme/dual-package"])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "@acme/dual-package"]), + ).rejects.toThrow("__exit__:1"); expect(installHooksFromNpmSpec).toHaveBeenCalledTimes(1); expect(hookNpmInstallCall().inspection).toBe("package-kind"); @@ -918,9 +981,9 @@ describe("plugins cli install", () => { }); try { - await expect(runPluginsCommand(["plugins", "install", localPath])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", localPath]), + ).rejects.toThrow("__exit__:1"); } finally { fs.rmSync(localPath, { recursive: true, force: true }); } @@ -943,9 +1006,9 @@ describe("plugins cli install", () => { }); try { - await expect(runPluginsCommand(["plugins", "install", localPath])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", localPath]), + ).rejects.toThrow("__exit__:1"); } finally { fs.rmSync(localPath, { recursive: true, force: true }); } @@ -963,9 +1026,9 @@ describe("plugins cli install", () => { primeBlockedPluginConfigMutation(); installHooksFromNpmSpec.mockRejectedValue(new Error("hook validation exploded")); - await expect(runPluginsCommand(["plugins", "install", "@acme/demo-plugin"])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "@acme/demo-plugin"]), + ).rejects.toThrow("__exit__:1"); expect(installHooksFromNpmSpec).toHaveBeenCalledTimes(1); expect(hookNpmInstallCall().inspection).toBe("package-kind"); @@ -981,9 +1044,9 @@ describe("plugins cli install", () => { installHooksFromPath.mockRejectedValue(new Error("hook validation exploded")); try { - await expect(runPluginsCommand(["plugins", "install", localPluginDir])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", localPluginDir]), + ).rejects.toThrow("__exit__:1"); } finally { fs.rmSync(localPluginDir, { recursive: true, force: true }); } @@ -1048,7 +1111,9 @@ describe("plugins cli install", () => { primeBlockedPluginConfigMutation(); setup(); - await expect(runPluginsCommand(args)).rejects.toThrow("__exit__:1"); + await expect(runPluginsCommand(withNonClawHubInstallAcknowledgement(args))).rejects.toThrow( + "__exit__:1", + ); expect(installer).not.toHaveBeenCalled(); expect(writeConfigFile).not.toHaveBeenCalled(); @@ -1062,7 +1127,9 @@ describe("plugins cli install", () => { primeBlockedPluginConfigMutation(); findBundledPluginSourceMock.mockReturnValue(undefined); - await expect(runPluginsCommand(["plugins", "install", "brave"])).rejects.toThrow("__exit__:1"); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "brave"]), + ).rejects.toThrow("__exit__:1"); expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); expect(writeConfigFile).not.toHaveBeenCalled(); @@ -1079,7 +1146,9 @@ describe("plugins cli install", () => { localPath: `/app/dist/extensions/${pluginId}`, }); - await expect(runPluginsCommand(["plugins", "install", pluginId])).rejects.toThrow("__exit__:1"); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", pluginId]), + ).rejects.toThrow("__exit__:1"); expect(installPluginFromPath).not.toHaveBeenCalled(); expect(writeConfigFile).not.toHaveBeenCalled(); @@ -1104,7 +1173,13 @@ describe("plugins cli install", () => { try { await expect( - runPluginsCommand(["plugins", "install", "demo", "--marketplace", "local/repo"]), + runAcknowledgedPluginsInstallCommand([ + "plugins", + "install", + "demo", + "--marketplace", + "local/repo", + ]), ).rejects.toThrow("__exit__:1"); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); @@ -1117,7 +1192,14 @@ describe("plugins cli install", () => { it("exits when --marketplace is combined with --link", async () => { await expect( - runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--link"]), + runAcknowledgedPluginsInstallCommand([ + "plugins", + "install", + "alpha", + "--marketplace", + "local/repo", + "--link", + ]), ).rejects.toThrow("__exit__:1"); expect(runtimeErrors.at(-1)).toContain("--link is not supported with --marketplace."); @@ -1126,7 +1208,7 @@ describe("plugins cli install", () => { it("exits when --force is combined with --link", async () => { await expect( - runPluginsCommand(["plugins", "install", "./plugin", "--link", "--force"]), + runAcknowledgedPluginsInstallCommand(["plugins", "install", "./plugin", "--link", "--force"]), ).rejects.toThrow("__exit__:1"); expect(runtimeErrors.at(-1)).toContain("--force is not supported with --link."); @@ -1136,7 +1218,13 @@ describe("plugins cli install", () => { it("exits when marketplace install fails", async () => { await expect( - runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]), + runAcknowledgedPluginsInstallCommand([ + "plugins", + "install", + "alpha", + "--marketplace", + "local/repo", + ]), ).rejects.toThrow("__exit__:1"); expect(marketplaceInstallCall().marketplace).toBe("local/repo"); @@ -1148,7 +1236,13 @@ describe("plugins cli install", () => { const extensionsDir = useProfileExtensionsDir(); await expect( - runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]), + runAcknowledgedPluginsInstallCommand([ + "plugins", + "install", + "alpha", + "--marketplace", + "local/repo", + ]), ).rejects.toThrow("__exit__:1"); expect(marketplaceInstallCall().extensionsDir).toBe(extensionsDir); @@ -1176,7 +1270,9 @@ describe("plugins cli install", () => { legacyIssues: [], }); - await expect(runPluginsCommand(["plugins", "install", "alpha"])).rejects.toThrow("__exit__:1"); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "alpha"]), + ).rejects.toThrow("__exit__:1"); expect(runtimeErrors.at(-1)).toContain( "Config invalid; run `openclaw doctor --fix` before installing plugins.", @@ -1226,7 +1322,13 @@ describe("plugins cli install", () => { warnings: ["slot adjusted"], }); - await runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]); + await runAcknowledgedPluginsInstallCommand([ + "plugins", + "install", + "alpha", + "--marketplace", + "local/repo", + ]); expect(persistedInstallRecord("alpha").source).toBe("marketplace"); expect(persistedInstallRecord("alpha").installPath).toBe(cliInstallPath("alpha")); @@ -1240,7 +1342,14 @@ describe("plugins cli install", () => { it("passes force through as overwrite mode for marketplace installs", async () => { await expect( - runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--force"]), + runAcknowledgedPluginsInstallCommand([ + "plugins", + "install", + "alpha", + "--marketplace", + "local/repo", + "--force", + ]), ).rejects.toThrow("__exit__:1"); expect(marketplaceInstallCall().marketplace).toBe("local/repo"); @@ -1248,6 +1357,176 @@ describe("plugins cli install", () => { expect(marketplaceInstallCall().mode).toBe("update"); }); + it("requires acknowledgement for noninteractive non-ClawHub plugin installs", async () => { + setTty(false); + primeSuccessfulPluginPersistence("demo"); + installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo")); + + await expect(runPluginsCommand(["plugins", "install", "npm:demo"])).rejects.toThrow( + "__exit__:1", + ); + + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain("outside ClawHub review"); + expect(runtimeErrors.at(-1)).toContain(NON_CLAWHUB_INSTALL_ACK_FLAG); + }); + + it("prompts interactive users before non-ClawHub plugin installs and cancels on no", async () => { + setTty(true); + promptYesNo.mockResolvedValueOnce(false); + primeSuccessfulPluginPersistence("demo"); + installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo")); + + await expect(runPluginsCommand(["plugins", "install", "npm:demo"])).rejects.toThrow( + "__exit__:1", + ); + + expect(promptYesNo).toHaveBeenCalledWith("Install this non-ClawHub plugin source?"); + expect(runtimeLogsContain("Installing plugin from npm registry")).toBe(true); + expect(runtimeLogsContain("outside ClawHub review")).toBe(true); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + }); + + it("prompts interactive users before non-ClawHub plugin installs and proceeds on yes", async () => { + setTty(true); + promptYesNo.mockResolvedValueOnce(true); + primeSuccessfulPluginPersistence("demo"); + installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo")); + + await runPluginsCommand(["plugins", "install", "npm:demo"]); + + expect(promptYesNo).toHaveBeenCalledWith("Install this non-ClawHub plugin source?"); + expect(runtimeLogsContain("Installing plugin from npm registry")).toBe(true); + expect(runtimeLogsContain("outside ClawHub review")).toBe(true); + expect(installPluginFromNpmSpec).toHaveBeenCalledTimes(1); + expect(persistedInstallRecord("demo").source).toBe("npm"); + }); + + it.each([ + { + label: "npm", + args: ["plugins", "install", "npm:demo"], + expectedSource: "npm registry", + setup: () => { + primeSuccessfulPluginPersistence("demo"); + installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo")); + }, + }, + { + label: "npm-pack", + args: ["plugins", "install", "npm-pack:/tmp/demo.tgz"], + expectedSource: "local npm-pack archive", + setup: () => { + primeSuccessfulPluginPersistence("demo"); + installPluginFromNpmPackArchive.mockResolvedValue(createNpmPackPluginInstallResult("demo")); + }, + }, + { + label: "git", + args: ["plugins", "install", "git:github.com/acme/demo@v1.2.3"], + expectedSource: "Git repository", + setup: () => { + primeSuccessfulPluginPersistence("demo"); + installPluginFromGitSpec.mockResolvedValue(createGitPluginInstallResult("demo")); + }, + }, + { + label: "marketplace", + args: ["plugins", "install", "demo", "--marketplace", "local/repo"], + expectedSource: "marketplace source", + setup: () => { + primeSuccessfulPluginPersistence("demo"); + installPluginFromMarketplace.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: cliInstallPath("demo"), + extensions: ["index.js"], + version: "1.2.3", + marketplaceName: "Claude", + marketplaceSource: "local/repo", + marketplacePlugin: "demo", + }); + }, + }, + ])( + "warns for acknowledged $label installs outside ClawHub", + async ({ args, expectedSource, setup }) => { + setup(); + + await runAcknowledgedPluginsInstallCommand(args); + + expect(runtimeLogsContain(`Installing plugin from ${expectedSource}`)).toBe(true); + expect(runtimeLogsContain("outside ClawHub review")).toBe(true); + }, + ); + + it.each([ + { + label: "local path", + expectedSource: "local path", + suffix: "", + }, + { + label: "local archive", + expectedSource: "local archive", + suffix: ".tgz", + }, + ])( + "warns for acknowledged $label installs outside ClawHub", + async ({ expectedSource, suffix }) => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-source-")); + const localSource = suffix ? `${tempRoot}${suffix}` : tempRoot; + if (suffix) { + fs.writeFileSync(localSource, "archive"); + } + primeSuccessfulPluginPersistence("demo"); + installPluginFromPath.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: cliInstallPath("demo"), + version: "1.2.3", + extensions: ["./dist/index.js"], + }); + + try { + await runAcknowledgedPluginsInstallCommand(["plugins", "install", localSource]); + } finally { + fs.rmSync(localSource, { recursive: true, force: true }); + if (suffix) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + } + + expect(runtimeLogsContain(`Installing plugin from ${expectedSource}`)).toBe(true); + expect(runtimeLogsContain("outside ClawHub review")).toBe(true); + }, + ); + + it("does not show the non-ClawHub warning for explicit ClawHub installs", async () => { + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("demo"); + loadConfig.mockReturnValue(cfg); + parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); + installPluginFromClawHub.mockResolvedValue( + createClawHubInstallResult({ + pluginId: "demo", + packageName: "demo", + version: "1.2.3", + channel: "official", + }), + ); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "clawhub:demo"]); + + expect(runtimeLogsContain("outside ClawHub review")).toBe(false); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + }); + it("installs ClawHub plugins and persists source metadata", async () => { const cfg = { plugins: { @@ -1271,7 +1550,7 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "clawhub:demo"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "clawhub:demo"]); expect(clawHubInstallCall().spec).toBe("clawhub:demo"); const record = persistedInstallRecord("demo"); @@ -1319,7 +1598,12 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "clawhub:demo", "--acknowledge-clawhub-risk"]); + await runAcknowledgedPluginsInstallCommand([ + "plugins", + "install", + "clawhub:demo", + "--acknowledge-clawhub-risk", + ]); expect(installPluginFromClawHub).toHaveBeenCalledWith( expect.objectContaining({ @@ -1346,9 +1630,9 @@ describe("plugins cli install", () => { warning: "WARNING - ClawHub found security risks in this release", }); - await expect(runPluginsCommand(["plugins", "install", "clawhub:demo"])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "clawhub:demo"]), + ).rejects.toThrow("__exit__:1"); expect(runtimeErrors.at(-1)).toContain("--acknowledge-clawhub-risk"); }); @@ -1363,9 +1647,9 @@ describe("plugins cli install", () => { '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", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "clawhub:demo"]), + ).rejects.toThrow("__exit__:1"); expect(runtimeErrors.at(-1)).toContain("ClawHub blocked artifact download"); }); @@ -1391,7 +1675,7 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "clawhub:demo"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "clawhub:demo"]); expect(clawHubInstallCall().extensionsDir).toBe(extensionsDir); expect(clawHubInstallCall().spec).toBe("clawhub:demo"); @@ -1427,7 +1711,7 @@ describe("plugins cli install", () => { requiresConfig: true, }); - await runPluginsCommand(["plugins", "install", pluginId]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", pluginId]); const writtenConfig = writeConfigFile.mock.calls[ writeConfigFile.mock.calls.length - 1 @@ -1474,7 +1758,7 @@ describe("plugins cli install", () => { }); enablePluginInConfig.mockReturnValue({ config: enabledCfg }); - await runPluginsCommand(["plugins", "install", pluginId]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", pluginId]); expect(enablePluginInConfig).toHaveBeenCalledTimes(1); expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); @@ -1506,7 +1790,7 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "clawhub:demo", "--force"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "clawhub:demo", "--force"]); expect(clawHubInstallCall().spec).toBe("clawhub:demo"); expect(clawHubInstallCall().mode).toBe("update"); @@ -1536,7 +1820,7 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "clawhub:demo@1.2.3"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "clawhub:demo@1.2.3"]); expect(clawHubInstallCall().spec).toBe("clawhub:demo@1.2.3"); const record = persistedInstallRecord("demo"); @@ -1567,7 +1851,7 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "brave"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "brave"]); expect(findBundledPluginSourceMock).toHaveBeenCalledWith({ lookup: { kind: "pluginId", value: "brave" }, @@ -1576,6 +1860,8 @@ describe("plugins cli install", () => { expect(npmInstallCall().spec).toBe("@openclaw/brave-plugin"); expect(npmInstallCall().expectedPluginId).toBe("brave"); expect(npmInstallCall().trustedSourceLinkedOfficialInstall).toBe(true); + expect(runtimeLogsContain("Installing plugin from npm registry")).toBe(true); + expect(runtimeLogsContain("outside ClawHub review")).toBe(true); const record = persistedInstallRecord("brave"); expect(record.source).toBe("npm"); expect(record.spec).toBe("@openclaw/brave-plugin"); @@ -1598,7 +1884,7 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "wecom"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "wecom"]); expect(npmInstallCall().spec).toBe("@wecom/wecom-openclaw-plugin@2026.5.7"); expect(npmInstallCall().expectedPluginId).toBe("wecom-openclaw-plugin"); @@ -1622,7 +1908,7 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", pluginId]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", pluginId]); expect(findBundledPluginSourceMock).toHaveBeenCalledWith({ lookup: { kind: "pluginId", value: pluginId }, @@ -1649,7 +1935,9 @@ describe("plugins cli install", () => { "aborted: npm package integrity drift detected for @wecom/wecom-openclaw-plugin@2026.5.7", }); - await expect(runPluginsCommand(["plugins", "install", "wecom"])).rejects.toThrow("__exit__:1"); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "wecom"]), + ).rejects.toThrow("__exit__:1"); expect(npmInstallCall().trustedSourceLinkedOfficialInstall).toBe(true); expect(hookNpmInstallCall().spec).toBe("@wecom/wecom-openclaw-plugin@2026.5.7"); @@ -1669,10 +1957,12 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "demo"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "demo"]); expect(installPluginFromClawHub).not.toHaveBeenCalled(); expect(npmInstallCall().spec).toBe("demo"); + expect(runtimeLogsContain("Installing plugin from npm registry")).toBe(true); + expect(runtimeLogsContain("outside ClawHub review")).toBe(true); const record = persistedInstallRecord("demo"); expect(record.source).toBe("npm"); expect(record.spec).toBe("demo"); @@ -1703,7 +1993,7 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "demo"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "demo"]); const record = persistedInstallRecord("demo"); expect(record.spec).toBe("demo"); @@ -1722,7 +2012,7 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "demo@beta"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "demo@beta"]); expect(installPluginFromClawHub).not.toHaveBeenCalled(); expect(npmInstallCall().spec).toBe("demo@beta"); @@ -1741,10 +2031,12 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "npm:demo"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "npm:demo"]); expect(npmInstallCall().spec).toBe("demo"); expect(npmInstallCall().mode).toBe("install"); + expect(runtimeLogsContain("Installing plugin from npm registry")).toBe(true); + expect(runtimeLogsContain("outside ClawHub review")).toBe(true); expect(installPluginFromClawHub).not.toHaveBeenCalled(); expect(persistedInstallRecord("demo").source).toBe("npm"); expect(persistedInstallRecord("demo").spec).toBe("demo"); @@ -1766,7 +2058,7 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", `npm-pack:${archivePath}`]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", `npm-pack:${archivePath}`]); expect(npmPackInstallCall().archivePath).toBe(archivePath); expect(npmPackInstallCall().mode).toBe("install"); @@ -1799,11 +2091,13 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "npm:brave"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "npm:brave"]); expect(npmInstallCall().spec).toBe("brave"); expect(npmInstallCall().expectedPluginId).toBeUndefined(); expect(npmInstallCall().trustedSourceLinkedOfficialInstall).toBeUndefined(); + expect(runtimeLogsContain("Installing plugin from npm registry")).toBe(true); + expect(runtimeLogsContain("outside ClawHub review")).toBe(true); expect(installPluginFromClawHub).not.toHaveBeenCalled(); }); @@ -1820,7 +2114,7 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "npm:@openclaw/discord"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "npm:@openclaw/discord"]); expect(npmInstallCall().spec).toBe("@openclaw/discord"); expect(npmInstallCall().expectedPluginId).toBe("discord"); @@ -1842,7 +2136,7 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "@openclaw/discord"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "@openclaw/discord"]); expect(npmInstallCall().spec).toBe("@openclaw/discord"); expect(npmInstallCall().expectedPluginId).toBe("discord"); @@ -1916,7 +2210,11 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "@wecom/wecom-openclaw-plugin@latest"]); + await runAcknowledgedPluginsInstallCommand([ + "plugins", + "install", + "@wecom/wecom-openclaw-plugin@latest", + ]); // Alternate selectors stay trusted by catalog package name, but must not // inherit catalog integrity unless the install spec matches exactly. @@ -1941,7 +2239,7 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "npm:demo"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "npm:demo"]); expect(npmInstallCall().extensionsDir).toBe(extensionsDir); expect(npmInstallCall().spec).toBe("demo"); @@ -1956,7 +2254,7 @@ describe("plugins cli install", () => { enablePluginInConfig.mockReturnValue({ config: enabledCfg }); recordPluginInstall.mockReturnValue(enabledCfg); - await runPluginsCommand([ + await runAcknowledgedPluginsInstallCommand([ "plugins", "install", "npm:demo", @@ -1987,9 +2285,9 @@ describe("plugins cli install", () => { code: "missing_openclaw_hooks", }); - await expect(runPluginsCommand(["plugins", "install", "npm:demo"])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "npm:demo"]), + ).rejects.toThrow("__exit__:1"); expect(installPluginFromClawHub).not.toHaveBeenCalled(); expect(runtimeErrors.at(-1)).toContain("npm install failed"); @@ -2007,9 +2305,9 @@ describe("plugins cli install", () => { error: "HOOK.md missing in /tmp/demo-hook", }); - await expect(runPluginsCommand(["plugins", "install", "npm:demo-hook"])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "npm:demo-hook"]), + ).rejects.toThrow("__exit__:1"); expect(runtimeErrors.at(-1)).toContain("npm install failed"); expect(runtimeErrors.at(-1)).toContain( @@ -2035,7 +2333,7 @@ describe("plugins cli install", () => { }); await expect( - runPluginsCommand(["plugins", "install", "npm:@openclaw/whatsapp"]), + runAcknowledgedPluginsInstallCommand(["plugins", "install", "npm:@openclaw/whatsapp"]), ).rejects.toThrow("__exit__:1"); expect(installPluginFromClawHub).not.toHaveBeenCalled(); @@ -2059,9 +2357,9 @@ describe("plugins cli install", () => { code: "missing_openclaw_hooks", }); - await expect(runPluginsCommand(["plugins", "install", "npm:memory-lancedb"])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "npm:memory-lancedb"]), + ).rejects.toThrow("__exit__:1"); expect(npmInstallCall().spec).toBe("memory-lancedb"); expect(installPluginFromClawHub).not.toHaveBeenCalled(); @@ -2073,7 +2371,9 @@ describe("plugins cli install", () => { it("rejects empty npm: prefix installs before resolver lookup", async () => { loadConfig.mockReturnValue({} as OpenClawConfig); - await expect(runPluginsCommand(["plugins", "install", "npm:"])).rejects.toThrow("__exit__:1"); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "npm:"]), + ).rejects.toThrow("__exit__:1"); expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); expect(installPluginFromClawHub).not.toHaveBeenCalled(); @@ -2093,7 +2393,11 @@ describe("plugins cli install", () => { warnings: [], }); - await runPluginsCommand(["plugins", "install", "git:github.com/acme/demo@v1.2.3"]); + await runAcknowledgedPluginsInstallCommand([ + "plugins", + "install", + "git:github.com/acme/demo@v1.2.3", + ]); expect(gitInstallCall().spec).toBe("git:github.com/acme/demo@v1.2.3"); expect(gitInstallCall().mode).toBe("install"); @@ -2113,7 +2417,12 @@ describe("plugins cli install", () => { loadConfig.mockReturnValue({} as OpenClawConfig); await expect( - runPluginsCommand(["plugins", "install", "git:github.com/acme/demo", "--pin"]), + runAcknowledgedPluginsInstallCommand([ + "plugins", + "install", + "git:github.com/acme/demo", + "--pin", + ]), ).rejects.toThrow("__exit__:1"); expect(installPluginFromGitSpec).not.toHaveBeenCalled(); @@ -2122,7 +2431,7 @@ describe("plugins cli install", () => { it("passes dangerous force unsafe install to marketplace installs", async () => { await expect( - runPluginsCommand([ + runAcknowledgedPluginsInstallCommand([ "plugins", "install", "alpha", @@ -2140,7 +2449,12 @@ describe("plugins cli install", () => { it("passes dangerous force unsafe install to npm installs", async () => { primeNpmPluginFallback(); - await runPluginsCommand(["plugins", "install", "demo", "--dangerously-force-unsafe-install"]); + await runAcknowledgedPluginsInstallCommand([ + "plugins", + "install", + "demo", + "--dangerously-force-unsafe-install", + ]); expect(npmInstallCall().spec).toBe("demo"); expect(npmInstallCall().dangerouslyForceUnsafeInstall).toBe(true); @@ -2171,7 +2485,7 @@ describe("plugins cli install", () => { }); try { - await runPluginsCommand([ + await runAcknowledgedPluginsInstallCommand([ "plugins", "install", tmpRoot, @@ -2196,7 +2510,7 @@ describe("plugins cli install", () => { }); try { - await runPluginsCommand([ + await runAcknowledgedPluginsInstallCommand([ "plugins", "install", tmpRoot, @@ -2225,7 +2539,7 @@ describe("plugins cli install", () => { try { await expect( - runPluginsCommand(["plugins", "install", localPluginDir, "--link"]), + runAcknowledgedPluginsInstallCommand(["plugins", "install", localPluginDir, "--link"]), ).rejects.toThrow("__exit__:1"); } finally { fs.rmSync(localPluginDir, { recursive: true, force: true }); @@ -2244,7 +2558,7 @@ describe("plugins cli install", () => { }); try { - await runPluginsCommand([ + await runAcknowledgedPluginsInstallCommand([ "plugins", "install", tmpRoot, @@ -2281,7 +2595,7 @@ describe("plugins cli install", () => { }); try { - await runPluginsCommand(["plugins", "install", localPluginDir]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", localPluginDir]); } finally { fs.rmSync(localPluginDir, { recursive: true, force: true }); } @@ -2292,7 +2606,7 @@ describe("plugins cli install", () => { it("passes force through as overwrite mode for npm installs", async () => { primeNpmPluginFallback(); - await runPluginsCommand(["plugins", "install", "demo", "--force"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "demo", "--force"]); expect(npmInstallCall().spec).toBe("demo"); expect(npmInstallCall().mode).toBe("update"); @@ -2312,7 +2626,7 @@ describe("plugins cli install", () => { }); await expect( - runPluginsCommand(["plugins", "install", "@example/lossless-claw"]), + runAcknowledgedPluginsInstallCommand(["plugins", "install", "@example/lossless-claw"]), ).rejects.toThrow("__exit__:1"); expect(runtimeErrors.at(-1)).toContain( @@ -2335,9 +2649,9 @@ describe("plugins cli install", () => { }); try { - await expect(runPluginsCommand(["plugins", "install", localPluginDir])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", localPluginDir]), + ).rejects.toThrow("__exit__:1"); } finally { fs.rmSync(localPluginDir, { recursive: true, force: true }); } @@ -2385,7 +2699,7 @@ describe("plugins cli install", () => { }); try { - await runPluginsCommand([ + await runAcknowledgedPluginsInstallCommand([ "plugins", "install", localPluginDir, @@ -2422,9 +2736,9 @@ describe("plugins cli install", () => { }); try { - await expect(runPluginsCommand(["plugins", "install", localPluginDir])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", localPluginDir]), + ).rejects.toThrow("__exit__:1"); } finally { fs.rmSync(localPluginDir, { recursive: true, force: true }); } @@ -2448,7 +2762,7 @@ describe("plugins cli install", () => { try { await expect( - runPluginsCommand([ + runAcknowledgedPluginsInstallCommand([ "plugins", "install", localPluginDir, @@ -2477,7 +2791,7 @@ describe("plugins cli install", () => { try { await expect( - runPluginsCommand([ + runAcknowledgedPluginsInstallCommand([ "plugins", "install", localPluginDir, @@ -2509,7 +2823,12 @@ describe("plugins cli install", () => { }); await expect( - runPluginsCommand(["plugins", "install", "demo", "--dangerously-force-unsafe-install"]), + runAcknowledgedPluginsInstallCommand([ + "plugins", + "install", + "demo", + "--dangerously-force-unsafe-install", + ]), ).rejects.toThrow("__exit__:1"); expect(installHooksFromNpmSpec).not.toHaveBeenCalled(); @@ -2522,9 +2841,9 @@ describe("plugins cli install", () => { pluginId: "unsafe-plugin", }); - await expect(runPluginsCommand(["plugins", "install", "@acme/unsafe-plugin"])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "@acme/unsafe-plugin"]), + ).rejects.toThrow("__exit__:1"); expect(installHooksFromNpmSpec).not.toHaveBeenCalled(); expect(runtimeErrors.at(-1)).toContain('Plugin "unsafe-plugin" installation blocked'); @@ -2548,7 +2867,12 @@ describe("plugins cli install", () => { }); await expect( - runPluginsCommand(["plugins", "install", "demo", "--dangerously-force-unsafe-install"]), + runAcknowledgedPluginsInstallCommand([ + "plugins", + "install", + "demo", + "--dangerously-force-unsafe-install", + ]), ).rejects.toThrow("__exit__:1"); expect(installHooksFromNpmSpec).not.toHaveBeenCalled(); @@ -2587,7 +2911,7 @@ describe("plugins cli install", () => { recordHookInstall.mockReturnValue(installedCfg); try { - await runPluginsCommand([ + await runAcknowledgedPluginsInstallCommand([ "plugins", "install", localHookDir, @@ -2642,7 +2966,7 @@ describe("plugins cli install", () => { }); recordHookInstall.mockReturnValue(installedCfg); - await runPluginsCommand([ + await runAcknowledgedPluginsInstallCommand([ "plugins", "install", "@acme/demo-hooks", @@ -2661,9 +2985,9 @@ describe("plugins cli install", () => { code: "skill_package", }); - await expect(runPluginsCommand(["plugins", "install", "clawhub:demo"])).rejects.toThrow( - "__exit__:1", - ); + await expect( + runAcknowledgedPluginsInstallCommand(["plugins", "install", "clawhub:demo"]), + ).rejects.toThrow("__exit__:1"); expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); expect(runtimeErrors.at(-1)).toContain('Use "openclaw skills install demo" instead.'); @@ -2672,7 +2996,7 @@ describe("plugins cli install", () => { it("falls back to installing hook packs from npm specs", async () => { const { installedCfg } = primeHookPackNpmFallback(); - await runPluginsCommand(["plugins", "install", "@acme/demo-hooks"]); + await runAcknowledgedPluginsInstallCommand(["plugins", "install", "@acme/demo-hooks"]); expect(hookNpmInstallCall().spec).toBe("@acme/demo-hooks"); const record = recordHookInstallCall(); @@ -2689,7 +3013,12 @@ describe("plugins cli install", () => { it("passes force through as overwrite mode for hook-pack npm fallback installs", async () => { primeHookPackNpmFallback(); - await runPluginsCommand(["plugins", "install", "@acme/demo-hooks", "--force"]); + await runAcknowledgedPluginsInstallCommand([ + "plugins", + "install", + "@acme/demo-hooks", + "--force", + ]); expect(hookNpmInstallCall().spec).toBe("@acme/demo-hooks"); expect(hookNpmInstallCall().mode).toBe("update"); diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index 9b2aab72d547..7f4bfba3413d 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -27,6 +27,7 @@ import type { type PluginInstallActionOptions = { acknowledgeClawHubRisk?: boolean; + acknowledgeNonClawHubInstall?: boolean; dangerouslyForceUnsafeInstall?: boolean; force?: boolean; link?: boolean; diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 974ebb35db80..91d6eef606cc 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -14,14 +14,19 @@ type PluginUpdateOptions = { dangerouslyForceUnsafeInstall?: boolean; }; -type CommanderClawHubRiskOptions = Record & { +type CommanderInstallRiskOptions = Record & { acknowledgeClawhubRisk?: boolean; + acknowledgeNonClawhubInstall?: boolean; }; -function normalizeCommanderClawHubRiskOption(opts: CommanderClawHubRiskOptions): boolean { +function normalizeCommanderClawHubRiskOption(opts: CommanderInstallRiskOptions): boolean { return opts.acknowledgeClawhubRisk === true || opts.acknowledgeClawHubRisk === true; } +function normalizeCommanderNonClawHubInstallOption(opts: CommanderInstallRiskOptions): boolean { + return opts.acknowledgeNonClawhubInstall === true || opts.acknowledgeNonClawHubInstall === true; +} + export type PluginMarketplaceListOptions = { json?: boolean; }; @@ -184,6 +189,11 @@ export function registerPluginsCli(program: Command) { "Acknowledge ClawHub release trust warnings without prompting", false, ) + .option( + "--acknowledge-non-clawhub-install", + "Acknowledge non-ClawHub plugin install provenance without prompting", + false, + ) .option( "--marketplace ", "Install a Claude marketplace plugin from a local repo/path or git/GitHub source", @@ -191,7 +201,7 @@ export function registerPluginsCli(program: Command) { .action( async ( raw: string, - opts: CommanderClawHubRiskOptions & { + opts: CommanderInstallRiskOptions & { dangerouslyForceUnsafeInstall?: boolean; force?: boolean; link?: boolean; @@ -203,6 +213,7 @@ export function registerPluginsCli(program: Command) { await runPluginsInstallAction(raw, { ...opts, acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts), + acknowledgeNonClawHubInstall: normalizeCommanderNonClawHubInstallOption(opts), }); }, ); diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 4e47ac73ccc9..18285f9777aa 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -46,6 +46,10 @@ 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 { + confirmNonClawHubInstall, + type NonClawHubInstallSourceClass, +} from "./non-clawhub-install-acknowledgement.js"; import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js"; import { resolvePluginInstallInvalidConfigPolicy, @@ -864,6 +868,7 @@ export async function runPluginInstallCommand(params: { raw: string; opts: InstallSafetyOverrides & { acknowledgeClawHubRisk?: boolean; + acknowledgeNonClawHubInstall?: boolean; force?: boolean; link?: boolean; pin?: boolean; @@ -993,8 +998,21 @@ export async function runPluginInstallCommand(params: { const installMode = resolveInstallMode(opts.force); const safetyOverrides = resolveInstallSafetyOverrides({ ...opts, config: cfg }); const extensionsDir = resolveDefaultPluginExtensionsDir(); + const acknowledgeNonClawHubSource = async ( + sourceClass: NonClawHubInstallSourceClass, + spec: string, + ): Promise => + await confirmNonClawHubInstall({ + acknowledged: opts.acknowledgeNonClawHubInstall, + runtime, + sourceClass, + spec, + }); if (opts.marketplace) { + if (!(await acknowledgeNonClawHubSource("marketplace", `${raw} from ${opts.marketplace}`))) { + return runtime.exit(1); + } const result = await installPluginFromMarketplace({ ...safetyOverrides, marketplace: opts.marketplace, @@ -1028,6 +1046,14 @@ export async function runPluginInstallCommand(params: { } if (fs.existsSync(resolved)) { + if ( + !(await acknowledgeNonClawHubSource( + resolveArchiveKind(resolved) ? "local-archive" : "local-path", + resolved, + )) + ) { + return runtime.exit(1); + } const fullyBlockedReason = resolveFullyBlockedConfigMutationReason(snapshot); if (fullyBlockedReason) { runtime.error(fullyBlockedReason); @@ -1182,6 +1208,9 @@ export async function runPluginInstallCommand(params: { ); return runtime.exit(1); } + if (!(await acknowledgeNonClawHubSource("npm", npmPrefixSpec))) { + return runtime.exit(1); + } const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({ npmSpec: npmPrefixSpec, findOfficialExternalPackage: findTrustedCatalogPackageInstall, @@ -1219,6 +1248,9 @@ export async function runPluginInstallCommand(params: { ); return runtime.exit(1); } + if (!(await acknowledgeNonClawHubSource("npm-pack", raw))) { + return runtime.exit(1); + } const npmPackResult = await tryInstallPluginFromNpmPackArchive({ snapshot, installMode, @@ -1235,6 +1267,9 @@ export async function runPluginInstallCommand(params: { } if (gitSpec) { + if (!(await acknowledgeNonClawHubSource("git", raw))) { + return runtime.exit(1); + } const gitResult = await tryInstallPluginFromGitSpec({ snapshot, installMode, @@ -1290,6 +1325,9 @@ export async function runPluginInstallCommand(params: { } if (officialExternalPlan) { + if (!(await acknowledgeNonClawHubSource("npm", officialExternalPlan.npmSpec))) { + return runtime.exit(1); + } const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({ snapshot, installMode, @@ -1347,6 +1385,9 @@ export async function runPluginInstallCommand(params: { npmSpec: raw, findOfficialExternalPackage: findTrustedCatalogPackageInstall, }); + if (!(await acknowledgeNonClawHubSource("npm", raw))) { + return runtime.exit(1); + } const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({ snapshot, installMode, diff --git a/src/crestodian/operations.test.ts b/src/crestodian/operations.test.ts index 9dc7e6d8daeb..e636a533b093 100644 --- a/src/crestodian/operations.test.ts +++ b/src/crestodian/operations.test.ts @@ -582,6 +582,32 @@ describe("parseCrestodianOperation", () => { ); }); + it("passes approval as non-ClawHub acknowledgement for npm plugin installs", async () => { + const tempDir = opTempDirs.make("crestodian-plugin-install-ack-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + const { runtime } = createCrestodianTestRuntime(); + const runPluginInstall = vi.fn(async (spec: string, pluginRuntime: RuntimeEnv) => { + pluginRuntime.log(`installed ${spec}`); + }); + + const result = await executeCrestodianOperation( + { kind: "plugin-install", spec: "npm:@openclaw/demo" }, + runtime, + { + approved: true, + deps: { runPluginInstall }, + }, + ); + + expect(result.applied).toBe(true); + const installCall = requireFirstMockCall(runPluginInstall, "runPluginInstall"); + expect(installCall[0]).toBe("npm:@openclaw/demo"); + expectRuntimeArg(installCall[1]); + expect(installCall[2]).toEqual({ + acknowledgeNonClawHubInstall: true, + }); + }); + it("uninstalls plugins only after approval and audits the write", async () => { const tempDir = opTempDirs.make("crestodian-plugin-uninstall-"); setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); diff --git a/src/crestodian/operations.ts b/src/crestodian/operations.ts index 815e255ba3a6..393b390fa009 100644 --- a/src/crestodian/operations.ts +++ b/src/crestodian/operations.ts @@ -81,6 +81,10 @@ export type CrestodianOperationResult = { followUp?: Extract; }; +export type CrestodianPluginInstallOptions = { + acknowledgeNonClawHubInstall?: boolean; +}; + /** Injectable command dependencies used by tests and alternate runners. */ export type CrestodianCommandDeps = { formatOverview?: CrestodianOverviewFormatter; @@ -105,7 +109,11 @@ export type CrestodianCommandDeps = { runGatewayRestart?: () => Promise; runGatewayStart?: () => Promise; runGatewayStop?: () => Promise; - runPluginInstall?: (spec: string, runtime: RuntimeEnv) => Promise; + runPluginInstall?: ( + spec: string, + runtime: RuntimeEnv, + options?: CrestodianPluginInstallOptions, + ) => Promise; runPluginUninstall?: (pluginId: string, runtime: RuntimeEnv) => Promise; runPluginsList?: (runtime: RuntimeEnv) => Promise; runPluginsSearch?: (query: string, runtime: RuntimeEnv) => Promise; @@ -845,11 +853,23 @@ async function executePluginInstall( run: async (ctx) => { const runPluginInstall = ctx.deps?.runPluginInstall ?? - (async (spec: string, pluginRuntime: RuntimeEnv) => { + (async ( + spec: string, + pluginRuntime: RuntimeEnv, + installOptions?: CrestodianPluginInstallOptions, + ) => { const { runPluginInstallCommand } = await import("../cli/plugins-install-command.js"); - await runPluginInstallCommand({ raw: spec, opts: {}, runtime: pluginRuntime }); + await runPluginInstallCommand({ + raw: spec, + opts: { + acknowledgeNonClawHubInstall: installOptions?.acknowledgeNonClawHubInstall === true, + }, + runtime: pluginRuntime, + }); }); - await runPluginInstall(operation.spec, createNoExitRuntime(ctx.runtime)); + await runPluginInstall(operation.spec, createNoExitRuntime(ctx.runtime), { + acknowledgeNonClawHubInstall: opts.approved === true, + }); return { summary: `Installed plugin ${operation.spec}`, details: { spec: operation.spec } }; }, });