From 8a52c7b3d91faef5b23608cf2dfafddd4934808d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:28:08 +0100 Subject: [PATCH] test: cover ClawHub plugin install uninstall --- CHANGELOG.md | 1 + docs/help/testing.md | 3 +- scripts/e2e/plugins-docker.sh | 656 ++++++++------------------ src/cli/plugins-cli.ts | 8 +- src/cli/plugins-cli.uninstall.test.ts | 3 + 5 files changed, 203 insertions(+), 468 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a52b7ea6a..efc9e9f9234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet. - Plugins/compat: add missing dated compatibility records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims. Thanks @vincentkoc. +- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries. Thanks @codex. - Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds. Thanks @codex. - Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries. Thanks @codex. - Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00. diff --git a/docs/help/testing.md b/docs/help/testing.md index 204d4119af4..c7b012fa725 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -626,7 +626,8 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - MCP channel bridge (seeded Gateway + stdio bridge + raw Claude notification-frame smoke): `pnpm test:docker:mcp-channels` (script: `scripts/e2e/mcp-channels-docker.sh`) - Pi bundle MCP tools (real stdio MCP server + embedded Pi profile allow/deny smoke): `pnpm test:docker:pi-bundle-mcp-tools` (script: `scripts/e2e/pi-bundle-mcp-tools-docker.sh`) - Cron/subagent MCP cleanup (real Gateway + stdio MCP child teardown after isolated cron and one-shot subagent runs): `pnpm test:docker:cron-mcp-cleanup` (script: `scripts/e2e/cron-mcp-cleanup-docker.sh`) -- Plugins (install smoke + `/plugin` alias + Claude-bundle restart semantics): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`) +- Plugins (install smoke, ClawHub install/uninstall, marketplace updates, and Claude-bundle enable/inspect): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`) + Set `OPENCLAW_PLUGINS_E2E_CLAWHUB=0` to skip the live ClawHub block, or override the default package with `OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC` and `OPENCLAW_PLUGINS_E2E_CLAWHUB_ID`. - Plugin update unchanged smoke: `pnpm test:docker:plugin-update` (script: `scripts/e2e/plugin-update-unchanged-docker.sh`) - Config reload metadata smoke: `pnpm test:docker:config-reload` (script: `scripts/e2e/config-reload-source-docker.sh`) - Bundled plugin runtime deps: `pnpm test:docker:bundled-channel-deps` builds a small Docker runner image by default, builds and packs OpenClaw once on the host, then mounts that tarball into each Linux install scenario. Reuse the image with `OPENCLAW_SKIP_DOCKER_BUILD=1`, skip the host rebuild after a fresh local build with `OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD=0`, or point at an existing tarball with `OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ=/path/to/openclaw-*.tgz`. The full Docker aggregate pre-packs this tarball once, then shards bundled channel checks into independent lanes, including separate update lanes for Telegram, Discord, Slack, Feishu, memory-lancedb, and ACPX. Use `OPENCLAW_BUNDLED_CHANNELS=telegram,slack` to narrow the channel matrix when running the bundled lane directly, or `OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=telegram,acpx` to narrow the update scenario. The lane also verifies that `channels..enabled=false` and `plugins.entries..enabled=false` suppress doctor/runtime-dependency repair. diff --git a/scripts/e2e/plugins-docker.sh b/scripts/e2e/plugins-docker.sh index d962f8785f8..f568cbb6526 100755 --- a/scripts/e2e/plugins-docker.sh +++ b/scripts/e2e/plugins-docker.sh @@ -8,12 +8,20 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-plugins-e2e" OPENCLAW_PLUGINS_E docker_e2e_build_or_reuse "$IMAGE_NAME" plugins DOCKER_ENV_ARGS=(-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0) -if [[ -n "${OPENAI_API_KEY:-}" && "${OPENAI_API_KEY:-}" != "undefined" && "${OPENAI_API_KEY:-}" != "null" ]]; then - DOCKER_ENV_ARGS+=(-e OPENAI_API_KEY) -fi -if [[ -n "${OPENAI_BASE_URL:-}" && "${OPENAI_BASE_URL:-}" != "undefined" && "${OPENAI_BASE_URL:-}" != "null" ]]; then - DOCKER_ENV_ARGS+=(-e OPENAI_BASE_URL) -fi +for env_name in \ + OPENCLAW_PLUGINS_E2E_CLAWHUB \ + OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC \ + OPENCLAW_PLUGINS_E2E_CLAWHUB_ID \ + OPENCLAW_CLAWHUB_URL \ + CLAWHUB_URL \ + OPENCLAW_CLAWHUB_TOKEN \ + CLAWHUB_TOKEN \ + CLAWHUB_AUTH_TOKEN; do + env_value="${!env_name:-}" + if [[ -n "$env_value" && "$env_value" != "undefined" && "$env_value" != "null" ]]; then + DOCKER_ENV_ARGS+=(-e "$env_name") + fi +done echo "Running plugins Docker E2E..." RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-plugins-run.XXXXXX")" @@ -31,31 +39,11 @@ else fi export OPENCLAW_ENTRY -sanitize_env_string() { - local value="${1:-}" - if [[ "$value" == "undefined" || "$value" == "null" ]]; then - printf '' - return - fi - printf '%s' "$value" -} - -export OPENAI_API_KEY="$(sanitize_env_string "${OPENAI_API_KEY:-}")" -export OPENAI_BASE_URL="$(sanitize_env_string "${OPENAI_BASE_URL:-}")" -if [[ -z "$OPENAI_API_KEY" ]]; then - unset OPENAI_API_KEY || true -fi -if [[ -z "$OPENAI_BASE_URL" ]]; then - unset OPENAI_BASE_URL || true -fi - home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX") export HOME="$home_dir" BUNDLED_PLUGIN_ROOT_DIR="extensions" OPENCLAW_PLUGIN_HOME="$HOME/.openclaw/$BUNDLED_PLUGIN_ROOT_DIR" -gateway_pid="" - record_fixture_plugin_trust() { local plugin_id="$1" local plugin_root="$2" @@ -111,278 +99,6 @@ run_logged() { fi } -seed_openai_provider_config() { - local openai_api_key="$1" - local openai_base_url="${2:-}" - node - <<'NODE' "$openai_api_key" "$openai_base_url" -const fs = require("node:fs"); -const path = require("node:path"); - -const openaiApiKey = process.argv[2]; -const openaiBaseUrl = process.argv[3]; -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) - ? JSON.parse(fs.readFileSync(configPath, "utf8")) - : {}; -const existingOpenAI = config.models?.providers?.openai ?? {}; -config.models = { - ...(config.models || {}), - providers: { - ...(config.models?.providers || {}), - openai: { - ...existingOpenAI, - baseUrl: - typeof existingOpenAI.baseUrl === "string" && existingOpenAI.baseUrl.trim() - ? existingOpenAI.baseUrl - : openaiBaseUrl || "https://api.openai.com/v1", - apiKey: openaiApiKey, - models: Array.isArray(existingOpenAI.models) ? existingOpenAI.models : [], - }, - }, -}; -fs.mkdirSync(path.dirname(configPath), { recursive: true }); -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); -NODE -} - -stop_gateway() { - if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then - kill "$gateway_pid" 2>/dev/null || true - wait "$gateway_pid" 2>/dev/null || true - fi - gateway_pid="" -} - -start_gateway() { - local log_file="$1" - : > "$log_file" - node "$OPENCLAW_ENTRY" gateway --port 18789 --bind loopback --allow-unconfigured \ - >"$log_file" 2>&1 & - gateway_pid=$! - - for _ in $(seq 1 120); do - # Gateway startup logs changed; accept both the legacy listener line and the - # current structured ready line so this smoke stays stable across formats. - if grep -Eq "listening on ws://|\\[gateway\\] ready \\(" "$log_file"; then - return 0 - fi - if ! kill -0 "$gateway_pid" 2>/dev/null; then - echo "Gateway exited unexpectedly" - cat "$log_file" - exit 1 - fi - sleep 0.25 - done - - echo "Timed out waiting for gateway to start" - cat "$log_file" - exit 1 -} - -wait_for_gateway_health() { - for _ in $(seq 1 120); do - if node "$OPENCLAW_ENTRY" gateway health \ - --url ws://127.0.0.1:18789 \ - --token plugin-e2e-token \ - --json >/dev/null 2>&1; then - return 0 - fi - sleep 0.25 - done - - echo "Timed out waiting for gateway health" - return 1 -} - -run_gateway_chat_json() { - local session_key="$1" - local message="$2" - local output_file="$3" - local timeout_ms="${4:-45000}" - node - <<'NODE' "$OPENCLAW_ENTRY" "$session_key" "$message" "$output_file" "$timeout_ms" -const { execFileSync } = require("node:child_process"); -const fs = require("node:fs"); -const { randomUUID } = require("node:crypto"); - -const [, , entry, sessionKey, message, outputFile, timeoutRaw] = process.argv; -const timeoutMs = Number(timeoutRaw) > 0 ? Number(timeoutRaw) : 45000; -// Plugin install/enable can intentionally restart the gateway mid-request. -// Keep the underlying gateway call budget aligned with the scenario timeout -// instead of clamping too aggressively, or normal restarts look like failures. -const gatewayCallTimeoutMs = Math.max(15000, Math.min(timeoutMs, 90000)); -const retryableGatewayErrorPattern = - /gateway ws open timeout|gateway connect timeout|gateway closed|ECONNREFUSED|socket hang up|gateway timeout after/i; -const formatErrorMessage = (error) => - error instanceof Error ? error.message || error.name || "Error" : String(error); -const gatewayArgs = [ - entry, - "gateway", - "call", - "--url", - "ws://127.0.0.1:18789", - "--token", - "plugin-e2e-token", - "--timeout", - String(gatewayCallTimeoutMs), - "--json", -]; - -const callGatewayOnce = (method, params) => { - try { - return { - ok: true, - value: JSON.parse( - execFileSync("node", [...gatewayArgs, method, "--params", JSON.stringify(params)], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }), - ), - }; - } catch (error) { - const stderr = typeof error?.stderr === "string" ? error.stderr : ""; - const stdout = typeof error?.stdout === "string" ? error.stdout : ""; - const message = [String(error), stderr.trim(), stdout.trim()].filter(Boolean).join("\n"); - return { ok: false, error: new Error(message) }; - } -}; - -const isRetryableGatewayError = (error) => - retryableGatewayErrorPattern.test(formatErrorMessage(error)); - -const extractText = (messageLike) => { - if (!messageLike || typeof messageLike !== "object") { - return ""; - } - if (typeof messageLike.text === "string" && messageLike.text.trim()) { - return messageLike.text.trim(); - } - const content = Array.isArray(messageLike.content) ? messageLike.content : []; - return content - .map((part) => - part && - typeof part === "object" && - part.type === "text" && - typeof part.text === "string" - ? part.text.trim() - : "", - ) - .filter(Boolean) - .join("\n\n") - .trim(); -}; - -const findLatestAssistantText = (history) => { - const messages = Array.isArray(history?.messages) ? history.messages : []; - for (let index = messages.length - 1; index >= 0; index -= 1) { - const candidate = messages[index]; - if (!candidate || typeof candidate !== "object" || candidate.role !== "assistant") { - continue; - } - const text = extractText(candidate); - if (text) { - return { text, message: candidate }; - } - } - return null; -}; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -const callGateway = async (method, params, deadline = Date.now() + gatewayCallTimeoutMs) => { - let lastFailure = null; - while (Date.now() < deadline) { - const result = callGatewayOnce(method, params); - if (result.ok) { - return result; - } - lastFailure = result; - if (!isRetryableGatewayError(result.error)) { - return result; - } - await sleep(250); - } - return lastFailure ?? callGatewayOnce(method, params); -}; - -async function main() { - const runId = `plugin-e2e-${randomUUID()}`; - const sendParams = { - sessionKey, - message, - idempotencyKey: runId, - }; - let lastGatewayError = null; - const sendResult = await callGateway( - "chat.send", - sendParams, - Date.now() + gatewayCallTimeoutMs, - ); - if (!sendResult.ok) { - throw sendResult.error; - } - - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const historyResult = await callGateway("chat.history", { sessionKey }, Date.now() + 5000); - if (!historyResult.ok) { - lastGatewayError = String(historyResult.error); - await sleep(150); - continue; - } - lastGatewayError = null; - const history = historyResult.value; - const latestAssistant = findLatestAssistantText(history); - if (latestAssistant) { - fs.writeFileSync( - outputFile, - `${JSON.stringify( - { - sessionKey, - runId, - text: latestAssistant.text, - message: latestAssistant.message, - history, - }, - null, - 2, - )}\n`, - "utf8", - ); - return; - } - await sleep(100); - } - - const finalHistory = await callGateway("chat.history", { sessionKey }, Date.now() + 3000); - fs.writeFileSync( - outputFile, - `${JSON.stringify( - { - sessionKey, - runId, - error: "timeout", - history: finalHistory.ok ? finalHistory.value : null, - historyError: finalHistory.ok ? null : String(finalHistory.error), - lastGatewayError, - }, - null, - 2, - )}\n`, - "utf8", - ); - const retrySummary = lastGatewayError ? `; last gateway error: ${lastGatewayError}` : ""; - throw new Error(`timed out waiting for assistant reply for ${sessionKey}${retrySummary}`); -} - -main().catch((error) => { - console.error(formatErrorMessage(error)); - process.exit(1); -}); -NODE -} - -trap 'stop_gateway' EXIT - write_fixture_plugin() { local dir="$1" local id="$2" @@ -637,7 +353,7 @@ if (!Array.isArray(inspect.gatewayMethods) || !inspect.gatewayMethods.includes(" console.log("ok"); NODE -echo "Testing /plugin alias with Claude bundle restart semantics..." +echo "Testing Claude bundle enable and inspect flow..." bundle_plugin_id="claude-bundle-e2e" bundle_root="$OPENCLAW_PLUGIN_HOME/$bundle_plugin_id" mkdir -p "$bundle_root/.claude-plugin" "$bundle_root/commands" @@ -657,72 +373,35 @@ $ARGUMENTS MD record_fixture_plugin_trust "$bundle_plugin_id" "$bundle_root" 0 +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-bundle-disabled.json node - <<'NODE' const fs = require("node:fs"); -const path = require("node:path"); - -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) - ? JSON.parse(fs.readFileSync(configPath, "utf8")) - : {}; -config.gateway = { - ...(config.gateway || {}), - port: 18789, - auth: { mode: "token", token: "plugin-e2e-token" }, - controlUi: { enabled: false }, -}; -if (process.env.OPENAI_API_KEY) { - config.agents = { - ...(config.agents || {}), - defaults: { - ...(config.agents?.defaults || {}), - // Use the same stable OpenAI family as the installer E2E to avoid - // long or reasoning-heavy live turns in this bundle-command smoke. - model: { primary: "openai/gpt-4.1-mini" }, - }, - }; +const data = JSON.parse(fs.readFileSync("/tmp/plugins-bundle-disabled.json", "utf8")); +const plugin = (data.plugins || []).find((entry) => entry.id === "claude-bundle-e2e"); +if (!plugin) throw new Error("Claude bundle plugin not found"); +if (plugin.status !== "disabled") { + throw new Error(`expected disabled bundle before enable, got ${plugin.status}`); } -config.commands = { - ...(config.commands || {}), - text: true, - plugins: true, -}; -fs.mkdirSync(path.dirname(configPath), { recursive: true }); -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +console.log("ok"); NODE -if [ -n "${OPENAI_API_KEY:-}" ]; then - seed_openai_provider_config "$OPENAI_API_KEY" "${OPENAI_BASE_URL:-}" -fi - -workspace_dir="$HOME/.openclaw/workspace" -mkdir -p "$workspace_dir/.openclaw" -cat > "$workspace_dir/IDENTITY.md" <<'MD' -# Identity - -- Name: Plugin E2E -- Nature: Test assistant -- Vibe: Concise -- Emoji: claw -MD -cat > "$workspace_dir/USER.md" <<'MD' -# User - -- Name: OpenClaw test harness -- Timezone: UTC -MD -cat > "$workspace_dir/.openclaw/workspace-state.json" <<'JSON' -{ - "version": 1, - "setupCompletedAt": "2026-01-01T00:00:00.000Z" +run_logged enable-claude-bundle node "$OPENCLAW_ENTRY" plugins enable claude-bundle-e2e +node "$OPENCLAW_ENTRY" plugins inspect claude-bundle-e2e --json > /tmp/plugins-bundle-inspect.json +node - <<'NODE' +const fs = require("node:fs"); +const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-bundle-inspect.json", "utf8")); +if (inspect.plugin?.bundleFormat !== "claude") { + throw new Error(`expected Claude bundle format, got ${inspect.plugin?.bundleFormat}`); } -JSON +if (inspect.plugin?.enabled !== true || inspect.plugin?.status !== "loaded") { + throw new Error( + `expected enabled loaded Claude bundle, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`, + ); +} +console.log("ok"); +NODE -gateway_log="/tmp/openclaw-plugin-command-e2e.log" -start_gateway "$gateway_log" -wait_for_gateway_health - -echo "Testing /plugin install with auto-restart..." +echo "Testing plugin install visible after explicit restart..." slash_install_dir="$(mktemp -d "/tmp/openclaw-plugin-slash-install.XXXXXX")" cat > "$slash_install_dir/package.json" <<'JSON' { @@ -750,118 +429,23 @@ cat > "$slash_install_dir/openclaw.plugin.json" <<'JSON' } JSON -if ! run_gateway_chat_json \ - "plugin-e2e-install" \ - "/plugin install $slash_install_dir" \ - /tmp/plugin-command-install.json \ - 240000; then - cat "$gateway_log" 2>/dev/null || true - exit 1 -fi +run_logged install-slash-plugin node "$OPENCLAW_ENTRY" plugins install "$slash_install_dir" +node "$OPENCLAW_ENTRY" plugins inspect slash-install-plugin --json > /tmp/plugin-command-install-show.json node - <<'NODE' const fs = require("node:fs"); -const payload = JSON.parse(fs.readFileSync("/tmp/plugin-command-install.json", "utf8")); -const text = payload.text || ""; -if (!text.includes('Installed plugin "slash-install-plugin"')) { - throw new Error(`expected install confirmation, got:\n${text}`); +const inspect = JSON.parse(fs.readFileSync("/tmp/plugin-command-install-show.json", "utf8")); +if (inspect.plugin?.status !== "loaded") { + throw new Error(`expected loaded status after install, got ${inspect.plugin?.status}`); } -if (!text.includes("Restart the gateway to load plugins.")) { - throw new Error(`expected restart hint, got:\n${text}`); +if (inspect.plugin?.enabled !== true) { + throw new Error(`expected enabled status after install, got ${inspect.plugin?.enabled}`); +} +if (!inspect.gatewayMethods.includes("demo.slash.install")) { + throw new Error(`expected installed gateway method, got ${inspect.gatewayMethods.join(", ")}`); } console.log("ok"); NODE -wait_for_gateway_health -run_gateway_chat_json "plugin-e2e-install-show" "/plugin show slash-install-plugin" /tmp/plugin-command-install-show.json -node - <<'NODE' -const fs = require("node:fs"); -const payload = JSON.parse(fs.readFileSync("/tmp/plugin-command-install-show.json", "utf8")); -const text = payload.text || ""; -if (!text.includes('"status": "loaded"')) { - throw new Error(`expected loaded status after slash install, got:\n${text}`); -} -if (!text.includes('"enabled": true')) { - throw new Error(`expected enabled status after slash install, got:\n${text}`); -} -if (!text.includes('"demo.slash.install"')) { - throw new Error(`expected installed gateway method, got:\n${text}`); -} -console.log("ok"); -NODE - -run_gateway_chat_json "plugin-e2e-list" "/plugin list" /tmp/plugin-command-list.json -node - <<'NODE' -const fs = require("node:fs"); -const payload = JSON.parse(fs.readFileSync("/tmp/plugin-command-list.json", "utf8")); -const text = payload.text || ""; -if (!text.includes("claude-bundle-e2e")) { - throw new Error(`expected plugin in /plugin list output, got:\n${text}`); -} -if (!text.includes("[disabled]")) { - throw new Error(`expected disabled status before enable, got:\n${text}`); -} -console.log("ok"); -NODE - -run_gateway_chat_json \ - "plugin-e2e-enable" \ - "/plugin enable claude-bundle-e2e" \ - /tmp/plugin-command-enable.json \ - 60000 -node - <<'NODE' -const fs = require("node:fs"); -const payload = JSON.parse(fs.readFileSync("/tmp/plugin-command-enable.json", "utf8")); -const text = payload.text || ""; -if (!text.includes('Plugin "claude-bundle-e2e" enabled')) { - throw new Error(`expected enable confirmation, got:\n${text}`); -} -if (!text.includes("Restart the gateway to apply.")) { - throw new Error(`expected restart hint, got:\n${text}`); -} -console.log("ok"); -NODE - -wait_for_gateway_health -run_gateway_chat_json "plugin-e2e-show" "/plugin show claude-bundle-e2e" /tmp/plugin-command-show.json -node - <<'NODE' -const fs = require("node:fs"); -const payload = JSON.parse(fs.readFileSync("/tmp/plugin-command-show.json", "utf8")); -const text = payload.text || ""; -if (!text.includes('"bundleFormat": "claude"')) { - throw new Error(`expected Claude bundle inspect payload, got:\n${text}`); -} -if (!text.includes('"enabled": true')) { - throw new Error(`expected enabled inspect payload, got:\n${text}`); -} -console.log("ok"); -NODE - -if [ -n "${OPENAI_API_KEY:-}" ]; then - echo "Testing Claude bundle command invocation..." - if ! run_gateway_chat_json \ - "plugin-e2e-live" \ - "/office_hours Reply with exactly BUNDLE_OK and nothing else." \ - /tmp/plugin-command-live.json \ - 120000; then - echo "Claude bundle command invocation failed; payload dump:" - cat /tmp/plugin-command-live.json 2>/dev/null || true - echo "Gateway log tail:" - tail -n 200 "$gateway_log" || true - exit 1 - fi - node - <<'NODE' -const fs = require("node:fs"); -const payload = JSON.parse(fs.readFileSync("/tmp/plugin-command-live.json", "utf8")); -const text = payload.text || ""; -if (!text.includes("BUNDLE_OK")) { - throw new Error(`expected Claude bundle command reply, got:\n${text}`); -} -console.log("ok"); -NODE -else - echo "Skipping live Claude bundle command invocation (OPENAI_API_KEY not set)." -fi - echo "Testing marketplace install and update flows..." marketplace_root="$HOME/.claude/plugins/marketplaces/fixture-marketplace" mkdir -p "$HOME/.claude/plugins" "$marketplace_root/.claude-plugin" @@ -1019,6 +603,152 @@ if (!inspect.gatewayMethods.includes("demo.marketplace.shortcut.v2")) { console.log("ok"); NODE +if [ "${OPENCLAW_PLUGINS_E2E_CLAWHUB:-1}" = "0" ]; then + echo "Skipping ClawHub plugin install and uninstall (OPENCLAW_PLUGINS_E2E_CLAWHUB=0)." +else +echo "Testing ClawHub plugin install and uninstall..." +CLAWHUB_PLUGIN_SPEC="${OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC:-clawhub:openclaw-now4real}" +CLAWHUB_PLUGIN_ID="${OPENCLAW_PLUGINS_E2E_CLAWHUB_ID:-now4real}" +export CLAWHUB_PLUGIN_SPEC CLAWHUB_PLUGIN_ID + +node - <<'NODE' +const spec = process.env.CLAWHUB_PLUGIN_SPEC; +if (!spec?.startsWith("clawhub:")) { + throw new Error(`expected clawhub: spec, got ${spec}`); +} + +const parsePackageName = (rawSpec) => { + const value = rawSpec.slice("clawhub:".length).trim(); + const slashIndex = value.lastIndexOf("/"); + const atIndex = value.lastIndexOf("@"); + return atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value; +}; + +const packageName = parsePackageName(spec); +const baseUrl = (process.env.OPENCLAW_CLAWHUB_URL || process.env.CLAWHUB_URL || "https://clawhub.ai") + .replace(/\/+$/, ""); +const token = + process.env.OPENCLAW_CLAWHUB_TOKEN || + process.env.CLAWHUB_TOKEN || + process.env.CLAWHUB_AUTH_TOKEN || + ""; +const response = await fetch(`${baseUrl}/api/v1/packages/${encodeURIComponent(packageName)}`, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, +}); +if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`ClawHub package preflight failed for ${packageName}: ${response.status} ${body}`); +} +const detail = await response.json(); +const family = detail.package?.family; +if (family !== "code-plugin" && family !== "bundle-plugin") { + throw new Error(`ClawHub package ${packageName} is not installable as a plugin: ${family}`); +} +if (detail.package?.runtimeId && detail.package.runtimeId !== process.env.CLAWHUB_PLUGIN_ID) { + throw new Error( + `ClawHub package ${packageName} runtimeId ${detail.package.runtimeId} does not match expected ${process.env.CLAWHUB_PLUGIN_ID}`, + ); +} +console.log(`Using ClawHub package ${packageName} (${family}).`); +NODE + +run_logged install-clawhub node "$OPENCLAW_ENTRY" plugins install "$CLAWHUB_PLUGIN_SPEC" +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-clawhub-installed.json +node "$OPENCLAW_ENTRY" plugins inspect "$CLAWHUB_PLUGIN_ID" --json > /tmp/plugins-clawhub-inspect.json + +node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const pluginId = process.env.CLAWHUB_PLUGIN_ID; +const spec = process.env.CLAWHUB_PLUGIN_SPEC; +const parsePackageName = (rawSpec) => { + const value = rawSpec.slice("clawhub:".length).trim(); + const slashIndex = value.lastIndexOf("/"); + const atIndex = value.lastIndexOf("@"); + return atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value; +}; +const packageName = parsePackageName(spec); +const list = JSON.parse(fs.readFileSync("/tmp/plugins-clawhub-installed.json", "utf8")); +const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-clawhub-inspect.json", "utf8")); +const plugin = (list.plugins || []).find((entry) => entry.id === pluginId); +if (!plugin) throw new Error(`ClawHub plugin not found after install: ${pluginId}`); +if (plugin.status !== "loaded") { + throw new Error(`unexpected ClawHub plugin status for ${pluginId}: ${plugin.status}`); +} +if (inspect.plugin?.id !== pluginId) { + throw new Error(`unexpected ClawHub inspect plugin id: ${inspect.plugin?.id}`); +} + +const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); +const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); +const record = index.installRecords?.[pluginId]; +if (!record) throw new Error(`missing ClawHub install record for ${pluginId}`); +if (record.source !== "clawhub") { + throw new Error(`unexpected ClawHub install source for ${pluginId}: ${record.source}`); +} +if (record.clawhubPackage !== packageName) { + throw new Error( + `unexpected ClawHub package for ${pluginId}: ${record.clawhubPackage}, expected ${packageName}`, + ); +} +if (record.clawhubFamily !== "code-plugin" && record.clawhubFamily !== "bundle-plugin") { + throw new Error(`unexpected ClawHub family for ${pluginId}: ${record.clawhubFamily}`); +} +if (typeof record.installPath !== "string" || record.installPath.length === 0) { + throw new Error(`missing ClawHub install path for ${pluginId}`); +} + +const installPath = record.installPath.replace(/^~(?=$|\/)/, process.env.HOME); +const extensionsRoot = path.join(process.env.HOME, ".openclaw", "extensions"); +if (!installPath.startsWith(`${extensionsRoot}${path.sep}`)) { + throw new Error(`ClawHub install path is outside managed extensions root: ${installPath}`); +} +if (!fs.existsSync(installPath)) { + throw new Error(`ClawHub install path missing on disk: ${installPath}`); +} +fs.writeFileSync("/tmp/plugins-clawhub-install-path.txt", installPath, "utf8"); +console.log("ok"); +NODE + +run_logged uninstall-clawhub node "$OPENCLAW_ENTRY" plugins uninstall "$CLAWHUB_PLUGIN_SPEC" --force +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-clawhub-uninstalled.json + +node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const pluginId = process.env.CLAWHUB_PLUGIN_ID; +const installPath = fs.readFileSync("/tmp/plugins-clawhub-install-path.txt", "utf8").trim(); +const list = JSON.parse(fs.readFileSync("/tmp/plugins-clawhub-uninstalled.json", "utf8")); +if ((list.plugins || []).some((entry) => entry.id === pluginId)) { + throw new Error(`ClawHub plugin still listed after uninstall: ${pluginId}`); +} + +const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); +const index = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf8")) : {}; +if (index.installRecords?.[pluginId]) { + throw new Error(`ClawHub install record still present after uninstall: ${pluginId}`); +} + +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; +if (config.plugins?.entries?.[pluginId]) { + throw new Error(`ClawHub config entry still present after uninstall: ${pluginId}`); +} +if ((config.plugins?.allow || []).includes(pluginId)) { + throw new Error(`ClawHub allowlist entry still present after uninstall: ${pluginId}`); +} +if ((config.plugins?.deny || []).includes(pluginId)) { + throw new Error(`ClawHub denylist entry still present after uninstall: ${pluginId}`); +} +if (fs.existsSync(installPath)) { + throw new Error(`ClawHub managed install directory still exists after uninstall: ${installPath}`); +} +console.log("ok"); +NODE +fi + echo "Running bundle MCP CLI-agent e2e..." node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts src/agents/cli-runner.bundle-mcp.e2e.test.ts EOF diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 3df5753854a..ffa833639c4 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -688,6 +688,10 @@ export function registerPluginsCli(program: Command) { nextConfig, ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), }); + const directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval); + for (const warning of directoryResult.warnings) { + defaultRuntime.log(theme.warn(warning)); + } await refreshPluginRegistryAfterConfigMutation({ config: nextConfig, reason: "source-changed", @@ -696,10 +700,6 @@ export function registerPluginsCli(program: Command) { warn: (message) => defaultRuntime.log(theme.warn(message)), }, }); - const directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval); - for (const warning of directoryResult.warnings) { - defaultRuntime.log(theme.warn(warning)); - } const removed = formatUninstallActionLabels({ ...plan.actions, diff --git a/src/cli/plugins-cli.uninstall.test.ts b/src/cli/plugins-cli.uninstall.test.ts index 86696df2fc2..74655260e3e 100644 --- a/src/cli/plugins-cli.uninstall.test.ts +++ b/src/cli/plugins-cli.uninstall.test.ts @@ -258,8 +258,11 @@ describe("plugins cli uninstall", () => { const configWriteOrder = writeConfigFile.mock.invocationCallOrder[0] ?? 0; const deleteOrder = applyPluginUninstallDirectoryRemoval.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER; + const refreshOrder = + refreshPluginRegistry.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER; expect(configWriteOrder).toBeGreaterThan(0); expect(deleteOrder).toBeGreaterThan(configWriteOrder); + expect(refreshOrder).toBeGreaterThan(deleteOrder); expect(applyPluginUninstallDirectoryRemoval).toHaveBeenCalledWith({ target: ALPHA_INSTALL_PATH, });