diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a9d7fce7d6d..21142885797c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - **Android chat code highlighting:** render fenced Kotlin, Swift, TypeScript, JavaScript, Python, Bash, and JSON blocks with bounded, theme-aware syntax colors while preserving plain rendering for unknown, partial, or oversized blocks. (#100217) - **Gateway TTS playback:** add an operator-scoped `tts.speak` RPC that returns configured-provider speech as inline whole-clip audio for remote clients. (#100708, #100770) - **Workboard dispatch cap:** add a request-scoped `--max-starts` override while preserving the default cap, sequential starts, and one-card-per-owner guard. (#100174) Thanks @souvikDevloper. +- **Plugin install provenance warnings:** require explicit `--force` acknowledgement for arbitrary executable plugin sources in CLI and chat installs, keep trusted ClawHub, bundled, official-catalog, and tracked-update flows frictionless, and restrict Crestodian installs to trusted sources. (#102197) Thanks @jesse-merhi. ### Fixes diff --git a/docs/cli/crestodian.md b/docs/cli/crestodian.md index fa5639682ecd..2be247a6abfe 100644 --- a/docs/cli/crestodian.md +++ b/docs/cli/crestodian.md @@ -100,6 +100,8 @@ Starting guided channel setup (`connect telegram`) also runs immediately. Its wi Persistent operations require conversational approval (or `--yes` for a direct command): write config, `config set`, `config set-ref`, setup/onboarding bootstrap, change the default model, start/stop/restart the Gateway, create agents, and install plugins. +Crestodian installs only ClawHub, bundled, or official-catalog plugins. Install any other executable source from a trusted shell with `openclaw plugins install `, where the normal source warning and acknowledgement flow applies. + Doctor repairs are unavailable inside Crestodian because they can rewrite the provider, authentication, or default-agent inference route powering the session. Exit Crestodian and run `openclaw doctor --fix` in a terminal. Read-only `doctor` remains available inside Crestodian. New agents inherit the live-verified default inference route. The agent id `crestodian` is reserved for the privileged virtual custodian and cannot be created as a normal agent. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 9ed2dac93751..97b0ae74df6b 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -121,7 +121,8 @@ generated README for first-time ClawHub publishing and trusted-publisher setup. ```bash openclaw plugins search "calendar" # search ClawHub plugins -openclaw plugins install # source auto-detection +openclaw plugins install @openclaw/ # trusted official catalog +openclaw plugins install # arbitrary npm package openclaw plugins install clawhub: # ClawHub only openclaw plugins install npm: # npm only openclaw plugins install npm-pack: # local npm-pack tarball @@ -131,7 +132,7 @@ openclaw plugins install # local path or archi openclaw plugins install -l # link instead of copy openclaw plugins install @ # marketplace shorthand openclaw plugins install --marketplace # marketplace (explicit) -openclaw plugins install --force # overwrite existing install +openclaw plugins install --force # confirm source / overwrite existing openclaw plugins install --pin # pin resolved npm version openclaw plugins install clawhub: --acknowledge-clawhub-risk openclaw plugins install --dangerously-force-unsafe-install @@ -145,6 +146,18 @@ 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 packages and OpenClaw's bundled/official catalog are trusted install +sources. A new arbitrary npm, `npm-pack:`, git, local path/archive, or +marketplace source warns and asks before continuing. Noninteractive arbitrary +installs must pass `--force` after you review and trust the source. The same +flag overwrites an existing install target when needed. Normal updates of an +already tracked install do not require it. This confirmation is separate from +`--acknowledge-clawhub-risk`, which only applies to risky ClawHub release trust +warnings. `--force` does not bypass `security.installPolicy` or remaining +install safety checks. + + `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 @@ -172,10 +185,10 @@ non-npm sources are not rewritten. If config is invalid during install, `plugins install` normally fails closed and tells you to run `openclaw doctor --fix` first. During Gateway startup and hot reload, invalid plugin config fails closed like any other invalid config; `openclaw doctor --fix` can quarantine the invalid plugin entry. The only documented install-time exception is a narrow bundled-plugin recovery path for plugins that explicitly opt into `openclaw.install.allowInvalidConfigRecovery`. - - `--force` reuses the existing install target and overwrites an already-installed plugin or hook pack in place. Use it when intentionally reinstalling the same id from a new local path, archive, ClawHub package, or npm artifact. For routine upgrades of an already tracked npm plugin, prefer `openclaw plugins update `. + + `--force` confirms a non-ClawHub source without prompting. It does not bypass `security.installPolicy` or remaining install safety checks. When the plugin or hook pack is already installed, it also reuses the existing target and overwrites it in place. Use it after reviewing an arbitrary npm, local, archive, git, or marketplace source, or when intentionally reinstalling the same id. For routine upgrades of an already tracked npm plugin, prefer `openclaw plugins update `. - If you run `plugins install` for a plugin id that is already installed, OpenClaw stops and points you at `plugins update ` for a normal upgrade, or at `plugins install --force` when you genuinely want to overwrite the current install from a different source. `--force` is not supported with `--link`. + If you run `plugins install` for a plugin id that is already installed, OpenClaw stops and points you at `plugins update ` for a normal upgrade, or at `plugins install --force` when you genuinely want to overwrite the current install from a different source. Arbitrary sources still show the interactive provenance warning; noninteractive installs must pass `--force` after review. Trusted ClawHub and OpenClaw-catalog sources do not need it. With `--link`, `--force` confirms the source but does not change the linked-path install mode. @@ -315,9 +328,10 @@ to `plugins.load.paths`): openclaw plugins install -l ./my-plugin ``` -`--link` is not supported with `--force` (linked plugins point at the source -path directly, so there is nothing to overwrite in place), `--marketplace`, or -`git:` installs, and it requires a local path that already exists. +`--link` is not supported with `--marketplace` or `git:` installs, and it +requires a local path that already exists. For a noninteractive local link, +pass `--force` after reviewing the source; it confirms provenance but does not +copy or overwrite the linked directory. Workspace-origin plugins discovered from a workspace extensions root are not @@ -417,11 +431,12 @@ openclaw plugins update openclaw plugins update --all openclaw plugins update --dry-run openclaw plugins update @openclaw/voice-call +openclaw plugins update @acme/demo openclaw plugins update openclaw-codex-app-server --acknowledge-clawhub-risk openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install ``` -Updates apply to tracked plugin installs in the managed plugin index and tracked hook-pack installs in `hooks.internal.installs`. +Updates apply to tracked plugin installs in the managed plugin index and tracked hook-pack installs in `hooks.internal.installs`. They reuse the source that the user already chose when installing the plugin, so they do not require a second source acknowledgement. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 8cbd1a511235..401d4894e2a8 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -327,8 +327,9 @@ OpenClaw can refresh the skills list mid-session: the skills watcher updates the 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: +- Installing/updating plugins runs executable code: - The install path is the per-plugin directory under the active plugin install root. + - ClawHub packages and OpenClaw's bundled/official catalog are trusted sources. A new arbitrary npm, `npm-pack:`, git, local path/archive, or marketplace source warns before install; noninteractive installs require `--force` after you review and trust that source. `--force` confirms provenance and permits overwrite; it does not bypass `security.installPolicy` or remaining install safety checks. Updates reuse the already selected 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/plugins/manage-plugins.md b/docs/plugins/manage-plugins.md index f72ecc429e10..6509c3944646 100644 --- a/docs/plugins/manage-plugins.md +++ b/docs/plugins/manage-plugins.md @@ -126,12 +126,17 @@ openclaw plugins install --link ./my-plugin Bare package specs install from npm during the launch cutover, unless the name matches a bundled or official plugin id, in which case OpenClaw uses that local/official copy instead. Use `clawhub:`, `npm:`, `git:`, or -`npm-pack:` for deterministic source selection. +`npm-pack:` for deterministic source selection. OpenClaw's bundled and official +catalog packages are trusted alongside ClawHub packages. New arbitrary npm, +git, local path/archive, `npm-pack:`, or marketplace sources require +`--force` in noninteractive installs after you review +and trust the source. -Use `--force` only to overwrite an existing install target from a different -source. For routine upgrades of a tracked npm, ClawHub, or hook-pack install, -use `openclaw plugins update` instead; `--force` is not supported with -`--link`. +`--force` confirms a non-ClawHub source without prompting and overwrites an +existing install target when needed. For routine upgrades of a tracked npm, +ClawHub, or hook-pack install, use `openclaw plugins update` instead. With +`--link`, `--force` only confirms the source; the linked directory is not +copied or overwritten. ## Restart and inspect diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 8f1beacc4a3a..24a89ff536d4 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -62,7 +62,11 @@ bundled, official external, and source-only plugins, see ``` Treat plugin installs like running code. Prefer pinned versions for - reproducible production installs. + reproducible production installs. ClawHub packages and OpenClaw's + bundled/official catalog are trusted sources. New arbitrary npm, git, + local path/archive, `npm-pack:`, or marketplace sources require + `--force` in noninteractive installs after you + review and trust the source. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 2ab6c211a0b4..e45aace59939 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -438,12 +438,23 @@ chat. /plugin show context7 /plugins enable context7 /plugins disable context7 -/plugins install ./path/to/plugin +/plugins install clawhub: +/plugins install npm:@openclaw/ +/plugins install npm: --force +/plugins install git:@ --force ``` `/plugins enable|disable` updates plugin config and hot-reloads the Gateway plugin runtime for new agent turns. `/plugins install` restarts managed -Gateways automatically because plugin source modules changed. +Gateways automatically because plugin source modules changed. Trusted ClawHub +and official-catalog installs do not need extra acknowledgement. Arbitrary npm, +git, archive, `npm-pack:`, and local path sources show a provenance warning and +require a trailing `--force` after you review the source. This flag acknowledges +the source and permits replacement of an existing install; it does not bypass +`security.installPolicy` or installer security checks. ClawHub releases with +risk warnings still require the separate shell-only +`--acknowledge-clawhub-risk` flag. Marketplace, linked, and pinned installs also +remain shell-only. ## `/trace`: plugin trace output diff --git a/extensions/codex/src/migration/plan.ts b/extensions/codex/src/migration/plan.ts index b5e8b6638dc0..a4636b4bd60f 100644 --- a/extensions/codex/src/migration/plan.ts +++ b/extensions/codex/src/migration/plan.ts @@ -233,7 +233,7 @@ function buildPluginItems( plugin.message ?? `Codex native plugin "${plugin.name}" was found but not activated automatically.`, recommendation: - "Review the plugin bundle first, then install trusted compatible plugins with openclaw plugins install .", + "Review the plugin bundle first, then install trusted compatible plugins with openclaw plugins install --force.", }), ); } diff --git a/scripts/e2e/kitchen-sink-rpc-walk.mjs b/scripts/e2e/kitchen-sink-rpc-walk.mjs index bc3dcf3f6525..1f00834be6d6 100644 --- a/scripts/e2e/kitchen-sink-rpc-walk.mjs +++ b/scripts/e2e/kitchen-sink-rpc-walk.mjs @@ -2583,7 +2583,7 @@ export async function main() { let sampleTimer; try { console.log(`Kitchen Sink RPC walk using ${PLUGIN_SPEC} via ${runner.label}`); - await runOpenClaw(runner, ["plugins", "install", PLUGIN_SPEC], env, { + await runOpenClaw(runner, ["plugins", "install", PLUGIN_SPEC, "--force"], env, { ...commandResourceOptions, requireResourceSample: true, resourceLabel: "plugins install", diff --git a/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh b/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh index 757ad0c3f3ad..9a7e2e86ebd0 100644 --- a/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh +++ b/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh @@ -155,11 +155,11 @@ run_success_scenario() { echo "Testing ${KITCHEN_SINK_LABEL} install from ${KITCHEN_SINK_SPEC}..." local install_args=("$KITCHEN_SINK_SPEC") if [ -n "${KITCHEN_SINK_PREINSTALL_SPEC:-}" ]; then - run_kitchen_sink_openclaw_logged "kitchen-sink-preinstall-${KITCHEN_SINK_LABEL}" plugins install "$KITCHEN_SINK_PREINSTALL_SPEC" + run_kitchen_sink_openclaw_logged "kitchen-sink-preinstall-${KITCHEN_SINK_LABEL}" plugins install "$KITCHEN_SINK_PREINSTALL_SPEC" --force assert_kitchen_sink_cutover_preinstalled install_args+=("--force") fi - run_kitchen_sink_openclaw_logged "kitchen-sink-install-${KITCHEN_SINK_LABEL}" plugins install "${install_args[@]}" + run_kitchen_sink_openclaw_logged "kitchen-sink-install-${KITCHEN_SINK_LABEL}" plugins install "${install_args[@]}" --force configure_kitchen_sink_runtime run_kitchen_sink_openclaw_logged "kitchen-sink-enable-${KITCHEN_SINK_LABEL}" plugins enable "$KITCHEN_SINK_ID" run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-plugins.json" plugins list --json @@ -178,7 +178,7 @@ run_success_scenario() { run_failure_scenario() { echo "Testing expected ${KITCHEN_SINK_LABEL} install failure from ${KITCHEN_SINK_SPEC}..." - run_expect_failure "install-${KITCHEN_SINK_LABEL}" openclaw_e2e_maybe_timeout "$KITCHEN_SINK_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC" + run_expect_failure "install-${KITCHEN_SINK_LABEL}" openclaw_e2e_maybe_timeout "$KITCHEN_SINK_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC" --force remove_kitchen_sink_channel_config run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.json" plugins list --json assert_kitchen_sink_removed diff --git a/scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh b/scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh index 05d23e8b35c8..5d015b272fe7 100644 --- a/scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh +++ b/scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh @@ -40,7 +40,7 @@ pack_fixture_plugin "$npm_pack_dir" /tmp/demo-corrupt-plugin.tgz demo-corrupt-pl start_npm_fixture_registry "@openclaw/demo-corrupt-plugin" "0.0.1" /tmp/demo-corrupt-plugin.tgz "$npm_registry_dir" echo "Installing managed external plugin..." -node "$entry" plugins install "npm:@openclaw/demo-corrupt-plugin@0.0.1" >/tmp/openclaw-corrupt-plugin-install.log 2>&1 +node "$entry" plugins install "npm:@openclaw/demo-corrupt-plugin@0.0.1" --force >/tmp/openclaw-corrupt-plugin-install.log 2>&1 node "$entry" plugins inspect demo-corrupt-plugin --runtime --json >/tmp/openclaw-corrupt-plugin-before.json unset NPM_CONFIG_REGISTRY npm_config_registry diff --git a/scripts/e2e/lib/plugins/marketplace.sh b/scripts/e2e/lib/plugins/marketplace.sh index b700d31cbe2f..f4cb91912a33 100644 --- a/scripts/e2e/lib/plugins/marketplace.sh +++ b/scripts/e2e/lib/plugins/marketplace.sh @@ -20,8 +20,8 @@ run_plugins_marketplace_scenario() { node scripts/e2e/lib/plugins/assertions.mjs marketplace-list - run_plugins_openclaw_logged install-marketplace-shortcut plugins install marketplace-shortcut@claude-fixtures - run_plugins_openclaw_logged install-marketplace-direct plugins install marketplace-direct --marketplace claude-fixtures + run_plugins_openclaw_logged install-marketplace-shortcut plugins install marketplace-shortcut@claude-fixtures --force + run_plugins_openclaw_logged install-marketplace-direct plugins install marketplace-direct --marketplace claude-fixtures --force run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace.json" plugins list --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace-shortcut-inspect.json" plugins inspect marketplace-shortcut --runtime --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace-direct-inspect.json" plugins inspect marketplace-direct --runtime --json diff --git a/scripts/e2e/lib/plugins/sweep.sh b/scripts/e2e/lib/plugins/sweep.sh index 9a1dec2a0e79..a79e5cd6cbeb 100644 --- a/scripts/e2e/lib/plugins/sweep.sh +++ b/scripts/e2e/lib/plugins/sweep.sh @@ -74,7 +74,7 @@ echo "Testing tgz install flow..." pack_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-pack.XXXXXX")" pack_fixture_plugin "$pack_dir" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-tgz.tgz" demo-plugin-tgz 0.0.1 demo.tgz "Demo Plugin TGZ" -run_plugins_openclaw_logged install-tgz plugins install "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-tgz.tgz" +run_plugins_openclaw_logged install-tgz plugins install "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-tgz.tgz" --force run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins2.json" plugins list --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins2-inspect.json" plugins inspect demo-plugin-tgz --runtime --json @@ -88,7 +88,7 @@ echo "Testing install from local folder (plugins.load.paths)..." dir_plugin="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-dir.XXXXXX")" write_fixture_plugin "$dir_plugin" demo-plugin-dir 0.0.1 demo.dir "Demo Plugin DIR" -run_plugins_openclaw_logged install-dir plugins install "$dir_plugin" +run_plugins_openclaw_logged install-dir plugins install "$dir_plugin" --force run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins3.json" plugins list --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins3-inspect.json" plugins inspect demo-plugin-dir --runtime --json @@ -105,7 +105,7 @@ echo "Testing install from local folder with preinstalled dependencies..." dir_deps_plugin="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-dir-deps.XXXXXX")" write_fixture_plugin_with_vendored_dependency "$dir_deps_plugin" demo-plugin-dir-deps 0.0.1 demo.dir.deps "Demo Plugin DIR Deps" -run_plugins_openclaw_logged install-dir-deps plugins install "$dir_deps_plugin" +run_plugins_openclaw_logged install-dir-deps plugins install "$dir_deps_plugin" --force run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-dir-deps.json" plugins list --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-dir-deps-inspect.json" plugins inspect demo-plugin-dir-deps --runtime --json @@ -119,7 +119,7 @@ echo "Testing install from npm spec (file:)..." file_pack_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-filepack.XXXXXX")" write_fixture_plugin "$file_pack_dir/package" demo-plugin-file 0.0.1 demo.file "Demo Plugin FILE" -run_plugins_openclaw_logged install-file plugins install "file:$file_pack_dir/package" +run_plugins_openclaw_logged install-file plugins install "file:$file_pack_dir/package" --force run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins4.json" plugins list --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins4-inspect.json" plugins inspect demo-plugin-file --runtime --json @@ -139,7 +139,7 @@ pack_fake_is_number_package "$npm_dep_pack_dir" "$OPENCLAW_PLUGINS_TMP_DIR/is-nu pack_fixture_plugin_with_invalid_extension_entry "$invalid_npm_pack_dir" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-invalid-metadata.tgz" demo-plugin-invalid-metadata 0.0.1 demo.invalid.metadata "Demo Plugin Invalid Metadata" start_npm_fixture_registry "@openclaw/demo-plugin-npm" "0.0.1" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-npm.tgz" "$npm_registry_dir" "is-number" "7.0.0" "$OPENCLAW_PLUGINS_TMP_DIR/is-number-7.0.0.tgz" "@openclaw/demo-plugin-invalid-metadata" "0.0.1" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-invalid-metadata.tgz" -run_plugins_openclaw_logged install-npm plugins install "npm:@openclaw/demo-plugin-npm@0.0.1" +run_plugins_openclaw_logged install-npm plugins install "npm:@openclaw/demo-plugin-npm@0.0.1" --force run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm.json" plugins list --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-inspect.json" plugins inspect demo-plugin-npm --runtime --json run_plugins_shell_logged exec-npm-plugin-cli 'node "$OPENCLAW_ENTRY" demo-npm ping >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-cli.txt"' @@ -154,7 +154,7 @@ run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-uninstalled. node scripts/e2e/lib/plugins/assertions.mjs plugin-npm-removed echo "Testing npm install rejects malformed package metadata..." -if openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins install "npm:@openclaw/demo-plugin-invalid-metadata@0.0.1" >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-invalid-openclaw-extensions.log" 2>&1; then +if openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins install "npm:@openclaw/demo-plugin-invalid-metadata@0.0.1" --force >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-invalid-openclaw-extensions.log" 2>&1; then cat "$OPENCLAW_PLUGINS_TMP_DIR/plugins-invalid-openclaw-extensions.log" echo "Expected malformed package metadata install to fail." >&2 exit 1 @@ -174,7 +174,7 @@ git -C "$git_repo" add -A git -C "$git_repo" commit -qm "test fixture" git_ref="$(git -C "$git_repo" rev-parse HEAD)" -run_plugins_openclaw_logged install-git plugins install "git:$git_repo_url@$git_ref" +run_plugins_openclaw_logged install-git plugins install "git:$git_repo_url@$git_ref" --force run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-git.json" plugins list --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-inspect.json" plugins inspect demo-plugin-git --runtime --json run_plugins_shell_logged exec-git-plugin-cli 'node "$OPENCLAW_ENTRY" demo-git ping >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-cli.txt"' @@ -198,7 +198,7 @@ git -C "$git_update_repo" add -A git -C "$git_update_repo" commit -qm "test fixture v1" git_update_ref_v1="$(git -C "$git_update_repo" rev-parse HEAD)" -run_plugins_openclaw_logged install-git-update plugins install "git:$git_update_repo_url@main" +run_plugins_openclaw_logged install-git-update plugins install "git:$git_update_repo_url@main" --force write_fixture_plugin_with_cli "$git_update_repo" demo-plugin-git-update 0.0.2 demo.git.update.v2 "Demo Plugin Git Update" demo-git-update "demo-plugin-git-update:pong-v2" git -C "$git_update_repo" add -A git -C "$git_update_repo" commit -qm "test fixture v2" @@ -227,7 +227,7 @@ echo "Testing plugin install visible after explicit restart..." slash_install_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-slash-install.XXXXXX")" write_fixture_plugin "$slash_install_dir" slash-install-plugin 0.0.1 demo.slash.install "Slash Install Plugin" -run_plugins_openclaw_logged install-slash-plugin plugins install "$slash_install_dir" +run_plugins_openclaw_logged install-slash-plugin plugins install "$slash_install_dir" --force run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugin-command-install-show.json" plugins inspect slash-install-plugin --runtime --json node scripts/e2e/lib/plugins/assertions.mjs slash-install diff --git a/scripts/e2e/lib/release-plugin-marketplace/scenario.sh b/scripts/e2e/lib/release-plugin-marketplace/scenario.sh index 6555ebe2d8f6..8e370c958ea0 100755 --- a/scripts/e2e/lib/release-plugin-marketplace/scenario.sh +++ b/scripts/e2e/lib/release-plugin-marketplace/scenario.sh @@ -75,7 +75,7 @@ node scripts/e2e/lib/release-scenarios/write-marketplace.mjs \ openclaw plugins marketplace list release-fixtures --json >/tmp/openclaw-release-plugin-marketplace-list.json node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-plugin-marketplace-list.json release-marketplace-plugin -openclaw plugins install release-marketplace-plugin@release-fixtures >/tmp/openclaw-release-plugin-marketplace-install-plugin.log 2>&1 +openclaw plugins install release-marketplace-plugin@release-fixtures --force >/tmp/openclaw-release-plugin-marketplace-install-plugin.log 2>&1 openclaw release-market ping >/tmp/openclaw-release-plugin-marketplace-cli-v1.log 2>&1 node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-plugin-marketplace-cli-v1.log "release-marketplace-plugin:v1" diff --git a/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh b/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh index 7e8ca4096a36..a07267bbf356 100755 --- a/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh +++ b/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh @@ -142,7 +142,7 @@ node scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs \ "Release Upgrade Plugin" \ release-upgrade \ "release-upgrade-plugin:pong" -openclaw plugins install "$plugin_dir" >"$PLUGIN_INSTALL_LOG" 2>&1 +openclaw plugins install "$plugin_dir" --force >"$PLUGIN_INSTALL_LOG" 2>&1 openclaw release-upgrade ping >"$PLUGIN_CLI_BEFORE_LOG" 2>&1 node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLUGIN_CLI_BEFORE_LOG" "release-upgrade-plugin:pong" node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT" @@ -166,7 +166,7 @@ node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLU clickclack_plugin_dir="$(mktemp -d "$scenario_tmp/clickclack-plugin.XXXXXX")" node scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs "$clickclack_plugin_dir" -openclaw plugins install "$clickclack_plugin_dir" >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1 +openclaw plugins install "$clickclack_plugin_dir" --force >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1 openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR" node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_JSON" diff --git a/scripts/e2e/lib/release-user-journey/scenario.sh b/scripts/e2e/lib/release-user-journey/scenario.sh index 361b27acffc8..4b4ff94e2644 100755 --- a/scripts/e2e/lib/release-user-journey/scenario.sh +++ b/scripts/e2e/lib/release-user-journey/scenario.sh @@ -192,7 +192,7 @@ plugin_a_dir="$(mktemp -d "$scenario_tmp/plugin-a.XXXXXX")" plugin_a_install_path_file="$PLUGIN_A_INSTALL_PATH_FILE" plugin_a_source_path_file="$PLUGIN_A_SOURCE_PATH_FILE" write_journey_plugin "$plugin_a_dir" journey-plugin-a 0.0.1 journey.a "Journey Plugin A" journey-a "journey-plugin-a:pong" -openclaw plugins install "$plugin_a_dir" >"$PLUGIN_A_INSTALL_LOG" 2>&1 +openclaw plugins install "$plugin_a_dir" --force >"$PLUGIN_A_INSTALL_LOG" 2>&1 node scripts/e2e/lib/release-user-journey/assertions.mjs \ remember-plugin-install-path \ journey-plugin-a \ @@ -213,14 +213,14 @@ node scripts/e2e/lib/release-user-journey/assertions.mjs \ echo "Installing replacement external plugin..." plugin_b_dir="$(mktemp -d "$scenario_tmp/plugin-b.XXXXXX")" write_journey_plugin "$plugin_b_dir" journey-plugin-b 0.0.1 journey.b "Journey Plugin B" journey-b "journey-plugin-b:pong" -openclaw plugins install "$plugin_b_dir" >"$PLUGIN_B_INSTALL_LOG" 2>&1 +openclaw plugins install "$plugin_b_dir" --force >"$PLUGIN_B_INSTALL_LOG" 2>&1 openclaw journey-b ping >"$PLUGIN_B_CLI_LOG" 2>&1 node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_CLI_LOG" "journey-plugin-b:pong" echo "Installing ClickClack fixture plugin..." clickclack_plugin_dir="$(mktemp -d "$scenario_tmp/clickclack-plugin.XXXXXX")" node scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs "$clickclack_plugin_dir" -openclaw plugins install "$clickclack_plugin_dir" >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1 +openclaw plugins install "$clickclack_plugin_dir" --force >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1 echo "Configuring ClickClack..." node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT" diff --git a/src/agents/tools/crestodian-tool.test.ts b/src/agents/tools/crestodian-tool.test.ts index 60538d19d90e..17a864fe62ee 100644 --- a/src/agents/tools/crestodian-tool.test.ts +++ b/src/agents/tools/crestodian-tool.test.ts @@ -101,6 +101,21 @@ describe("crestodian tool", () => { expect(mocks.executeCrestodianOperation).not.toHaveBeenCalled(); }); + it("rejects arbitrary plugin installs before creating an approval proposal", async () => { + const proposalRef: { current?: string } = {}; + const tool = createCrestodianTool({ surface: "cli", proposalRef }); + + await expect( + tool.execute("plugin-install", { + action: "plugin_install", + spec: "npm:@example/plugin", + approved: true, + }), + ).rejects.toThrow(/trusted shell/); + expect(proposalRef.current).toBeUndefined(); + expect(mocks.executeCrestodianOperation).not.toHaveBeenCalled(); + }); + it("defers an approved mutation to the host after the full proposal handshake", async () => { const proposalRef: { current?: string } = {}; // Phase 1: unarmed proposal is denied and records the exact operation. diff --git a/src/agents/tools/crestodian-tool.ts b/src/agents/tools/crestodian-tool.ts index c10b59772734..083eb0a4d836 100644 --- a/src/agents/tools/crestodian-tool.ts +++ b/src/agents/tools/crestodian-tool.ts @@ -11,6 +11,7 @@ import { isPersistentCrestodianOperation, type CrestodianOperation, } from "../../crestodian/operations.js"; +import { validateCrestodianPluginInstallSpec } from "../../crestodian/plugin-install.js"; import type { RuntimeEnv } from "../../runtime.js"; import { stringEnum } from "../schema/typebox.js"; import { stableStringify } from "../stable-stringify.js"; @@ -299,8 +300,14 @@ function operationForAction(params: Record): CrestodianOperatio return { kind: "gateway-restart" }; case "plugin_search": return { kind: "plugin-search", query: requireParam(params, "query") }; - case "plugin_install": - return { kind: "plugin-install", spec: requireParam(params, "spec") }; + case "plugin_install": { + const spec = requireParam(params, "spec"); + const validationError = validateCrestodianPluginInstallSpec(spec); + if (validationError) { + throw new ToolInputError(`crestodian: ${validationError}`); + } + return { kind: "plugin-install", spec }; + } case "plugin_uninstall": return { kind: "plugin-uninstall", pluginId: requireParam(params, "pluginId") }; case "setup": { @@ -354,6 +361,7 @@ export function createCrestodianTool(options: CrestodianToolOptions): AnyAgentTo "connect_channel/open_setup(target=channels) starts guided chat setup; open_agent hands off normal agent.", "Cannot change active inference route here. configure_model_provider/open_setup guided|classic, provider/credential/default-model change: exit and `openclaw onboard`; never ask credentials here.", "Writes (setup/set_default_model/config_set/config_set_ref/create_agent/gateway_*/plugin_install) need approved=true only after user clearly agrees to exact change in this conversation. Host applies after turn and rechecks live inference owner.", + "plugin_install accepts only ClawHub, bundled, or official-catalog plugins; direct arbitrary-source installs belong in a trusted shell.", "Unknown config path: config_schema first; schema is truth. Secret: config_set_ref env, never plaintext. Raw auth/models/env/secrets/plugins/tools/agent-route/$include writes refused; typed workflow only.", "Plugin uninstall refused: exit, CLI. Doctor repair refused: exit, `openclaw doctor --fix`.", "Every write validated/audited. CONFIG INVALID => fix immediately.", diff --git a/src/auto-reply/reply/commands-plugins-install.ts b/src/auto-reply/reply/commands-plugins-install.ts new file mode 100644 index 000000000000..9d908ba9b533 --- /dev/null +++ b/src/auto-reply/reply/commands-plugins-install.ts @@ -0,0 +1,323 @@ +import fs from "node:fs"; +import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js"; +import { buildNpmInstallRecordFields } from "../../cli/npm-resolution.js"; +import { resolveBundledInstallPlanBeforeNpm } from "../../cli/plugin-install-plan.js"; +import { + createPluginInstallLogger, + parseNpmPackPrefixPath, + resolveFileNpmSpecToLocalPath, +} from "../../cli/plugins-command-helpers.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { PluginInstallRecord } from "../../config/types.plugins.js"; +import { resolveArchiveKind } from "../../infra/archive.js"; +import { parseClawHubPluginSpec } from "../../infra/clawhub.js"; +import { installBundledPluginSource } from "../../plugins/bundled-install.js"; +import { findBundledPluginSource } from "../../plugins/bundled-sources.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 { + persistPluginInstall, + type ConfigSnapshotForInstallPersist, +} from "../../plugins/install-persistence.js"; +import { + formatNonClawHubInstallWarning, + NON_CLAWHUB_INSTALL_FORCE_FLAG, + resolveOpenClawTrustedNpmPackageInstall, + type NonClawHubInstallSourceClass, +} from "../../plugins/install-provenance.js"; +import { + installPluginFromNpmPackArchive, + installPluginFromNpmSpec, + installPluginFromPath, +} from "../../plugins/install.js"; +import { resolveCatalogOfficialExternalInstallPlan } from "../../plugins/official-external-install-trust.js"; +import { resolveUserPath } from "../../utils.js"; + +function looksLikeLocalPluginInstallSpec(raw: string): boolean { + return ( + raw.startsWith(".") || + raw.startsWith("~") || + raw.startsWith("/") || + raw.endsWith(".ts") || + raw.endsWith(".js") || + raw.endsWith(".mjs") || + raw.endsWith(".cjs") || + raw.endsWith(".tgz") || + raw.endsWith(".tar.gz") || + raw.endsWith(".tar") || + raw.endsWith(".zip") + ); +} + +function resolveNonClawHubChatInstallAcknowledgement(params: { + force: boolean; + sourceClass: NonClawHubInstallSourceClass; + spec: string; +}): { ok: true; warning: string } | { ok: false; error: string } { + const warning = formatNonClawHubInstallWarning(params); + if (params.force) { + return { ok: true, warning }; + } + return { + ok: false, + error: `${warning}\nReview the source, then rerun this chat command with ${NON_CLAWHUB_INSTALL_FORCE_FLAG} to continue.`, + }; +} + +export async function installPluginFromPluginsCommand(params: { + raw: string; + force: boolean; + config: OpenClawConfig; + snapshot: ConfigSnapshotForInstallPersist; +}): 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 }; + } + const normalized = fileSpec && fileSpec.ok ? fileSpec.path : params.raw; + const resolved = resolveUserPath(normalized); + const installMode = params.force ? "update" : "install"; + + if (fs.existsSync(resolved)) { + const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path"; + const bundledLocalSource = + source === "path" + ? findBundledPluginSource({ lookup: { kind: "localPath", value: resolved } }) + : undefined; + const acknowledgement = bundledLocalSource + ? null + : resolveNonClawHubChatInstallAcknowledgement({ + force: params.force, + sourceClass: source === "archive" ? "local-archive" : "local-path", + spec: params.raw, + }); + if (acknowledgement && !acknowledgement.ok) { + return acknowledgement; + } + const result = await installPluginFromPath({ + path: resolved, + config: params.config, + mode: installMode, + logger: createPluginInstallLogger(), + }); + if (!result.ok) { + return { ok: false, error: result.error }; + } + await persistPluginInstall({ + snapshot: params.snapshot, + pluginId: result.pluginId, + install: { + source, + sourcePath: resolved, + installPath: result.targetDir, + version: result.version, + }, + }); + return { + ok: true, + pluginId: result.pluginId, + ...(acknowledgement?.ok ? { warnings: [acknowledgement.warning] } : {}), + }; + } + + const npmPackPath = parseNpmPackPrefixPath(params.raw); + if (npmPackPath !== null) { + if (!npmPackPath) { + return { ok: false, error: "Unsupported npm-pack plugin spec: missing archive path." }; + } + const acknowledgement = resolveNonClawHubChatInstallAcknowledgement({ + force: params.force, + sourceClass: "npm-pack", + spec: params.raw, + }); + if (!acknowledgement.ok) { + return acknowledgement; + } + const result = await installPluginFromNpmPackArchive({ + archivePath: npmPackPath, + config: params.config, + mode: installMode, + logger: createPluginInstallLogger(), + }); + if (!result.ok) { + return { ok: false, error: result.error }; + } + const installRecord = { + ...buildNpmInstallRecordFields({ + spec: result.npmResolution?.resolvedSpec ?? result.manifestName ?? result.pluginId, + installPath: result.targetDir, + version: result.version, + resolution: result.npmResolution, + }), + sourcePath: npmPackPath, + artifactKind: "npm-pack", + artifactFormat: "tgz", + ...(result.npmResolution?.integrity ? { npmIntegrity: result.npmResolution.integrity } : {}), + ...(result.npmResolution?.shasum ? { npmShasum: result.npmResolution.shasum } : {}), + ...(result.npmTarballName ? { npmTarballName: result.npmTarballName } : {}), + } satisfies PluginInstallRecord; + await persistPluginInstall({ + snapshot: params.snapshot, + pluginId: result.pluginId, + install: installRecord, + }); + return { ok: true, pluginId: result.pluginId, warnings: [acknowledgement.warning] }; + } + + if (looksLikeLocalPluginInstallSpec(params.raw)) { + return { ok: false, error: `Path not found: ${resolved}` }; + } + + const gitPrefix = params.raw.trim().toLowerCase().startsWith("git:"); + const gitSpec = parseGitPluginSpec(params.raw); + if (gitPrefix && !gitSpec) { + return { ok: false, error: `unsupported git: plugin spec: ${params.raw}` }; + } + if (gitSpec) { + const acknowledgement = resolveNonClawHubChatInstallAcknowledgement({ + force: params.force, + sourceClass: "git", + spec: params.raw, + }); + if (!acknowledgement.ok) { + return acknowledgement; + } + const result = await installPluginFromGitSpec({ + spec: params.raw, + config: params.config, + mode: installMode, + logger: createPluginInstallLogger(), + }); + if (!result.ok) { + return { ok: false, error: result.error }; + } + await persistPluginInstall({ + snapshot: params.snapshot, + pluginId: result.pluginId, + install: { + source: "git", + spec: params.raw, + installPath: result.targetDir, + version: result.version, + resolvedAt: result.git.resolvedAt, + gitUrl: result.git.url, + gitRef: result.git.ref, + gitCommit: result.git.commit, + }, + }); + return { ok: true, pluginId: result.pluginId, warnings: [acknowledgement.warning] }; + } + + const clawhubSpec = parseClawHubPluginSpec(params.raw); + if (clawhubSpec) { + const warnings: string[] = []; + const logger = createPluginInstallLogger(); + const result = await installPluginFromClawHub({ + spec: params.raw, + config: params.config, + mode: installMode, + logger: { + info: logger.info, + warn: (message) => { + warnings.push(stripAnsi(message)); + logger.warn(message); + }, + terminalLinks: false, + }, + }); + if (!result.ok) { + 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: { + ...buildClawHubPluginInstallRecordFields(result.clawhub), + spec: params.raw, + installPath: result.targetDir, + version: result.version, + }, + }); + return { ok: true, pluginId: result.pluginId, warnings }; + } + + const npmSpec = params.raw.trim().toLowerCase().startsWith("npm:") + ? params.raw.trim().slice("npm:".length) + : params.raw; + const explicitNpm = params.raw.trim().toLowerCase().startsWith("npm:"); + const bundledPlan = explicitNpm + ? null + : resolveBundledInstallPlanBeforeNpm({ + rawSpec: params.raw, + findBundledSource: (lookup) => findBundledPluginSource({ lookup }), + }); + if (bundledPlan) { + const bundledInstall = await installBundledPluginSource({ + snapshot: params.snapshot, + rawSpec: params.raw, + bundledSource: bundledPlan.bundledSource, + warning: bundledPlan.warning, + }); + return { + ok: true, + pluginId: bundledInstall.pluginId, + warnings: bundledInstall.warnings, + }; + } + const trustedNpmInstall = resolveOpenClawTrustedNpmPackageInstall(npmSpec); + const officialIdPlan = resolveCatalogOfficialExternalInstallPlan(params.raw); + const arbitraryNpmAcknowledgement = + !trustedNpmInstall && !officialIdPlan + ? resolveNonClawHubChatInstallAcknowledgement({ + force: params.force, + sourceClass: "npm", + spec: params.raw, + }) + : null; + if (arbitraryNpmAcknowledgement && !arbitraryNpmAcknowledgement.ok) { + return arbitraryNpmAcknowledgement; + } + const trustedPluginId = trustedNpmInstall?.pluginId ?? officialIdPlan?.pluginId; + const trustedNpmSpec = officialIdPlan?.npmSpec ?? npmSpec; + const expectedIntegrity = + trustedNpmInstall?.expectedIntegrity ?? officialIdPlan?.expectedIntegrity; + const result = await installPluginFromNpmSpec({ + spec: trustedNpmSpec, + config: params.config, + mode: installMode, + ...(trustedPluginId ? { expectedPluginId: trustedPluginId } : {}), + ...(expectedIntegrity ? { expectedIntegrity } : {}), + ...(trustedNpmInstall || officialIdPlan ? { trustedSourceLinkedOfficialInstall: true } : {}), + logger: createPluginInstallLogger(), + }); + if (!result.ok) { + return { ok: false, error: result.error }; + } + const installRecord = buildNpmInstallRecordFields({ + spec: trustedNpmSpec, + installPath: result.targetDir, + version: result.version, + resolution: result.npmResolution, + }); + await persistPluginInstall({ + snapshot: params.snapshot, + pluginId: result.pluginId, + install: installRecord, + }); + return { + ok: true, + pluginId: result.pluginId, + ...(arbitraryNpmAcknowledgement?.ok ? { warnings: [arbitraryNpmAcknowledgement.warning] } : {}), + }; +} diff --git a/src/auto-reply/reply/commands-plugins.install.test.ts b/src/auto-reply/reply/commands-plugins.install.test.ts index 666fddb9591f..755f4b9309bf 100644 --- a/src/auto-reply/reply/commands-plugins.install.test.ts +++ b/src/auto-reply/reply/commands-plugins.install.test.ts @@ -10,12 +10,14 @@ import { handlePluginsCommand } from "./commands-plugins.js"; import { buildPluginsCommandParams } from "./commands.test-harness.js"; const { + installPluginFromNpmPackArchiveMock, installPluginFromNpmSpecMock, installPluginFromPathMock, installPluginFromClawHubMock, installPluginFromGitSpecMock, persistPluginInstallMock, } = vi.hoisted(() => ({ + installPluginFromNpmPackArchiveMock: vi.fn(), installPluginFromNpmSpecMock: vi.fn(), installPluginFromPathMock: vi.fn(), installPluginFromClawHubMock: vi.fn(), @@ -29,6 +31,7 @@ vi.mock("../../plugins/install.js", async () => { ); return { ...actual, + installPluginFromNpmPackArchive: installPluginFromNpmPackArchiveMock, installPluginFromNpmSpec: installPluginFromNpmSpecMock, installPluginFromPath: installPluginFromPathMock, }; @@ -106,8 +109,26 @@ function expectPersistedInstall(pluginId: string, expectedInstall: Record>>, + expectedSource: string, +): void { + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain(expectedSource); + expect(result.reply?.text).toContain("outside ClawHub review"); + expect(result.reply?.text).toContain("rerun this chat command with --force"); + expect(result.reply?.text).toContain("--force"); + expect(installPluginFromNpmPackArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(installPluginFromPathMock).not.toHaveBeenCalled(); + expect(installPluginFromClawHubMock).not.toHaveBeenCalled(); + expect(installPluginFromGitSpecMock).not.toHaveBeenCalled(); + expect(persistPluginInstallMock).not.toHaveBeenCalled(); +} + describe("handleCommands /plugins install", () => { afterEach(async () => { + installPluginFromNpmPackArchiveMock.mockReset(); installPluginFromNpmSpecMock.mockReset(); installPluginFromPathMock.mockReset(); installPluginFromClawHubMock.mockReset(); @@ -116,15 +137,27 @@ describe("handleCommands /plugins install", () => { await workspaceHarness.cleanupWorkspaces(); }); - it("passes the active config to npm install policy preflight", async () => { + it("rejects npm chat installs before package installer side effects", async () => { + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams("/plugins install @acme/policy-plugin@1.0.0", workspaceDir); + + const result = await handlePluginsCommand(params, true); + + if (result === null) { + throw new Error("expected plugin install result"); + } + expectNonClawHubChatInstallRejected( + result, + "Installing plugin from npm registry: @acme/policy-plugin@1.0.0", + ); + }); + }); + + it("installs an arbitrary npm package after a trailing --force acknowledgement", async () => { const policyConfig: OpenClawConfig = { - commands: { - text: true, - plugins: true, - }, - plugins: { - enabled: true, - }, + commands: { text: true, plugins: true }, + plugins: { enabled: true }, security: { installPolicy: { enabled: true, @@ -158,21 +191,23 @@ describe("handleCommands /plugins install", () => { ); const workspaceDir = await workspaceHarness.createWorkspace(); const params = buildPluginsParams( - "/plugins install @acme/policy-plugin@1.0.0", + "/plugins install @acme/policy-plugin@1.0.0 --force", workspaceDir, { cfg: policyConfig }, ); const result = await handlePluginsCommand(params, true); - if (result === null) { - throw new Error("expected plugin install result"); - } - expect(result.reply?.text).toContain('Installed plugin "policy-plugin"'); - expectObjectFields(mockFirstObjectArg(installPluginFromNpmSpecMock), { + expect(result?.reply?.text).toContain('Installed plugin "policy-plugin"'); + expect(result?.reply?.text).toContain("outside ClawHub review"); + const installParams = mockFirstObjectArg(installPluginFromNpmSpecMock); + expectObjectFields(installParams, { spec: "@acme/policy-plugin@1.0.0", config: policyConfig, + mode: "update", }); + expect(installParams).not.toHaveProperty("expectedPluginId"); + expect(installParams).not.toHaveProperty("trustedSourceLinkedOfficialInstall"); expectPersistedInstall("policy-plugin", { source: "npm", spec: "@acme/policy-plugin@1.0.0", @@ -182,16 +217,241 @@ describe("handleCommands /plugins install", () => { }); }); - it("installs a plugin from a local path", async () => { - installPluginFromPathMock.mockResolvedValue({ + it("allows npm packages matched by the official catalog", async () => { + const policyConfig: OpenClawConfig = { + commands: { text: true, plugins: true }, + plugins: { enabled: true }, + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: process.execPath, + args: ["-e", "process.exit(1)"], + allowInsecurePath: true, + }, + }, + }, + }; + installPluginFromNpmSpecMock.mockResolvedValue({ ok: true, - pluginId: "path-install-plugin", - targetDir: "/tmp/path-install-plugin", - version: "0.0.1", + pluginId: "brave", + targetDir: "/tmp/brave", + version: "1.0.0", extensions: ["index.js"], + npmResolution: { + name: "@openclaw/brave-plugin", + version: "1.0.0", + resolvedSpec: "@openclaw/brave-plugin@1.0.0", + }, }); persistPluginInstallMock.mockResolvedValue({}); + await withTempHome("openclaw-command-plugins-home-", async (home) => { + await fs.writeFile( + path.join(home, ".openclaw", "openclaw.json"), + `${JSON.stringify(policyConfig, null, 2)}\n`, + ); + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams( + "/plugins install npm:@openclaw/brave-plugin", + workspaceDir, + { cfg: policyConfig }, + ); + + const result = await handlePluginsCommand(params, true); + + expect(result?.reply?.text).toContain('Installed plugin "brave"'); + expectObjectFields(mockFirstObjectArg(installPluginFromNpmSpecMock), { + spec: "@openclaw/brave-plugin", + config: policyConfig, + expectedPluginId: "brave", + trustedSourceLinkedOfficialInstall: true, + }); + expectPersistedInstall("brave", { + source: "npm", + spec: "@openclaw/brave-plugin", + installPath: "/tmp/brave", + version: "1.0.0", + }); + }); + }); + + it("allows npm packages matched by a bundled plugin manifest", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "discord", + targetDir: "/tmp/discord", + version: "1.0.0", + extensions: ["index.js"], + npmResolution: { + name: "@openclaw/discord", + version: "1.0.0", + resolvedSpec: "@openclaw/discord@1.0.0", + }, + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams("/plugins install npm:@openclaw/discord", workspaceDir); + + const result = await handlePluginsCommand(params, true); + + expect(result?.reply?.text).toContain('Installed plugin "discord"'); + expectObjectFields(mockFirstObjectArg(installPluginFromNpmSpecMock), { + spec: "@openclaw/discord", + expectedPluginId: "discord", + trustedSourceLinkedOfficialInstall: true, + }); + expectPersistedInstall("discord", { + source: "npm", + spec: "@openclaw/discord", + installPath: "/tmp/discord", + version: "1.0.0", + }); + }); + }); + + it("installs bare bundled plugin ids from the bundled source without --force", async () => { + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams("/plugins install discord", workspaceDir); + + const result = await handlePluginsCommand(params, true); + + expect(result?.reply?.text).toContain('Installed plugin "discord"'); + expect(result?.reply?.text).toContain('Using bundled plugin "discord"'); + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(installPluginFromPathMock).not.toHaveBeenCalled(); + expectPersistedInstall("discord", { + source: "path", + spec: "discord", + sourcePath: expect.stringContaining("extensions/discord"), + installPath: expect.stringContaining("extensions/discord"), + }); + }); + }); + + it("allows plugin ids matched by the official catalog", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "wecom-openclaw-plugin", + targetDir: "/tmp/wecom-openclaw-plugin", + version: "2026.5.7", + extensions: ["index.js"], + npmResolution: { + name: "@wecom/wecom-openclaw-plugin", + version: "2026.5.7", + resolvedSpec: "@wecom/wecom-openclaw-plugin@2026.5.7", + }, + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams("/plugins install wecom-openclaw-plugin", workspaceDir); + + const result = await handlePluginsCommand(params, true); + + expect(result?.reply?.text).toContain('Installed plugin "wecom-openclaw-plugin"'); + expectObjectFields(mockFirstObjectArg(installPluginFromNpmSpecMock), { + spec: "@wecom/wecom-openclaw-plugin@2026.5.7", + expectedPluginId: "wecom-openclaw-plugin", + trustedSourceLinkedOfficialInstall: true, + }); + expectPersistedInstall("wecom-openclaw-plugin", { + source: "npm", + spec: "@wecom/wecom-openclaw-plugin@2026.5.7", + installPath: "/tmp/wecom-openclaw-plugin", + version: "2026.5.7", + }); + }); + }); + + it("does not treat an explicit npm package as an official plugin id", async () => { + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams("/plugins install npm:brave", workspaceDir); + + const result = await handlePluginsCommand(params, true); + + if (result === null) { + throw new Error("expected plugin install result"); + } + expectNonClawHubChatInstallRejected(result, "Installing plugin from npm registry: npm:brave"); + }); + }); + + it("rejects npm-pack chat installs before package installer side effects", async () => { + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams("/plugins install npm-pack:/tmp/demo.tgz", workspaceDir); + + const result = await handlePluginsCommand(params, true); + if (result === null) { + throw new Error("expected plugin install result"); + } + expectNonClawHubChatInstallRejected( + result, + "Installing plugin from local npm-pack archive: npm-pack:/tmp/demo.tgz", + ); + }); + }); + + it("installs an npm-pack archive after a trailing --force acknowledgement", async () => { + installPluginFromNpmPackArchiveMock.mockResolvedValue({ + ok: true, + pluginId: "packed-demo", + targetDir: "/tmp/packed-demo", + manifestName: "@acme/packed-demo", + version: "1.2.3", + extensions: ["index.js"], + npmTarballName: "acme-packed-demo-1.2.3.tgz", + npmResolution: { + name: "@acme/packed-demo", + version: "1.2.3", + resolvedSpec: "@acme/packed-demo@1.2.3", + integrity: "sha512-packed", + shasum: "a".repeat(40), + resolvedAt: "2026-07-14T00:00:00.000Z", + }, + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const archivePath = "/tmp/packed-demo.tgz"; + const params = buildPluginsParams( + `/plugins install npm-pack:${archivePath} --force`, + workspaceDir, + ); + + const result = await handlePluginsCommand(params, true); + + expect(result?.reply?.text).toContain('Installed plugin "packed-demo"'); + expect(result?.reply?.text).toContain("outside ClawHub review"); + expectObjectFields(mockFirstObjectArg(installPluginFromNpmPackArchiveMock), { + archivePath, + mode: "update", + }); + expectPersistedInstall("packed-demo", { + source: "npm", + spec: "@acme/packed-demo@1.2.3", + sourcePath: archivePath, + installPath: "/tmp/packed-demo", + artifactKind: "npm-pack", + artifactFormat: "tgz", + npmIntegrity: "sha512-packed", + npmShasum: "a".repeat(40), + npmTarballName: "acme-packed-demo-1.2.3.tgz", + }); + }); + }); + + it("rejects local path chat installs before package installer side effects", async () => { await withTempHome("openclaw-command-plugins-home-", async () => { const workspaceDir = await workspaceHarness.createWorkspace(); const pluginDir = path.join(workspaceDir, "fixtures", "path-install-plugin"); @@ -202,13 +462,127 @@ describe("handleCommands /plugins install", () => { if (result === null) { throw new Error("expected plugin install result"); } - expect(result.reply?.text).toContain('Installed plugin "path-install-plugin"'); - expect(mockFirstObjectArg(installPluginFromPathMock).path).toBe(pluginDir); - expectPersistedInstall("path-install-plugin", { + expectNonClawHubChatInstallRejected( + result, + `Installing plugin from local path: ${pluginDir}`, + ); + }); + }); + + it("installs a local path after a trailing --force acknowledgement", async () => { + installPluginFromPathMock.mockResolvedValue({ + ok: true, + pluginId: "path-demo", + targetDir: "/tmp/path-demo", + version: "1.0.0", + extensions: ["index.js"], + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const pluginDir = path.join(workspaceDir, "fixtures", "path-install-plugin"); + await fs.mkdir(pluginDir, { recursive: true }); + const params = buildPluginsParams(`/plugins install ${pluginDir} --force`, workspaceDir); + + const result = await handlePluginsCommand(params, true); + + expect(result?.reply?.text).toContain('Installed plugin "path-demo"'); + expect(result?.reply?.text).toContain("outside ClawHub review"); + expectObjectFields(mockFirstObjectArg(installPluginFromPathMock), { + path: pluginDir, + mode: "update", + }); + expectPersistedInstall("path-demo", { source: "path", sourcePath: pluginDir, - installPath: "/tmp/path-install-plugin", - version: "0.0.1", + installPath: "/tmp/path-demo", + version: "1.0.0", + }); + }); + }); + + it("installs a bundled local path without --force", async () => { + const bundledPath = path.resolve("extensions/discord"); + installPluginFromPathMock.mockResolvedValue({ + ok: true, + pluginId: "discord", + targetDir: "/tmp/discord", + version: "1.0.0", + extensions: ["index.js"], + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams(`/plugins install ${bundledPath}`, workspaceDir); + + const result = await handlePluginsCommand(params, true); + + expect(result?.reply?.text).toContain('Installed plugin "discord"'); + expect(result?.reply?.text).not.toContain("outside ClawHub review"); + expectObjectFields(mockFirstObjectArg(installPluginFromPathMock), { + path: bundledPath, + mode: "install", + }); + expectPersistedInstall("discord", { + source: "path", + sourcePath: bundledPath, + installPath: "/tmp/discord", + version: "1.0.0", + }); + }); + }); + + it("rejects local archive chat installs before package installer side effects", async () => { + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const pluginArchive = path.join(workspaceDir, "fixtures", "archive-install-plugin.tgz"); + await fs.mkdir(path.dirname(pluginArchive), { recursive: true }); + await fs.writeFile(pluginArchive, "not-a-real-archive"); + + const params = buildPluginsParams(`/plugins install ${pluginArchive}`, workspaceDir); + const result = await handlePluginsCommand(params, true); + if (result === null) { + throw new Error("expected plugin install result"); + } + expectNonClawHubChatInstallRejected( + result, + `Installing plugin from local archive: ${pluginArchive}`, + ); + }); + }); + + it("installs a local archive after a trailing --force acknowledgement", async () => { + installPluginFromPathMock.mockResolvedValue({ + ok: true, + pluginId: "archive-demo", + targetDir: "/tmp/archive-demo", + version: "2.0.0", + extensions: ["index.js"], + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const pluginArchive = path.join(workspaceDir, "fixtures", "archive-install-plugin.tgz"); + await fs.mkdir(path.dirname(pluginArchive), { recursive: true }); + await fs.writeFile(pluginArchive, "not-a-real-archive"); + const params = buildPluginsParams(`/plugins install ${pluginArchive} --force`, workspaceDir); + + const result = await handlePluginsCommand(params, true); + + expect(result?.reply?.text).toContain('Installed plugin "archive-demo"'); + expect(result?.reply?.text).toContain("outside ClawHub review"); + expectObjectFields(mockFirstObjectArg(installPluginFromPathMock), { + path: pluginArchive, + mode: "update", + }); + expectPersistedInstall("archive-demo", { + source: "archive", + sourcePath: pluginArchive, + installPath: "/tmp/archive-demo", + version: "2.0.0", }); }); }); @@ -219,7 +593,7 @@ describe("handleCommands /plugins install", () => { const pluginDir = path.join(workspaceDir, "fixtures", "channel-installed-plugin"); await fs.mkdir(pluginDir, { recursive: true }); - const params = buildPluginsParams(`/plugins install ${pluginDir}`, workspaceDir, { + const params = buildPluginsParams(`/plugins install ${pluginDir} --force`, workspaceDir, { omitGatewayClientScopes: true, senderIsOwner: false, }); @@ -239,16 +613,7 @@ describe("handleCommands /plugins install", () => { }); }); - it("allows gateway clients with operator.admin to install plugins", async () => { - installPluginFromPathMock.mockResolvedValue({ - ok: true, - pluginId: "gateway-admin-plugin", - targetDir: "/tmp/gateway-admin-plugin", - version: "0.0.1", - extensions: ["index.js"], - }); - persistPluginInstallMock.mockResolvedValue({}); - + it("requires --force for non-ClawHub gateway client installs with operator.admin", async () => { await withTempHome("openclaw-command-plugins-home-", async () => { const workspaceDir = await workspaceHarness.createWorkspace(); const pluginDir = path.join(workspaceDir, "fixtures", "gateway-admin-plugin"); @@ -262,13 +627,45 @@ describe("handleCommands /plugins install", () => { const result = await handlePluginsCommand(params, true); expect(result?.shouldContinue).toBe(false); + if (result === null) { + throw new Error("expected plugin install result"); + } + expectNonClawHubChatInstallRejected( + result, + `Installing plugin from local path: ${pluginDir}`, + ); + }); + }); + + it("allows a gateway client with operator.admin to force a non-ClawHub install", async () => { + installPluginFromPathMock.mockResolvedValue({ + ok: true, + pluginId: "gateway-admin-plugin", + targetDir: "/tmp/gateway-admin-plugin", + version: "1.0.0", + extensions: ["index.js"], + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const pluginDir = path.join(workspaceDir, "fixtures", "gateway-admin-plugin"); + await fs.mkdir(pluginDir, { recursive: true }); + const params = buildPluginsParams(`/plugins install ${pluginDir} --force`, workspaceDir, { + gatewayClientScopes: ["operator.admin", "operator.write"], + senderIsOwner: false, + }); + + const result = await handlePluginsCommand(params, true); + expect(result?.reply?.text).toContain('Installed plugin "gateway-admin-plugin"'); - expect(mockFirstObjectArg(installPluginFromPathMock).path).toBe(pluginDir); + expectObjectFields(mockFirstObjectArg(installPluginFromPathMock), { + path: pluginDir, + mode: "update", + }); expectPersistedInstall("gateway-admin-plugin", { source: "path", sourcePath: pluginDir, - installPath: "/tmp/gateway-admin-plugin", - version: "0.0.1", }); }); }); @@ -417,7 +814,7 @@ describe("handleCommands /plugins install", () => { await withTempHome("openclaw-command-plugins-home-", async () => { const workspaceDir = await workspaceHarness.createWorkspace(); const params = buildPluginsParams( - "/plugins install clawhub:@openclaw/risky-demo@1.2.3", + "/plugins install clawhub:@openclaw/risky-demo@1.2.3 --force", workspaceDir, ); const result = await handlePluginsCommand(params, true); @@ -431,9 +828,12 @@ describe("handleCommands /plugins install", () => { 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", - ); + const installParams = mockFirstObjectArg(installPluginFromClawHubMock); + expectObjectFields(installParams, { + spec: "clawhub:@openclaw/risky-demo@1.2.3", + mode: "update", + }); + expect(installParams).not.toHaveProperty("acknowledgeClawHubRisk"); expect(persistPluginInstallMock).not.toHaveBeenCalled(); }); }); @@ -521,22 +921,7 @@ describe("handleCommands /plugins install", () => { }); }); - it("installs from an explicit git: spec", async () => { - installPluginFromGitSpecMock.mockResolvedValue({ - ok: true, - pluginId: "git-demo", - targetDir: "/tmp/git-demo", - version: "1.2.3", - extensions: ["index.js"], - git: { - url: "https://github.com/acme/git-demo.git", - ref: "v1.2.3", - commit: "abc123", - resolvedAt: "2026-04-30T12:00:00.000Z", - }, - }); - persistPluginInstallMock.mockResolvedValue({}); - + it("rejects explicit git: chat installs before installer side effects", async () => { await withTempHome("openclaw-command-plugins-home-", async () => { const workspaceDir = await workspaceHarness.createWorkspace(); const params = buildPluginsParams( @@ -547,22 +932,70 @@ describe("handleCommands /plugins install", () => { if (result === null) { throw new Error("expected plugin install result"); } - expect(result.reply?.text).toContain('Installed plugin "git-demo"'); - expect(mockFirstObjectArg(installPluginFromGitSpecMock).spec).toBe( - "git:github.com/acme/git-demo@v1.2.3", - ); + expectNonClawHubChatInstallRejected(result, "git:github.com/acme/git-demo@v1.2.3"); + }); + }); + + it("installs an explicit git: source after a trailing --force acknowledgement", async () => { + installPluginFromGitSpecMock.mockResolvedValue({ + ok: true, + pluginId: "git-demo", + targetDir: "/tmp/git-demo", + version: "1.2.3", + extensions: ["index.js"], + git: { + url: "https://github.com/acme/git-demo.git", + ref: "v1.2.3", + commit: "0123456789abcdef0123456789abcdef01234567", + resolvedAt: "2026-07-14T00:00:00.000Z", + }, + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const spec = "git:github.com/acme/git-demo@v1.2.3"; + const params = buildPluginsParams(`/plugins install ${spec} --force`, workspaceDir); + + const result = await handlePluginsCommand(params, true); + + expect(result?.reply?.text).toContain('Installed plugin "git-demo"'); + expect(result?.reply?.text).toContain("outside ClawHub review"); + expectObjectFields(mockFirstObjectArg(installPluginFromGitSpecMock), { + spec, + mode: "update", + }); expectPersistedInstall("git-demo", { source: "git", - spec: "git:github.com/acme/git-demo@v1.2.3", + spec, installPath: "/tmp/git-demo", version: "1.2.3", gitUrl: "https://github.com/acme/git-demo.git", gitRef: "v1.2.3", - gitCommit: "abc123", + gitCommit: "0123456789abcdef0123456789abcdef01234567", }); }); }); + it("rejects --force unless it is the final install argument", async () => { + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams( + "/plugins install --force @acme/policy-plugin@1.0.0", + workspaceDir, + ); + + const result = await handlePluginsCommand(params, true); + + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toContain( + "Usage: /plugins install [--force]", + ); + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(persistPluginInstallMock).not.toHaveBeenCalled(); + }); + }); + it("treats /plugin add as an install alias", async () => { installPluginFromClawHubMock.mockResolvedValue({ ok: true, @@ -601,19 +1034,17 @@ describe("handleCommands /plugins install", () => { }); }); - it("trusts catalog npm package installs with alternate selectors", async () => { + it("allows catalog npm package chat installs with alternate selectors", async () => { installPluginFromNpmSpecMock.mockResolvedValue({ ok: true, pluginId: "wecom-openclaw-plugin", targetDir: "/tmp/wecom-openclaw-plugin", - version: "2026.4.23", + version: "2026.5.7", extensions: ["index.js"], npmResolution: { name: "@wecom/wecom-openclaw-plugin", - version: "2026.4.23", - resolvedSpec: "@wecom/wecom-openclaw-plugin@2026.4.23", - integrity: "sha512-wecom", - resolvedAt: "2026-05-04T20:00:00.000Z", + version: "2026.5.7", + resolvedSpec: "@wecom/wecom-openclaw-plugin@2026.5.7", }, }); persistPluginInstallMock.mockResolvedValue({}); @@ -629,20 +1060,19 @@ describe("handleCommands /plugins install", () => { throw new Error("expected plugin install result"); } expect(result.reply?.text).toContain('Installed plugin "wecom-openclaw-plugin"'); - const npmInstallArgs = mockFirstObjectArg(installPluginFromNpmSpecMock); - expectObjectFields(npmInstallArgs, { + expectObjectFields(mockFirstObjectArg(installPluginFromNpmSpecMock), { spec: "@wecom/wecom-openclaw-plugin@latest", expectedPluginId: "wecom-openclaw-plugin", + expectedIntegrity: undefined, trustedSourceLinkedOfficialInstall: true, }); - expect(npmInstallArgs.expectedIntegrity).toBeUndefined(); expectPersistedInstall("wecom-openclaw-plugin", { source: "npm", spec: "@wecom/wecom-openclaw-plugin@latest", installPath: "/tmp/wecom-openclaw-plugin", - version: "2026.4.23", + version: "2026.5.7", resolvedName: "@wecom/wecom-openclaw-plugin", - resolvedVersion: "2026.4.23", + resolvedVersion: "2026.5.7", }); }); }); diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 2f8748c0c834..59076431c6da 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -1,36 +1,16 @@ -// Implements plugin command listing, install, and configuration helpers. -import fs from "node:fs"; +// Implements plugin command listing and configuration helpers. 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 { - createPluginInstallLogger, - resolveFileNpmSpecToLocalPath, -} from "../../cli/plugins-command-helpers.js"; import { readConfigFileSnapshot, readConfigFileSnapshotForWrite } from "../../config/config.js"; import { assertConfigWriteAllowedInCurrentMode } from "../../config/nix-mode-write-guard.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; 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 { 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 { - persistPluginInstall, resolveInstallConfigMutationPreflights, selectInstallMutationWriteOptions, type ConfigSnapshotForInstallPersist, } from "../../plugins/install-persistence.js"; -import { installPluginFromNpmSpec, installPluginFromPath } from "../../plugins/install.js"; import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js"; -import { - getOfficialExternalPluginCatalogEntryForPackage, - resolveOfficialExternalPluginId, - resolveOfficialExternalPluginInstall, -} from "../../plugins/official-external-plugin-catalog.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../plugins/registry-refresh.js"; import type { PluginRecord } from "../../plugins/registry.js"; import { @@ -41,13 +21,13 @@ import { formatPluginCompatibilityNotice, type PluginStatusReport, } from "../../plugins/status.js"; -import { resolveUserPath } from "../../utils.js"; import { rejectNonOwnerCommand, rejectUnauthorizedCommand, requireCommandFlagEnabled, requireGatewayClientScope, } from "./command-gates.js"; +import { installPluginFromPluginsCommand } from "./commands-plugins-install.js"; import type { CommandHandler } from "./commands-types.js"; import { AutoReplyConfigMutationError, setPluginEnabledFromCommand } from "./config-mutations.js"; import { parsePluginsCommand } from "./plugins-commands.js"; @@ -176,192 +156,6 @@ function findPlugin(report: PluginStatusReport, rawName: string): PluginRecord | ); } -function looksLikeLocalPluginInstallSpec(raw: string): boolean { - return ( - raw.startsWith(".") || - raw.startsWith("~") || - raw.startsWith("/") || - raw.endsWith(".ts") || - raw.endsWith(".js") || - raw.endsWith(".mjs") || - raw.endsWith(".cjs") || - raw.endsWith(".tgz") || - raw.endsWith(".tar.gz") || - raw.endsWith(".tar") || - raw.endsWith(".zip") - ); -} - -function findTrustedCatalogPackageInstall(packageName: string): - | { - pluginId: string; - npmSpec?: string; - expectedIntegrity?: string; - } - | undefined { - const entry = getOfficialExternalPluginCatalogEntryForPackage(packageName); - if (!entry) { - return undefined; - } - const pluginId = resolveOfficialExternalPluginId(entry); - if (!pluginId) { - return undefined; - } - const install = resolveOfficialExternalPluginInstall(entry); - return { - pluginId, - ...(install?.npmSpec ? { npmSpec: install.npmSpec } : {}), - ...(install?.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}), - }; -} - -async function installPluginFromPluginsCommand(params: { - raw: string; - config: OpenClawConfig; - snapshot: ConfigSnapshotForInstallPersist; -}): 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 }; - } - const normalized = fileSpec && fileSpec.ok ? fileSpec.path : params.raw; - const resolved = resolveUserPath(normalized); - - if (fs.existsSync(resolved)) { - const result = await installPluginFromPath({ - path: resolved, - config: params.config, - logger: createPluginInstallLogger(), - }); - if (!result.ok) { - return { ok: false, error: result.error }; - } - const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path"; - await persistPluginInstall({ - snapshot: params.snapshot, - pluginId: result.pluginId, - install: { - source, - sourcePath: resolved, - installPath: result.targetDir, - version: result.version, - }, - }); - return { ok: true, pluginId: result.pluginId }; - } - - if (looksLikeLocalPluginInstallSpec(params.raw)) { - return { ok: false, error: `Path not found: ${resolved}` }; - } - - const gitPrefix = params.raw.trim().toLowerCase().startsWith("git:"); - const gitSpec = parseGitPluginSpec(params.raw); - if (gitPrefix && !gitSpec) { - return { ok: false, error: `unsupported git: plugin spec: ${params.raw}` }; - } - if (gitSpec) { - const result = await installPluginFromGitSpec({ - spec: params.raw, - config: params.config, - logger: createPluginInstallLogger(), - }); - if (!result.ok) { - return { ok: false, error: result.error }; - } - await persistPluginInstall({ - snapshot: params.snapshot, - pluginId: result.pluginId, - install: { - source: "git", - spec: params.raw, - installPath: result.targetDir, - version: result.version, - resolvedAt: result.git.resolvedAt, - gitUrl: result.git.url, - gitRef: result.git.ref, - gitCommit: result.git.commit, - }, - }); - return { ok: true, pluginId: result.pluginId }; - } - - const clawhubSpec = parseClawHubPluginSpec(params.raw); - if (clawhubSpec) { - const warnings: string[] = []; - const logger = createPluginInstallLogger(); - const result = await installPluginFromClawHub({ - spec: params.raw, - config: params.config, - logger: { - info: logger.info, - warn: (message) => { - warnings.push(stripAnsi(message)); - logger.warn(message); - }, - terminalLinks: false, - }, - }); - if (!result.ok) { - 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: { - ...buildClawHubPluginInstallRecordFields(result.clawhub), - spec: params.raw, - installPath: result.targetDir, - version: result.version, - }, - }); - return { ok: true, pluginId: result.pluginId, warnings }; - } - - const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({ - npmSpec: params.raw, - findOfficialExternalPackage: findTrustedCatalogPackageInstall, - }); - const result = await installPluginFromNpmSpec({ - spec: params.raw, - config: params.config, - ...(officialNpmTrust - ? { - expectedPluginId: officialNpmTrust.pluginId, - ...(officialNpmTrust.expectedIntegrity - ? { expectedIntegrity: officialNpmTrust.expectedIntegrity } - : {}), - trustedSourceLinkedOfficialInstall: true, - } - : {}), - logger: createPluginInstallLogger(), - }); - if (!result.ok) { - return { ok: false, error: result.error }; - } - const installRecord = buildNpmInstallRecordFields({ - spec: params.raw, - installPath: result.targetDir, - version: result.version, - resolution: result.npmResolution, - }); - await persistPluginInstall({ - snapshot: params.snapshot, - pluginId: result.pluginId, - install: installRecord, - }); - return { ok: true, pluginId: result.pluginId }; -} - async function loadPluginCommandState( workspaceDir: string, options?: { loadModules?: boolean }, @@ -489,6 +283,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm } const installed = await installPluginFromPluginsCommand({ raw: pluginsCommand.spec, + force: pluginsCommand.force, config: loadedConfig.snapshot.config, snapshot: loadedConfig.snapshot, }); diff --git a/src/auto-reply/reply/plugins-commands.ts b/src/auto-reply/reply/plugins-commands.ts index 79c65f1e90fc..cf6b729f9830 100644 --- a/src/auto-reply/reply/plugins-commands.ts +++ b/src/auto-reply/reply/plugins-commands.ts @@ -8,7 +8,7 @@ import { type PluginsCommand = | { action: "list" } | { action: "inspect"; name?: string } - | { action: "install"; spec: string } + | { action: "install"; force: boolean; spec: string } | { action: "enable"; name: string } | { action: "disable"; name: string } | { action: "error"; message: string }; @@ -43,13 +43,18 @@ export function parsePluginsCommand(raw: string): PluginsCommand | null { } if (action === "install" || action === "add") { - if (!name) { + const force = rest.at(-1) === "--force"; + const specParts = force ? rest.slice(0, -1) : rest; + const hasMisplacedForce = specParts.includes("--force"); + const spec = specParts.join(" ").trim(); + if (!spec || hasMisplacedForce) { return { action: "error", - message: "Usage: /plugins install ", + message: + "Usage: /plugins install [--force]", }; } - return { action: "install", spec: name }; + return { action: "install", force, spec }; } if (action === "enable" || action === "disable") { diff --git a/src/cli/hooks-cli.test.ts b/src/cli/hooks-cli.test.ts index 6fbba3259ab6..caad296a8fdf 100644 --- a/src/cli/hooks-cli.test.ts +++ b/src/cli/hooks-cli.test.ts @@ -1,10 +1,27 @@ // Hooks CLI tests cover hook command registration and output behavior. import { expectDefined } from "@openclaw/normalization-core"; -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()); +const runPluginUpdateCommandMock = vi.hoisted(() => vi.fn()); + +vi.mock("./plugins-install-command.js", () => ({ + runPluginInstallCommand: runPluginInstallCommandMock, +})); + +vi.mock("./plugins-update-command.js", () => ({ + runPluginUpdateCommand: runPluginUpdateCommandMock, +})); + const report: HookStatusReport = { workspaceDir: "/tmp/workspace", managedHooksDir: "/tmp/hooks", @@ -33,6 +50,11 @@ const report: HookStatusReport = { ], }; +beforeEach(() => { + runPluginInstallCommandMock.mockReset(); + runPluginUpdateCommandMock.mockReset(); +}); + function createPluginManagedHookReport(): HookStatusReport { return { workspaceDir: "/tmp/workspace", @@ -107,4 +129,20 @@ describe("hooks cli formatting", () => { expect(output).toContain("voice-call"); expect(output).toContain("Managed by plugin"); }); + + it("forwards --force through the deprecated install alias", async () => { + runPluginInstallCommandMock.mockResolvedValueOnce(undefined); + const program = new Command().exitOverride(); + registerHooksCli(program); + + await program.parseAsync(["hooks", "install", "npm:demo-hooks", "--force"], { + from: "user", + }); + + expect(runPluginInstallCommandMock).toHaveBeenCalledWith({ + raw: "npm:demo-hooks", + opts: expect.objectContaining({ force: true }), + invalidateRuntimeCache: false, + }); + }); }); diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index 87378333cef9..81b5f6835884 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -1,4 +1,3 @@ -// Hooks CLI for listing, checking, toggling, installing, and updating hook integrations. import type { Command } from "commander"; import { decorativeEmoji, @@ -564,7 +563,8 @@ 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("--force", "Confirm non-ClawHub sources and overwrite an existing hook pack", false) + .action(async (raw: string, opts: { force?: boolean; link?: boolean; pin?: boolean }) => { defaultRuntime.log( theme.warn("`openclaw hooks install` is deprecated; use `openclaw plugins install`."), ); diff --git a/src/cli/non-clawhub-install-acknowledgement.ts b/src/cli/non-clawhub-install-acknowledgement.ts new file mode 100644 index 000000000000..6f1ced377a1e --- /dev/null +++ b/src/cli/non-clawhub-install-acknowledgement.ts @@ -0,0 +1,41 @@ +import { theme } from "../../packages/terminal-core/src/theme.js"; +import { + formatNonClawHubInstallWarning, + NON_CLAWHUB_INSTALL_FORCE_FLAG, + type NonClawHubInstallSourceClass, +} from "../plugins/install-provenance.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { promptYesNo } from "./prompt.js"; + +export { + NON_CLAWHUB_INSTALL_FORCE_FLAG, + type NonClawHubInstallSourceClass, +} from "../plugins/install-provenance.js"; + +function canPromptForNonClawHubInstall(): boolean { + return process.stdin.isTTY && process.stdout.isTTY; +} + +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_FORCE_FLAG} after reviewing the source.`, + ); + return false; +} diff --git a/src/cli/plugin-install-plan.test.ts b/src/cli/plugin-install-plan.test.ts index 5a52d50e39e6..6d219453ee5c 100644 --- a/src/cli/plugin-install-plan.test.ts +++ b/src/cli/plugin-install-plan.test.ts @@ -2,12 +2,14 @@ import { installedPluginRoot } from "openclaw/plugin-sdk/test-fixtures"; import { describe, expect, it, vi } from "vitest"; import { PLUGIN_INSTALL_ERROR_CODE } from "../plugins/install.js"; +import { + resolveCatalogOfficialExternalInstallPlan, + resolveCatalogOfficialExternalNpmPackageTrust, +} from "../plugins/official-external-install-trust.js"; import { resolveBundledInstallPlanForCatalogEntry, resolveBundledInstallPlanBeforeNpm, resolveBundledInstallPlanForNpmFailure, - resolveOfficialExternalInstallPlanBeforeNpm, - resolveOfficialExternalNpmPackageTrust, } from "./plugin-install-plan.js"; describe("plugin install plan helpers", () => { @@ -70,81 +72,39 @@ describe("plugin install plan helpers", () => { }); it("resolves exact official external plugin ids before npm fallback", () => { - const findOfficialExternalPlugin = vi.fn().mockReturnValue({ - pluginId: "brave", - npmSpec: "@openclaw/brave-plugin", - expectedIntegrity: "sha512-brave", - }); + const result = resolveCatalogOfficialExternalInstallPlan("wecom-openclaw-plugin"); - const result = resolveOfficialExternalInstallPlanBeforeNpm({ - rawSpec: "brave", - findOfficialExternalPlugin, - }); - - expect(findOfficialExternalPlugin).toHaveBeenCalledWith("brave"); expect(result).toEqual({ - pluginId: "brave", - npmSpec: "@openclaw/brave-plugin", - expectedIntegrity: "sha512-brave", + pluginId: "wecom-openclaw-plugin", + npmSpec: "@wecom/wecom-openclaw-plugin@2026.5.7", + expectedIntegrity: + "sha512-TCkP9as00WfEhgFWG8YL/rcmaWGIshAki2HQh83nTRccGfVBCoGjrEboTTqq3yDmK9koWTV11zi8u8A4dNtvug==", }); }); it("skips official external plan for explicit npm selectors", () => { - const findOfficialExternalPlugin = vi.fn(); - + expect(resolveCatalogOfficialExternalInstallPlan("wecom-openclaw-plugin@beta")).toBeNull(); expect( - resolveOfficialExternalInstallPlanBeforeNpm({ - rawSpec: "brave@beta", - findOfficialExternalPlugin, - }), + resolveCatalogOfficialExternalInstallPlan("@wecom/wecom-openclaw-plugin@2026.5.7"), ).toBeNull(); - expect( - resolveOfficialExternalInstallPlanBeforeNpm({ - rawSpec: "@openclaw/brave-plugin", - findOfficialExternalPlugin, - }), - ).toBeNull(); - expect(findOfficialExternalPlugin).not.toHaveBeenCalled(); - }); - - it("skips official external plan without an npm install spec", () => { - const result = resolveOfficialExternalInstallPlanBeforeNpm({ - rawSpec: "brave", - findOfficialExternalPlugin: vi.fn().mockReturnValue({ - pluginId: "brave", - }), - }); - - expect(result).toBeNull(); }); it("trusts exact official external npm packages without remapping the spec", () => { - const findOfficialExternalPackage = vi.fn().mockReturnValue({ - pluginId: "discord", - npmSpec: "@openclaw/discord", - }); + const result = resolveCatalogOfficialExternalNpmPackageTrust( + "@wecom/wecom-openclaw-plugin@2026.5.7", + ); - const result = resolveOfficialExternalNpmPackageTrust({ - npmSpec: "@openclaw/discord", - findOfficialExternalPackage, - }); - - expect(findOfficialExternalPackage).toHaveBeenCalledWith("@openclaw/discord"); expect(result).toEqual({ - pluginId: "discord", + pluginId: "wecom-openclaw-plugin", + expectedIntegrity: + "sha512-TCkP9as00WfEhgFWG8YL/rcmaWGIshAki2HQh83nTRccGfVBCoGjrEboTTqq3yDmK9koWTV11zi8u8A4dNtvug==", trustedSourceLinkedOfficialInstall: true, }); }); it("does not trust npm package names outside the official external catalog", () => { - const findOfficialExternalPackage = vi.fn(); + const result = resolveCatalogOfficialExternalNpmPackageTrust("@acme/outside@1.0.0"); - const result = resolveOfficialExternalNpmPackageTrust({ - npmSpec: "brave", - findOfficialExternalPackage, - }); - - expect(findOfficialExternalPackage).toHaveBeenCalledWith("brave"); expect(result).toBeNull(); }); diff --git a/src/cli/plugin-install-plan.ts b/src/cli/plugin-install-plan.ts index d551b63f2f05..5fa3345208ae 100644 --- a/src/cli/plugin-install-plan.ts +++ b/src/cli/plugin-install-plan.ts @@ -9,22 +9,6 @@ type BundledLookup = (params: { value: string; }) => BundledPluginSource | undefined; -type OfficialExternalPluginLookup = (pluginId: string) => - | { - pluginId: string; - npmSpec?: string; - expectedIntegrity?: string; - } - | undefined; - -type OfficialExternalPackageLookup = (packageName: string) => - | { - pluginId: string; - npmSpec?: string; - expectedIntegrity?: string; - } - | undefined; - function isBareNpmPackageName(spec: string): boolean { const trimmed = spec.trim(); return /^[a-z0-9][a-z0-9-._~]*$/.test(trimmed); @@ -108,55 +92,6 @@ export function resolveBundledInstallPlanBeforeNpm(params: { }; } -export function resolveOfficialExternalInstallPlanBeforeNpm(params: { - rawSpec: string; - findOfficialExternalPlugin: OfficialExternalPluginLookup; -}): { pluginId: string; npmSpec: string; expectedIntegrity?: string } | null { - if (!isBareNpmPackageName(params.rawSpec)) { - return null; - } - const entry = params.findOfficialExternalPlugin(params.rawSpec); - const npmSpec = entry?.npmSpec?.trim(); - if (!entry?.pluginId || !npmSpec) { - return null; - } - return { - pluginId: entry.pluginId, - npmSpec, - ...(entry.expectedIntegrity ? { expectedIntegrity: entry.expectedIntegrity } : {}), - }; -} - -export function resolveOfficialExternalNpmPackageTrust(params: { - npmSpec: string; - findOfficialExternalPackage: OfficialExternalPackageLookup; -}): { - pluginId: string; - expectedIntegrity?: string; - trustedSourceLinkedOfficialInstall: true; -} | null { - const parsed = parseRegistryNpmSpec(params.npmSpec); - if (!parsed) { - return null; - } - const entry = params.findOfficialExternalPackage(parsed.name); - if (!entry?.pluginId) { - return null; - } - const catalogSpec = entry.npmSpec?.trim(); - const catalogPackageName = catalogSpec ? parseRegistryNpmSpec(catalogSpec)?.name : undefined; - if (catalogPackageName && catalogPackageName !== parsed.name) { - return null; - } - return { - pluginId: entry.pluginId, - ...(entry.expectedIntegrity && catalogSpec === params.npmSpec.trim() - ? { expectedIntegrity: entry.expectedIntegrity } - : {}), - trustedSourceLinkedOfficialInstall: true, - }; -} - export function resolveBundledInstallPlanForNpmFailure(params: { rawSpec: string; code?: string; diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 50496e6d3f79..a40988b8d49e 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_FORCE_FLAG = "--force"; + +function withNonClawHubInstallAcknowledgement(args: string[]): string[] { + if (args.includes(NON_CLAWHUB_INSTALL_FORCE_FLAG)) { + return args; + } + return [...args, NON_CLAWHUB_INSTALL_FORCE_FLAG]; +} + +async function runAcknowledgedPluginsInstallCommand(args: string[]): Promise { + await runPluginsCommand(withNonClawHubInstallAcknowledgement(args)); +} + function primeBlockedPluginConfigMutation( params: { blockHooks?: boolean; config?: OpenClawConfig } = {}, ): void { @@ -595,9 +650,10 @@ 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 () => { + it("shows one force option for confirmation and overwrite", async () => { const { Command } = await import("commander"); const { registerPluginsCli } = await import("./plugins-cli.js"); const program = new Command(); @@ -607,17 +663,17 @@ describe("plugins cli install", () => { const installCommand = pluginsCommand?.commands.find((command) => command.name() === "install"); const helpText = installCommand?.helpInformation() ?? ""; - expect(helpText).toContain("--force"); - expect(helpText).toContain("Overwrite an existing installed plugin or"); - expect(helpText).toContain("hook pack"); + expect(helpText.match(/--force/g)).toHaveLength(1); + expect(helpText).toContain("Confirm non-ClawHub sources and overwrite"); + expect(helpText).toContain("an existing plugin or hook pack"); }); 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 +690,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 +734,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 +756,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 +780,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 +804,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 +824,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 +868,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 +893,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 +912,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 +935,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 +958,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 +980,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 +1005,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 +1025,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 +1043,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 +1110,9 @@ describe("plugins cli install", () => { primeBlockedPluginConfigMutation(); setup(); - await expect(runPluginsCommand(args)).rejects.toThrow("__exit__:1"); + const commandArgs = + args[2] === "clawhub:demo" ? args : withNonClawHubInstallAcknowledgement(args); + await expect(runPluginsCommand(commandArgs)).rejects.toThrow("__exit__:1"); expect(installer).not.toHaveBeenCalled(); expect(writeConfigFile).not.toHaveBeenCalled(); @@ -1104,7 +1168,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,26 +1187,30 @@ 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."); + expect(runtimeErrors.at(-1)).toContain("openclaw plugins install --link --force"); expect(installPluginFromMarketplace).not.toHaveBeenCalled(); }); - it("exits when --force is combined with --link", async () => { - await expect( - runPluginsCommand(["plugins", "install", "./plugin", "--link", "--force"]), - ).rejects.toThrow("__exit__:1"); - - expect(runtimeErrors.at(-1)).toContain("--force is not supported with --link."); - expect(installPluginFromMarketplace).not.toHaveBeenCalled(); - expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); - }); - 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 +1222,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 +1256,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 +1308,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 +1328,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 +1343,279 @@ 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_FORCE_FLAG); + }); + + it.each([ + { + label: "npm-pack", + prepare: () => ({ + args: ["plugins", "install", "npm-pack:/tmp/demo.tgz"], + expectNoInstallerSideEffects: () => + expect(installPluginFromNpmPackArchive).not.toHaveBeenCalled(), + }), + }, + { + label: "git", + prepare: () => ({ + args: ["plugins", "install", "git:github.com/acme/demo@v1.2.3"], + expectNoInstallerSideEffects: () => expect(installPluginFromGitSpec).not.toHaveBeenCalled(), + }), + }, + { + label: "marketplace", + prepare: () => ({ + args: ["plugins", "install", "demo", "--marketplace", "local/repo"], + expectNoInstallerSideEffects: () => + expect(installPluginFromMarketplace).not.toHaveBeenCalled(), + }), + }, + { + label: "local path", + prepare: () => { + const localPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-source-")); + return { + args: ["plugins", "install", localPath], + cleanup: () => fs.rmSync(localPath, { recursive: true, force: true }), + expectNoInstallerSideEffects: () => expect(installPluginFromPath).not.toHaveBeenCalled(), + }; + }, + }, + { + label: "local archive", + prepare: () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-source-")); + const archivePath = `${tempRoot}.tgz`; + fs.writeFileSync(archivePath, "archive"); + return { + args: ["plugins", "install", archivePath], + cleanup: () => { + fs.rmSync(archivePath, { force: true }); + fs.rmSync(tempRoot, { recursive: true, force: true }); + }, + expectNoInstallerSideEffects: () => expect(installPluginFromPath).not.toHaveBeenCalled(), + }; + }, + }, + ])( + "requires acknowledgement for noninteractive $label installs before installer side effects", + async ({ prepare }) => { + setTty(false); + const prepared: { + args: string[]; + cleanup?: () => void; + expectNoInstallerSideEffects: () => void; + } = prepare(); + + try { + await expect(runPluginsCommand(prepared.args)).rejects.toThrow("__exit__:1"); + } finally { + prepared.cleanup?.(); + } + + prepared.expectNoInstallerSideEffects(); + expect(runtimeErrors.at(-1)).toContain("outside ClawHub review"); + expect(runtimeErrors.at(-1)).toContain(NON_CLAWHUB_INSTALL_FORCE_FLAG); + }, + ); + + it("does not require acknowledgement for a bundled plugin's local source path", async () => { + const localPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-plugin-source-")); + findBundledPluginSourceMock.mockImplementation((params: unknown) => { + const { lookup } = params as { + lookup: { kind: "localPath" | "npmSpec" | "pluginId"; value: string }; + }; + return lookup.kind === "localPath" && path.resolve(lookup.value) === path.resolve(localPath) + ? { pluginId: "demo", localPath } + : undefined; + }); + primeSuccessfulPluginPersistence("demo"); + installPluginFromPath.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: cliInstallPath("demo"), + version: "1.0.0", + extensions: ["index.js"], + }); + + try { + await runPluginsCommand(["plugins", "install", localPath]); + } finally { + fs.rmSync(localPath, { recursive: true, force: true }); + } + + expect(promptYesNo).not.toHaveBeenCalled(); + expect(runtimeErrors.join("\n")).not.toContain("outside ClawHub review"); + expect(installPluginFromPath).toHaveBeenCalledTimes(1); + }); + + 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: { @@ -1576,6 +1944,7 @@ describe("plugins cli install", () => { expect(npmInstallCall().spec).toBe("@openclaw/brave-plugin"); expect(npmInstallCall().expectedPluginId).toBe("brave"); expect(npmInstallCall().trustedSourceLinkedOfficialInstall).toBe(true); + expect(runtimeLogsContain("outside ClawHub review")).toBe(false); const record = persistedInstallRecord("brave"); expect(record.source).toBe("npm"); expect(record.spec).toBe("@openclaw/brave-plugin"); @@ -1669,10 +2038,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 +2074,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 +2093,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 +2112,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(npmInstallCall().mode).toBe("update"); + 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,10 +2139,10 @@ 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"); + expect(npmPackInstallCall().mode).toBe("update"); expect(installPluginFromPath).not.toHaveBeenCalled(); expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); const record = persistedInstallRecord("demo"); @@ -1799,11 +2172,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(); }); @@ -1825,6 +2200,7 @@ describe("plugins cli install", () => { expect(npmInstallCall().spec).toBe("@openclaw/discord"); expect(npmInstallCall().expectedPluginId).toBe("discord"); expect(npmInstallCall().trustedSourceLinkedOfficialInstall).toBe(true); + expect(runtimeLogsContain("outside ClawHub review")).toBe(false); expect(installPluginFromClawHub).not.toHaveBeenCalled(); }); @@ -1924,6 +2300,7 @@ describe("plugins cli install", () => { expect(npmInstallCall().expectedPluginId).toBe("wecom-openclaw-plugin"); expect(npmInstallCall().trustedSourceLinkedOfficialInstall).toBe(true); expect(npmInstallCall().expectedIntegrity).toBeUndefined(); + expect(runtimeLogsContain("outside ClawHub review")).toBe(false); expect(installPluginFromClawHub).not.toHaveBeenCalled(); }); @@ -1941,7 +2318,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 +2333,7 @@ describe("plugins cli install", () => { enablePluginInConfig.mockReturnValue({ config: enabledCfg }); recordPluginInstall.mockReturnValue(enabledCfg); - await runPluginsCommand([ + await runAcknowledgedPluginsInstallCommand([ "plugins", "install", "npm:demo", @@ -1987,9 +2364,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 +2384,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 +2412,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 +2436,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 +2450,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,10 +2472,14 @@ 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"); + expect(gitInstallCall().mode).toBe("update"); expect(installPluginFromClawHub).not.toHaveBeenCalled(); expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); const record = persistedInstallRecord("demo"); @@ -2113,16 +2496,21 @@ 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(); - expect(runtimeErrors.at(-1)).toContain("openclaw plugins install git:@"); + expect(runtimeErrors.at(-1)).toContain("openclaw plugins install git:@ --force"); }); it("passes dangerous force unsafe install to marketplace installs", async () => { await expect( - runPluginsCommand([ + runAcknowledgedPluginsInstallCommand([ "plugins", "install", "alpha", @@ -2140,7 +2528,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 +2564,7 @@ describe("plugins cli install", () => { }); try { - await runPluginsCommand([ + await runAcknowledgedPluginsInstallCommand([ "plugins", "install", tmpRoot, @@ -2183,6 +2576,7 @@ describe("plugins cli install", () => { } expect(pathInstallCall().path).toBe(tmpRoot); + expect(pathInstallCall().mode).toBe("install"); expect(pathInstallCall().dryRun).toBe(true); expect(pathInstallCall().allowSourceTypeScriptEntries).toBe(true); expect(pathInstallCall().dangerouslyForceUnsafeInstall).toBe(true); @@ -2196,7 +2590,7 @@ describe("plugins cli install", () => { }); try { - await runPluginsCommand([ + await runAcknowledgedPluginsInstallCommand([ "plugins", "install", tmpRoot, @@ -2225,7 +2619,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 +2638,7 @@ describe("plugins cli install", () => { }); try { - await runPluginsCommand([ + await runAcknowledgedPluginsInstallCommand([ "plugins", "install", tmpRoot, @@ -2255,7 +2649,7 @@ describe("plugins cli install", () => { } expect(hookPathInstallCall().path).toBe(tmpRoot); - expect(hookPathInstallCall().mode).toBe("install"); + expect(hookPathInstallCall().mode).toBe("update"); expect(hookPathInstallCall().dangerouslyForceUnsafeInstall).toBe(true); }); @@ -2281,7 +2675,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 +2686,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 +2706,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 +2729,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 +2779,7 @@ describe("plugins cli install", () => { }); try { - await runPluginsCommand([ + await runAcknowledgedPluginsInstallCommand([ "plugins", "install", localPluginDir, @@ -2422,9 +2816,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 +2842,7 @@ describe("plugins cli install", () => { try { await expect( - runPluginsCommand([ + runAcknowledgedPluginsInstallCommand([ "plugins", "install", localPluginDir, @@ -2477,7 +2871,7 @@ describe("plugins cli install", () => { try { await expect( - runPluginsCommand([ + runAcknowledgedPluginsInstallCommand([ "plugins", "install", localPluginDir, @@ -2509,7 +2903,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 +2921,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 +2947,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 +2991,7 @@ describe("plugins cli install", () => { recordHookInstall.mockReturnValue(installedCfg); try { - await runPluginsCommand([ + await runAcknowledgedPluginsInstallCommand([ "plugins", "install", localHookDir, @@ -2642,7 +3046,7 @@ describe("plugins cli install", () => { }); recordHookInstall.mockReturnValue(installedCfg); - await runPluginsCommand([ + await runAcknowledgedPluginsInstallCommand([ "plugins", "install", "@acme/demo-hooks", @@ -2672,7 +3076,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 +3093,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.ts b/src/cli/plugins-cli.ts index f285a1e33971..a421c7a61c43 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -15,6 +15,7 @@ type PluginUpdateOptions = { }; type CommanderClawHubRiskOptions = Record & { + acknowledgeClawHubRisk?: boolean; acknowledgeClawhubRisk?: boolean; }; @@ -172,7 +173,11 @@ export function registerPluginsCli(program: Command) { "Path (.ts/.js/.zip/.tgz/.tar.gz), npm package spec, or marketplace plugin name", ) .option("-l, --link", "Link a local path instead of copying", false) - .option("--force", "Overwrite an existing installed plugin or hook pack", false) + .option( + "--force", + "Confirm non-ClawHub sources and overwrite an existing plugin or hook pack", + false, + ) .option("--pin", "Record npm installs as exact resolved @", false) .option( "--dangerously-force-unsafe-install", diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index ea58e98c97a9..31c3027f16ac 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -1,6 +1,5 @@ // Plugin install command implementation for bundled, npm, path, git, ClawHub, and hook packs. import fs from "node:fs"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { theme } from "../../packages/terminal-core/src/theme.js"; import { @@ -16,7 +15,8 @@ import { import { resolveArchiveKind } from "../infra/archive.js"; import { parseClawHubPluginSpec } from "../infra/clawhub.js"; import { formatErrorMessage } from "../infra/errors.js"; -import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js"; +import { installBundledPluginSource } from "../plugins/bundled-install.js"; +import { findBundledPluginSource } from "../plugins/bundled-sources.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"; @@ -29,6 +29,7 @@ import { type ConfigMutationPreflight, type ConfigSnapshotForInstallPersist, } from "../plugins/install-persistence.js"; +import { resolveOpenClawTrustedNpmPackageInstall } from "../plugins/install-provenance.js"; import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js"; import { PLUGIN_INSTALL_ERROR_CODE, @@ -41,20 +42,19 @@ import { installPluginFromMarketplace, resolveMarketplaceInstallShortcut, } from "../plugins/marketplace.js"; -import { - getOfficialExternalPluginCatalogEntryForPackage, - getOfficialExternalPluginCatalogEntry, - resolveOfficialExternalPluginId, - resolveOfficialExternalPluginInstall, -} from "../plugins/official-external-plugin-catalog.js"; +import { resolveCatalogOfficialExternalInstallPlan } from "../plugins/official-external-install-trust.js"; import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js"; -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 { persistHookPackInstall } from "./hook-install-persistence.js"; import { looksLikeLocalInstallSpec } from "./install-spec.js"; +import { + confirmNonClawHubInstall, + NON_CLAWHUB_INSTALL_FORCE_FLAG, + type NonClawHubInstallSourceClass, +} from "./non-clawhub-install-acknowledgement.js"; import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js"; import { resolvePluginInstallInvalidConfigPolicy, @@ -64,8 +64,6 @@ import { import { resolveBundledInstallPlanBeforeNpm, resolveBundledInstallPlanForNpmFailure, - resolveOfficialExternalInstallPlanBeforeNpm, - resolveOfficialExternalNpmPackageTrust, } from "./plugin-install-plan.js"; import { createHookPackInstallLogger, @@ -124,36 +122,6 @@ async function probeHookPackFromPath( const DEPRECATED_DANGEROUS_FORCE_UNSAFE_INSTALL_WARNING = "--dangerously-force-unsafe-install is deprecated and no longer affects plugin installs because built-in install-time dangerous-code scanning has been removed. Configure security.installPolicy for operator-owned install decisions."; -function findTrustedCatalogPackageInstall(packageName: string): - | { - pluginId: string; - npmSpec?: string; - expectedIntegrity?: string; - } - | undefined { - // The catalog is the trust list. Raw npm selectors such as - // @scope/pkg@latest inherit install-scan trust when their package name is - // cataloged; integrity remains tied to exact catalog specs in the planner. - const entry = getOfficialExternalPluginCatalogEntryForPackage(packageName); - if (!entry) { - return undefined; - } - const pluginId = resolveOfficialExternalPluginId(entry); - if (!pluginId) { - return undefined; - } - const install = resolveOfficialExternalPluginInstall(entry); - return { - pluginId, - ...(install?.npmSpec ? { npmSpec: install.npmSpec } : {}), - ...(install?.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}), - }; -} - -function isEmptyRecord(value: Record): boolean { - return Object.keys(value).length === 0; -} - function supportsPluginRecoveryIncludeShape(parsed: Record): boolean { if (Object.hasOwn(parsed, "$include")) { return false; @@ -179,85 +147,6 @@ function assertPluginConfigMutationAllowed(preflight: ConfigMutationPreflight): } } -function hasValidBundledPluginConfig(params: { - bundledSource: BundledPluginSource; - existingEntry: unknown; -}): boolean { - if (!params.bundledSource.requiresConfig) { - return true; - } - if (!isRecord(params.existingEntry)) { - return false; - } - const config = params.existingEntry.config; - if (!isRecord(config)) { - return false; - } - if (!params.bundledSource.configSchema) { - return !isEmptyRecord(config); - } - return validateJsonSchemaValue({ - schema: params.bundledSource.configSchema, - cacheKey: `bundled-install:${params.bundledSource.pluginId}`, - value: config, - applyDefaults: true, - }).ok; -} - -function prepareConfigForDisabledBundledInstall( - config: OpenClawConfig, - pluginId: string, -): OpenClawConfig { - const entries = config.plugins?.entries ?? {}; - const { [pluginId]: _removedEntry, ...nextEntries } = entries; - return { - ...config, - plugins: { - ...config.plugins, - entries: nextEntries, - }, - }; -} - -async function installBundledPluginSource(params: { - snapshot: ConfigSnapshotForInstallExecution; - rawSpec: string; - bundledSource: BundledPluginSource; - warning: string; - invalidateRuntimeCache?: boolean; - runtime?: RuntimeEnv; -}) { - // Bundled plugins with required config are recorded but not enabled until config validates. - const existingEntry = params.snapshot.config.plugins?.entries?.[params.bundledSource.pluginId]; - const shouldEnable = hasValidBundledPluginConfig({ - bundledSource: params.bundledSource, - existingEntry, - }); - const configBase = shouldEnable - ? params.snapshot.config - : prepareConfigForDisabledBundledInstall(params.snapshot.config, params.bundledSource.pluginId); - const configWarning = shouldEnable - ? "" - : `Installed bundled plugin "${params.bundledSource.pluginId}" without enabling it because it requires configuration first. Configure it, then run \`openclaw plugins enable ${params.bundledSource.pluginId}\`.`; - await persistPluginInstall({ - snapshot: { - ...params.snapshot, - config: configBase, - }, - pluginId: params.bundledSource.pluginId, - install: { - source: "path", - spec: params.rawSpec, - sourcePath: params.bundledSource.localPath, - installPath: params.bundledSource.localPath, - }, - enable: shouldEnable, - invalidateRuntimeCache: params.invalidateRuntimeCache, - warningMessage: [params.warning, configWarning].filter(Boolean).join("\n"), - runtime: params.runtime, - }); -} - async function tryInstallHookPackFromLocalPath(params: { snapshot: ConfigSnapshotForInstallExecution; resolvedPath: string; @@ -900,13 +789,13 @@ export async function runPluginInstallCommand(params: { if (opts.marketplace) { if (opts.link) { runtime.error( - `--link is not supported with --marketplace. Remove --link, or install a local path with ${formatCliCommand("openclaw plugins install --link ")}.`, + `--link is not supported with --marketplace. Remove --link, or install a local path with ${formatCliCommand(`openclaw plugins install --link ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)}.`, ); return runtime.exit(1); } if (opts.pin) { runtime.error( - `--pin is not supported with --marketplace. Use ${formatCliCommand("openclaw plugins install --marketplace ")} without --pin.`, + `--pin is not supported with --marketplace. Use ${formatCliCommand(`openclaw plugins install --marketplace ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)} without --pin.`, ); return runtime.exit(1); } @@ -915,25 +804,19 @@ export async function runPluginInstallCommand(params: { const gitSpec = parseGitPluginSpec(raw); if (gitPrefix && !gitSpec) { runtime.error( - `Unsupported git plugin spec: ${raw}. Use ${formatCliCommand("openclaw plugins install git:@")}.`, + `Unsupported git plugin spec: ${raw}. Use ${formatCliCommand(`openclaw plugins install git:@ ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)}.`, ); return runtime.exit(1); } if (gitSpec && opts.link) { runtime.error( - `--link is not supported with git: installs. Use ${formatCliCommand("openclaw plugins install git:@")} for Git installs or ${formatCliCommand("openclaw plugins install --link ")} for local paths.`, + `--link is not supported with git: installs. Use ${formatCliCommand(`openclaw plugins install git:@ ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)} for Git installs or ${formatCliCommand(`openclaw plugins install --link ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)} for local paths.`, ); return runtime.exit(1); } if (gitSpec && opts.pin) { runtime.error( - `--pin is not supported with git: installs. Pin the ref in the spec instead, for example ${formatCliCommand("openclaw plugins install git:@")}.`, - ); - return runtime.exit(1); - } - if (opts.link && opts.force) { - runtime.error( - `--force is not supported with --link. Linked plugins point at the source path directly; remove --force and re-run ${formatCliCommand("openclaw plugins install --link ")}.`, + `--pin is not supported with git: installs. Pin the ref in the spec instead, for example ${formatCliCommand(`openclaw plugins install git:@ ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)}.`, ); return runtime.exit(1); } @@ -961,24 +844,7 @@ export async function runPluginInstallCommand(params: { }); const officialExternalPlan = resolvesToLocalPath ? null - : resolveOfficialExternalInstallPlanBeforeNpm({ - rawSpec: raw, - findOfficialExternalPlugin: (pluginId) => { - const entry = getOfficialExternalPluginCatalogEntry(pluginId); - const resolvedPluginId = entry ? resolveOfficialExternalPluginId(entry) : undefined; - const install = entry ? resolveOfficialExternalPluginInstall(entry) : null; - const npmSpec = install?.npmSpec; - return resolvedPluginId && npmSpec - ? { - pluginId: resolvedPluginId, - npmSpec, - ...(install.expectedIntegrity - ? { expectedIntegrity: install.expectedIntegrity } - : {}), - } - : undefined; - }, - }); + : resolveCatalogOfficialExternalInstallPlan(raw); if (bundledPreNpmPlan || officialExternalPlan) { request = { ...request, installKind: "plugin" }; } @@ -990,11 +856,25 @@ export async function runPluginInstallCommand(params: { return runtime.exit(1); } const cfg = snapshot.config; - const installMode = resolveInstallMode(opts.force); + // For linked paths, --force confirms source provenance without changing copy/update mode. + const installMode = resolveInstallMode(opts.force && !opts.link); const safetyOverrides = resolveInstallSafetyOverrides({ ...opts, config: cfg }); const extensionsDir = resolveDefaultPluginExtensionsDir(); + const acknowledgeNonClawHubSource = async ( + sourceClass: NonClawHubInstallSourceClass, + spec: string, + ): Promise => + await confirmNonClawHubInstall({ + acknowledged: opts.force, + 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 +908,18 @@ export async function runPluginInstallCommand(params: { } if (fs.existsSync(resolved)) { + const bundledLocalSource = resolveArchiveKind(resolved) + ? undefined + : findBundledPluginSource({ lookup: { kind: "localPath", value: resolved } }); + if ( + !bundledLocalSource && + !(await acknowledgeNonClawHubSource( + resolveArchiveKind(resolved) ? "local-archive" : "local-path", + resolved, + )) + ) { + return runtime.exit(1); + } const fullyBlockedReason = resolveFullyBlockedConfigMutationReason(snapshot); if (fullyBlockedReason) { runtime.error(fullyBlockedReason); @@ -1169,7 +1061,7 @@ export async function runPluginInstallCommand(params: { if (opts.link) { runtime.error( - `--link requires a local path. Run ${formatCliCommand("openclaw plugins install --link ")}.`, + `--link requires a local path. Run ${formatCliCommand(`openclaw plugins install --link ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)}.`, ); return runtime.exit(1); } @@ -1178,14 +1070,14 @@ export async function runPluginInstallCommand(params: { if (npmPrefixSpec !== null) { if (!npmPrefixSpec) { runtime.error( - `Unsupported npm plugin spec: missing package. Use ${formatCliCommand("openclaw plugins install npm:")}.`, + `Unsupported npm plugin spec: missing package. Use ${formatCliCommand(`openclaw plugins install npm: ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)}.`, ); return runtime.exit(1); } - const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({ - npmSpec: npmPrefixSpec, - findOfficialExternalPackage: findTrustedCatalogPackageInstall, - }); + const trustedNpmInstall = resolveOpenClawTrustedNpmPackageInstall(npmPrefixSpec); + if (!trustedNpmInstall && !(await acknowledgeNonClawHubSource("npm", npmPrefixSpec))) { + return runtime.exit(1); + } const npmPrefixResult = await tryInstallPluginOrHookPackFromNpmSpec({ snapshot, installMode, @@ -1195,11 +1087,11 @@ export async function runPluginInstallCommand(params: { allowBundledFallback: false, extensionsDir, invalidateRuntimeCache, - ...(officialNpmTrust + ...(trustedNpmInstall ? { - expectedPluginId: officialNpmTrust.pluginId, - ...(officialNpmTrust.expectedIntegrity - ? { expectedIntegrity: officialNpmTrust.expectedIntegrity } + expectedPluginId: trustedNpmInstall.pluginId, + ...(trustedNpmInstall.expectedIntegrity + ? { expectedIntegrity: trustedNpmInstall.expectedIntegrity } : {}), trustedSourceLinkedOfficialInstall: true, } @@ -1215,10 +1107,13 @@ export async function runPluginInstallCommand(params: { if (npmPackPath !== null) { if (!npmPackPath) { runtime.error( - `Unsupported npm-pack plugin spec: missing archive path. Use ${formatCliCommand("openclaw plugins install npm-pack:")}.`, + `Unsupported npm-pack plugin spec: missing archive path. Use ${formatCliCommand(`openclaw plugins install npm-pack: ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)}.`, ); return runtime.exit(1); } + if (!(await acknowledgeNonClawHubSource("npm-pack", raw))) { + return runtime.exit(1); + } const npmPackResult = await tryInstallPluginFromNpmPackArchive({ snapshot, installMode, @@ -1235,6 +1130,9 @@ export async function runPluginInstallCommand(params: { } if (gitSpec) { + if (!(await acknowledgeNonClawHubSource("git", raw))) { + return runtime.exit(1); + } const gitResult = await tryInstallPluginFromGitSpec({ snapshot, installMode, @@ -1263,7 +1161,7 @@ export async function runPluginInstallCommand(params: { ]) ) { runtime.error( - `Plugin path not found: ${resolved}. Check the path, or install from npm with ${formatCliCommand("openclaw plugins install npm:")}.`, + `Plugin path not found: ${resolved}. Check the path, or install from npm with ${formatCliCommand(`openclaw plugins install npm: ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)}.`, ); return runtime.exit(1); } @@ -1343,10 +1241,10 @@ export async function runPluginInstallCommand(params: { return; } - const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({ - npmSpec: raw, - findOfficialExternalPackage: findTrustedCatalogPackageInstall, - }); + const trustedNpmInstall = resolveOpenClawTrustedNpmPackageInstall(raw); + if (!trustedNpmInstall && !(await acknowledgeNonClawHubSource("npm", raw))) { + return runtime.exit(1); + } const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({ snapshot, installMode, @@ -1356,11 +1254,11 @@ export async function runPluginInstallCommand(params: { allowBundledFallback: true, extensionsDir, invalidateRuntimeCache, - ...(officialNpmTrust + ...(trustedNpmInstall ? { - expectedPluginId: officialNpmTrust.pluginId, - ...(officialNpmTrust.expectedIntegrity - ? { expectedIntegrity: officialNpmTrust.expectedIntegrity } + expectedPluginId: trustedNpmInstall.pluginId, + ...(trustedNpmInstall.expectedIntegrity + ? { expectedIntegrity: trustedNpmInstall.expectedIntegrity } : {}), trustedSourceLinkedOfficialInstall: true, } diff --git a/src/crestodian/operations.test.ts b/src/crestodian/operations.test.ts index c9daf1952142..187e37b65e49 100644 --- a/src/crestodian/operations.test.ts +++ b/src/crestodian/operations.test.ts @@ -268,9 +268,9 @@ describe("parseCrestodianOperation", () => { kind: "plugin-search", query: "calendar sync", }); - expect(parseCrestodianOperation("install npm plugin @openclaw/demo")).toEqual({ + expect(parseCrestodianOperation("install npm plugin @openclaw/discord")).toEqual({ kind: "plugin-install", - spec: "npm:@openclaw/demo", + spec: "npm:@openclaw/discord", }); expect(parseCrestodianOperation("plugin install clawhub:openclaw-demo")).toEqual({ kind: "plugin-install", @@ -280,6 +280,11 @@ describe("parseCrestodianOperation", () => { kind: "plugin-uninstall", pluginId: "openclaw-demo", }); + expect(parseCrestodianOperation("plugin install npm:@example/plugin")).toEqual({ + kind: "none", + message: + "Crestodian installs only ClawHub, bundled, or official-catalog plugins. Use `openclaw plugins install ` in a trusted shell to review an arbitrary executable source.", + }); }); it("parses config read and schema lookups", () => { @@ -894,6 +899,18 @@ describe("parseCrestodianOperation", () => { expect(mockConfig.readConfigFileSnapshot).not.toHaveBeenCalled(); }); + it("rejects arbitrary plugin sources before proposing or installing them", async () => { + const { runtime } = createCrestodianTestRuntime(); + const runPluginInstall = vi.fn(); + + await expect( + executeCrestodianOperation({ kind: "plugin-install", spec: "npm:@example/plugin" }, runtime, { + deps: { runPluginInstall }, + }), + ).rejects.toThrow("trusted shell"); + expect(runPluginInstall).not.toHaveBeenCalled(); + }); + it("refuses plugin uninstall because it cannot prove inference survives", async () => { const { runtime, lines } = createCrestodianTestRuntime(); const runPluginUninstall = vi.fn(); diff --git a/src/crestodian/operations.ts b/src/crestodian/operations.ts index b53ddbcd1cb6..9e15b29bb750 100644 --- a/src/crestodian/operations.ts +++ b/src/crestodian/operations.ts @@ -15,6 +15,7 @@ import { type DefaultInferenceRouteProjection, } from "./inference-route.js"; import type { CrestodianOverview } from "./overview.js"; +import { validateCrestodianPluginInstallSpec } from "./plugin-install.js"; /** * Crestodian command parser and operation executor. @@ -290,12 +291,17 @@ export function parseCrestodianOperation(input: string): CrestodianOperation { } const pluginInstallMatch = trimmed.match(PLUGIN_INSTALL_RE); if (pluginInstallMatch?.groups?.spec?.trim()) { + const spec = normalizePluginInstallSpec( + pluginInstallMatch.groups.spec.trim(), + pluginInstallMatch.groups.source, + ); + const validationError = validateCrestodianPluginInstallSpec(spec); + if (validationError) { + return { kind: "none", message: validationError }; + } return { kind: "plugin-install", - spec: normalizePluginInstallSpec( - pluginInstallMatch.groups.spec.trim(), - pluginInstallMatch.groups.source, - ), + spec, }; } const pluginUninstallMatch = trimmed.match(PLUGIN_UNINSTALL_RE); @@ -414,21 +420,6 @@ function normalizePluginInstallSpec(spec: string, source: string | undefined): s return trimmed; } -function validateCrestodianPluginInstallSpec(spec: string): string | null { - const trimmed = spec.trim(); - if (!trimmed) { - return "Plugin install spec is required."; - } - if (/\s/.test(trimmed)) { - return "Crestodian plugin install accepts one npm or ClawHub package spec."; - } - if (/^(?:\.{1,2}\/|\/|~\/|file:|git(?:\+ssh|\+https)?:|https?:)/i.test(trimmed)) { - // Crestodian does not install local paths or URLs; those can execute arbitrary package code. - return "Crestodian plugin install accepts npm or ClawHub package specs only."; - } - return null; -} - /** * Return whether an operation can change local state or process lifecycle. * Guided setup operations are intentionally absent: starting a wizard is not @@ -1084,11 +1075,9 @@ async function executePluginInstall( runtime: RuntimeEnv, opts: ExecuteOptions, ): Promise { - if (opts.approved) { - const validationError = validateCrestodianPluginInstallSpec(operation.spec); - if (validationError) { - throw new Error(validationError); - } + const validationError = validateCrestodianPluginInstallSpec(operation.spec); + if (validationError) { + throw new Error(validationError); } const result = await applyPersistentOperation({ auditOperation: "plugin.install", diff --git a/src/crestodian/plugin-install.ts b/src/crestodian/plugin-install.ts new file mode 100644 index 000000000000..83da3bed11cb --- /dev/null +++ b/src/crestodian/plugin-install.ts @@ -0,0 +1,19 @@ +import { isOpenClawTrustedPluginInstallSpec } from "../plugins/install-provenance.js"; + +export function validateCrestodianPluginInstallSpec(spec: string): string | null { + const trimmed = spec.trim(); + if (!trimmed) { + return "Plugin install spec is required."; + } + if (/\s/.test(trimmed)) { + return "Crestodian plugin install accepts one npm or ClawHub package spec."; + } + if (/^(?:\.{1,2}\/|\/|~\/|file:|git(?:\+ssh|\+https)?:|https?:)/i.test(trimmed)) { + // Crestodian does not install local paths or URLs; those can execute arbitrary package code. + return "Crestodian plugin install accepts npm or ClawHub package specs only."; + } + if (!isOpenClawTrustedPluginInstallSpec(trimmed)) { + return "Crestodian installs only ClawHub, bundled, or official-catalog plugins. Use `openclaw plugins install ` in a trusted shell to review an arbitrary executable source."; + } + return null; +} diff --git a/src/plugins/bundled-install.ts b/src/plugins/bundled-install.ts new file mode 100644 index 000000000000..2fe2c4718520 --- /dev/null +++ b/src/plugins/bundled-install.ts @@ -0,0 +1,92 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { BundledPluginSource } from "./bundled-sources.js"; +import { + persistPluginInstall, + type ConfigSnapshotForInstallPersist, +} from "./install-persistence.js"; +import { validateJsonSchemaValue } from "./schema-validator.js"; + +function hasValidBundledPluginConfig(params: { + bundledSource: BundledPluginSource; + existingEntry: unknown; +}): boolean { + if (!params.bundledSource.requiresConfig) { + return true; + } + if (!isRecord(params.existingEntry)) { + return false; + } + const config = params.existingEntry.config; + if (!isRecord(config)) { + return false; + } + if (!params.bundledSource.configSchema) { + return Object.keys(config).length > 0; + } + return validateJsonSchemaValue({ + schema: params.bundledSource.configSchema, + cacheKey: `bundled-install:${params.bundledSource.pluginId}`, + value: config, + applyDefaults: true, + }).ok; +} + +function prepareConfigForDisabledBundledInstall( + config: OpenClawConfig, + pluginId: string, +): OpenClawConfig { + const entries = config.plugins?.entries ?? {}; + const { [pluginId]: _removedEntry, ...nextEntries } = entries; + return { + ...config, + plugins: { + ...config.plugins, + entries: nextEntries, + }, + }; +} + +export async function installBundledPluginSource(params: { + snapshot: ConfigSnapshotForInstallPersist; + rawSpec: string; + bundledSource: BundledPluginSource; + warning?: string; + invalidateRuntimeCache?: boolean; + runtime?: RuntimeEnv; +}): Promise<{ pluginId: string; warnings: string[] }> { + // Bundled plugins with required config are recorded but not enabled until config validates. + const existingEntry = params.snapshot.config.plugins?.entries?.[params.bundledSource.pluginId]; + const shouldEnable = hasValidBundledPluginConfig({ + bundledSource: params.bundledSource, + existingEntry, + }); + const configBase = shouldEnable + ? params.snapshot.config + : prepareConfigForDisabledBundledInstall(params.snapshot.config, params.bundledSource.pluginId); + const configWarning = shouldEnable + ? undefined + : `Installed bundled plugin "${params.bundledSource.pluginId}" without enabling it because it requires configuration first. Configure it, then run \`openclaw plugins enable ${params.bundledSource.pluginId}\`.`; + const warnings = [params.warning, configWarning].filter((warning): warning is string => + Boolean(warning), + ); + await persistPluginInstall({ + snapshot: { + ...params.snapshot, + config: configBase, + }, + pluginId: params.bundledSource.pluginId, + install: { + source: "path", + spec: params.rawSpec, + sourcePath: params.bundledSource.localPath, + installPath: params.bundledSource.localPath, + }, + enable: shouldEnable, + invalidateRuntimeCache: params.invalidateRuntimeCache, + ...(warnings.length > 0 ? { warningMessage: warnings.join("\n") } : {}), + runtime: params.runtime, + }); + return { pluginId: params.bundledSource.pluginId, warnings }; +} diff --git a/src/plugins/bundled-sources.test.ts b/src/plugins/bundled-sources.test.ts index 8fd2f7750f39..3178a26f064d 100644 --- a/src/plugins/bundled-sources.test.ts +++ b/src/plugins/bundled-sources.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { findBundledPluginSource, findBundledPluginSourceInMap, + getProcessBundledPluginSources, resolveBundledPluginSources, } from "./bundled-sources.js"; @@ -149,6 +150,16 @@ describe("bundled plugin sources", () => { loadPluginManifestMock.mockReset(); }); + it("reuses one process-stable bundled source snapshot", () => { + setBundledLookupFixture(); + + const first = getProcessBundledPluginSources(); + const second = getProcessBundledPluginSources(); + + expect(second).toBe(first); + expect(discoverOpenClawPluginsMock).toHaveBeenCalledOnce(); + }); + it("resolves bundled sources keyed by plugin id", () => { setBundledDiscoveryCandidates([ createBundledCandidate({ @@ -201,6 +212,11 @@ describe("bundled plugin sources", () => { { kind: "pluginId", value: "diffs" } as const, { pluginId: "diffs", localPath: appBundledPluginRoot("diffs") }, ], + [ + "finds bundled source by local path", + { kind: "localPath", value: appBundledPluginRoot("diffs") } as const, + { pluginId: "diffs", localPath: appBundledPluginRoot("diffs") }, + ], [ "returns undefined for missing plugin id", { kind: "pluginId", value: "not-found" } as const, diff --git a/src/plugins/bundled-sources.ts b/src/plugins/bundled-sources.ts index a8a7c8197bfc..844458ed06df 100644 --- a/src/plugins/bundled-sources.ts +++ b/src/plugins/bundled-sources.ts @@ -1,4 +1,5 @@ // Resolves bundled plugin source metadata from package manifests. +import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js"; @@ -13,7 +14,10 @@ export type BundledPluginSource = { requiresConfig?: boolean; }; -type BundledPluginLookup = { kind: "npmSpec"; value: string } | { kind: "pluginId"; value: string }; +type BundledPluginLookup = + | { kind: "localPath"; value: string } + | { kind: "npmSpec"; value: string } + | { kind: "pluginId"; value: string }; export function findBundledPluginSourceInMap(params: { bundled: ReadonlyMap; @@ -27,7 +31,11 @@ export function findBundledPluginSourceInMap(params: { return params.bundled.get(targetValue); } for (const source of params.bundled.values()) { - if (source.npmSpec === targetValue) { + if ( + (params.lookup.kind === "npmSpec" && source.npmSpec === targetValue) || + (params.lookup.kind === "localPath" && + path.resolve(source.localPath) === path.resolve(targetValue)) + ) { return source; } } @@ -83,6 +91,14 @@ export function resolveBundledPluginSources(params: { return bundled; } +let processBundledPluginSources: ReadonlyMap | undefined; + +/** Bundled manifests are process-stable; installs and metadata changes require restart. */ +export function getProcessBundledPluginSources(): ReadonlyMap { + processBundledPluginSources ??= resolveBundledPluginSources({}); + return processBundledPluginSources; +} + function pluginConfigSchemaHasRequiredFields(schema: unknown): boolean { if (!isRecord(schema)) { return false; diff --git a/src/plugins/install-provenance.test.ts b/src/plugins/install-provenance.test.ts new file mode 100644 index 000000000000..61f7a4532812 --- /dev/null +++ b/src/plugins/install-provenance.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import type { BundledPluginSource } from "./bundled-sources.js"; +import { isOpenClawTrustedPluginInstallSpec } from "./install-provenance.js"; + +const bundledSources = new Map([ + [ + "discord", + { + pluginId: "discord", + localPath: "/opt/openclaw/extensions/discord", + npmSpec: "@openclaw/discord", + }, + ], +]); + +describe("plugin install provenance", () => { + it.each([ + "discord", + "@openclaw/discord", + "npm:@openclaw/discord", + "/opt/openclaw/extensions/discord", + "brave", + "npm:@openclaw/brave-plugin", + "clawhub:openclaw-demo", + ])("trusts OpenClaw-owned install source %s", (spec) => { + expect(isOpenClawTrustedPluginInstallSpec(spec, bundledSources)).toBe(true); + }); + + it.each(["npm:discord", "npm:@example/plugin", "/tmp/example-plugin"])( + "keeps arbitrary install source %s untrusted", + (spec) => { + expect(isOpenClawTrustedPluginInstallSpec(spec, bundledSources)).toBe(false); + }, + ); +}); diff --git a/src/plugins/install-provenance.ts b/src/plugins/install-provenance.ts new file mode 100644 index 000000000000..e96cb5a61260 --- /dev/null +++ b/src/plugins/install-provenance.ts @@ -0,0 +1,100 @@ +// Shared policy and messaging for installs outside OpenClaw's trusted plugin sources. +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; +import { + findBundledPluginSourceInMap, + getProcessBundledPluginSources, + type BundledPluginSource, +} from "./bundled-sources.js"; +import { + resolveCatalogOfficialExternalInstallPlan, + resolveCatalogOfficialExternalNpmPackageTrust, +} from "./official-external-install-trust.js"; + +export const NON_CLAWHUB_INSTALL_FORCE_FLAG = "--force"; + +export type NonClawHubInstallSourceClass = + | "git" + | "local-archive" + | "local-path" + | "marketplace" + | "npm" + | "npm-pack"; + +export function resolveOpenClawTrustedNpmPackageInstall( + npmSpec: string, + bundledSources: ReadonlyMap = getProcessBundledPluginSources(), +): { + pluginId: string; + expectedIntegrity?: string; +} | null { + const packageName = parseRegistryNpmSpec(npmSpec)?.name; + if (!packageName) { + return null; + } + const bundled = findBundledPluginSourceInMap({ + bundled: bundledSources, + lookup: { kind: "npmSpec", value: packageName }, + }); + if (bundled) { + return { pluginId: bundled.pluginId }; + } + return resolveCatalogOfficialExternalNpmPackageTrust(npmSpec); +} + +export function isOpenClawTrustedPluginInstallSpec( + spec: string, + bundledSources: ReadonlyMap = getProcessBundledPluginSources(), +): boolean { + const trimmed = spec.trim(); + if (trimmed.toLowerCase().startsWith("clawhub:")) { + return true; + } + const explicitNpm = trimmed.toLowerCase().startsWith("npm:"); + const npmSpec = explicitNpm ? trimmed.slice("npm:".length) : trimmed; + if (explicitNpm) { + return resolveOpenClawTrustedNpmPackageInstall(npmSpec, bundledSources) !== null; + } + const parsedPackageName = parseRegistryNpmSpec(npmSpec)?.name; + const bundled = + findBundledPluginSourceInMap({ + bundled: bundledSources, + lookup: { kind: "pluginId", value: npmSpec }, + }) ?? + (parsedPackageName + ? findBundledPluginSourceInMap({ + bundled: bundledSources, + lookup: { kind: "npmSpec", value: parsedPackageName }, + }) + : undefined) ?? + findBundledPluginSourceInMap({ + bundled: bundledSources, + lookup: { kind: "localPath", value: npmSpec }, + }); + return Boolean( + bundled ?? + resolveOpenClawTrustedNpmPackageInstall(npmSpec, bundledSources) ?? + resolveCatalogOfficialExternalInstallPlan(npmSpec), + ); +} + +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", +}; + +export function formatNonClawHubInstallWarning(params: { + sourceClass: NonClawHubInstallSourceClass; + spec: string; +}): string { + const sourceLabel = sourceClassLabels[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"); +} diff --git a/src/plugins/official-external-install-trust.ts b/src/plugins/official-external-install-trust.ts new file mode 100644 index 000000000000..0625477380c7 --- /dev/null +++ b/src/plugins/official-external-install-trust.ts @@ -0,0 +1,112 @@ +// Resolves trusted official external plugin installs from the OpenClaw-owned catalog. +import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; +import { + getOfficialExternalPluginCatalogEntry, + getOfficialExternalPluginCatalogEntryForPackage, + resolveOfficialExternalPluginId, + resolveOfficialExternalPluginInstall, +} from "./official-external-plugin-catalog.js"; + +type OfficialExternalPluginLookup = (pluginId: string) => + | { + pluginId: string; + npmSpec?: string; + expectedIntegrity?: string; + } + | undefined; + +type OfficialExternalPackageLookup = (packageName: string) => + | { + pluginId: string; + npmSpec?: string; + expectedIntegrity?: string; + } + | undefined; + +function isBareNpmPackageName(spec: string): boolean { + const trimmed = spec.trim(); + return /^[a-z0-9][a-z0-9-._~]*$/.test(trimmed); +} + +function resolveCatalogInstall(value: string, lookup: "package" | "plugin") { + const entry = + lookup === "package" + ? getOfficialExternalPluginCatalogEntryForPackage(value) + : getOfficialExternalPluginCatalogEntry(value); + if (!entry) { + return undefined; + } + const pluginId = resolveOfficialExternalPluginId(entry); + if (!pluginId) { + return undefined; + } + const install = resolveOfficialExternalPluginInstall(entry); + return { + pluginId, + ...(install?.npmSpec ? { npmSpec: install.npmSpec } : {}), + ...(install?.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}), + }; +} + +function resolveOfficialExternalInstallPlanBeforeNpm(params: { + rawSpec: string; + findOfficialExternalPlugin: OfficialExternalPluginLookup; +}): { pluginId: string; npmSpec: string; expectedIntegrity?: string } | null { + if (!isBareNpmPackageName(params.rawSpec)) { + return null; + } + const entry = params.findOfficialExternalPlugin(params.rawSpec); + const npmSpec = entry?.npmSpec?.trim(); + if (!entry?.pluginId || !npmSpec) { + return null; + } + return { + pluginId: entry.pluginId, + npmSpec, + ...(entry.expectedIntegrity ? { expectedIntegrity: entry.expectedIntegrity } : {}), + }; +} + +function resolveOfficialExternalNpmPackageTrust(params: { + npmSpec: string; + findOfficialExternalPackage: OfficialExternalPackageLookup; +}): { + pluginId: string; + expectedIntegrity?: string; + trustedSourceLinkedOfficialInstall: true; +} | null { + const parsed = parseRegistryNpmSpec(params.npmSpec); + if (!parsed) { + return null; + } + const entry = params.findOfficialExternalPackage(parsed.name); + if (!entry?.pluginId) { + return null; + } + const catalogSpec = entry.npmSpec?.trim(); + const catalogPackageName = catalogSpec ? parseRegistryNpmSpec(catalogSpec)?.name : undefined; + if (catalogPackageName && catalogPackageName !== parsed.name) { + return null; + } + return { + pluginId: entry.pluginId, + ...(entry.expectedIntegrity && catalogSpec === params.npmSpec.trim() + ? { expectedIntegrity: entry.expectedIntegrity } + : {}), + trustedSourceLinkedOfficialInstall: true, + }; +} + +export function resolveCatalogOfficialExternalInstallPlan(rawSpec: string) { + return resolveOfficialExternalInstallPlanBeforeNpm({ + rawSpec, + findOfficialExternalPlugin: (pluginId) => resolveCatalogInstall(pluginId, "plugin"), + }); +} + +export function resolveCatalogOfficialExternalNpmPackageTrust(npmSpec: string) { + return resolveOfficialExternalNpmPackageTrust({ + npmSpec, + findOfficialExternalPackage: (packageName) => resolveCatalogInstall(packageName, "package"), + }); +} diff --git a/test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts b/test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts index 79b632de91ac..f4e8e5e50138 100644 --- a/test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts +++ b/test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts @@ -554,7 +554,7 @@ async function runPluginLifecycleMatrix() { summaryTsv, "install-v1", "node", - [entry, "plugins", "install", `npm:${packageName}@1.0.0`], + [entry, "plugins", "install", `npm:${packageName}@1.0.0`, "--force"], runEnv, ); assertVersion(pluginId, "1.0.0", runEnv); diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index ba71a3371eaa..f3812e4eab95 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -2168,6 +2168,7 @@ grep -qx -- "OPENCLAW_E2E_COMMAND_TIMEOUT=23s" "$TMPDIR/package-args" 'PLUGIN_INSTALL_LOG="$LOG_DIR/plugin-install.log"', 'AGENT_LOG="$LOG_DIR/agent.log"', 'plugin_dir="$(mktemp -d "$scenario_tmp/plugin.XXXXXX")"', + 'plugins install "$plugin_dir" --force', ], removed: [ "/tmp/openclaw-release-upgrade-user-journey-openai.jsonl", @@ -4472,19 +4473,19 @@ heartbeat_elapsed="\${BASH_REMATCH[1]}" expect(unboundedPluginCliLines, path).toEqual([]); } - expect(sweep).toContain('plugins install "$dir_plugin"'); + expect(sweep).toContain('plugins install "$dir_plugin" --force'); expect(sweep).toContain("plugins update demo-plugin-dir"); expect(assertions).toContain('Skipping "demo-plugin-dir" (source: path).'); expect(sweep).toContain("start_npm_fixture_registry"); - expect(sweep).toContain('plugins install "npm:@openclaw/demo-plugin-npm@0.0.1"'); + expect(sweep).toContain('plugins install "npm:@openclaw/demo-plugin-npm@0.0.1" --force'); expect(sweep).toContain("plugins update demo-plugin-npm"); expect(assertions).toContain("demo-plugin-npm is up to date (0.0.1)."); expect(npmRegistry).toContain('"dist-tags": { latest: entry.latestVersion }'); expect(npmRegistry).toContain("existing.latestVersion = version"); expect(npmRegistry).toContain("packageArgs.length % 3"); - expect(sweep).toContain('plugins install "git:$git_update_repo_url@main"'); + expect(sweep).toContain('plugins install "git:$git_update_repo_url@main" --force'); expect(sweep).toContain("plugins update demo-plugin-git-update"); expect(assertions).toContain("demo.git.update.v2"); diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 578ce008627a..e0ff0df2a870 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -138,8 +138,8 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { "npm-to-clawhub|clawhub:@openclaw/kitchen-sink@latest|openclaw-kitchen-sink-fixture|clawhub|success|basic||${KITCHEN_SINK_NPM_SPEC}", ); expect(script).toContain("scripts/e2e/lib/kitchen-sink-plugin/sweep.sh"); - expect(sweepScript).toContain('plugins install "$KITCHEN_SINK_SPEC"'); - expect(sweepScript).toContain('plugins install "$KITCHEN_SINK_PREINSTALL_SPEC"'); + expect(sweepScript).toContain('plugins install "$KITCHEN_SINK_SPEC" --force'); + expect(sweepScript).toContain('plugins install "$KITCHEN_SINK_PREINSTALL_SPEC" --force'); expect(sweepScript).toContain("assert-cutover-preinstalled"); expect(sweepScript).toContain('install_args+=("--force")'); expect(sweepScript).toContain("KITCHEN_SINK_PERSONALITY"); @@ -149,7 +149,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { sweepScript.indexOf("run_success_scenario()"), sweepScript.indexOf("run_failure_scenario()"), ); - expect(successScenario.indexOf('plugins install "${install_args[@]}"')).toBeLessThan( + expect(successScenario.indexOf('plugins install "${install_args[@]}" --force')).toBeLessThan( successScenario.indexOf("configure_kitchen_sink_runtime"), ); expect(successScenario.indexOf("configure_kitchen_sink_runtime")).toBeLessThan( diff --git a/test/scripts/plugin-update-unchanged-docker.test.ts b/test/scripts/plugin-update-unchanged-docker.test.ts index 1b1aafcc6a99..066269ae5f1a 100644 --- a/test/scripts/plugin-update-unchanged-docker.test.ts +++ b/test/scripts/plugin-update-unchanged-docker.test.ts @@ -201,6 +201,7 @@ describe("plugin update unchanged Docker E2E", () => { it("bounds corrupt plugin update commands and prints diagnostics on hangs", () => { const script = readFileSync(CORRUPT_UPDATE_SCENARIO_SCRIPT, "utf8"); + expect(script).toContain('plugins install "npm:@openclaw/demo-corrupt-plugin@0.0.1" --force'); expect(script).toContain("OPENCLAW_UPDATE_CORRUPT_PLUGIN_TIMEOUT_SECONDS"); expect(script).toContain( "openclaw_e2e_read_positive_int_env OPENCLAW_UPDATE_CORRUPT_PLUGIN_TIMEOUT_SECONDS 900",