From 22fa77de31abb976e95150a96b96042592322a67 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 1 May 2026 00:06:13 -0700 Subject: [PATCH] test(e2e): add bundled plugin runtime smoke --- ...bundled-plugin-install-uninstall-docker.sh | 9 +- .../runtime-smoke.mjs | 654 ++++++++++++++++++ .../bundled-plugin-install-uninstall/sweep.sh | 9 + 3 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs diff --git a/scripts/e2e/bundled-plugin-install-uninstall-docker.sh b/scripts/e2e/bundled-plugin-install-uninstall-docker.sh index 0467abd91f0..b25364edeb0 100755 --- a/scripts/e2e/bundled-plugin-install-uninstall-docker.sh +++ b/scripts/e2e/bundled-plugin-install-uninstall-docker.sh @@ -15,7 +15,14 @@ DOCKER_ENV_ARGS=( for env_name in \ OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL \ OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX \ - OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS; do + OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS \ + OPENCLAW_BUNDLED_PLUGIN_RUNTIME_SMOKE \ + OPENCLAW_BUNDLED_PLUGIN_RUNTIME_PORT_BASE \ + OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS \ + OPENCLAW_BUNDLED_PLUGIN_RUNTIME_RPC_MS \ + OPENCLAW_BUNDLED_PLUGIN_RUNTIME_WATCHDOG_MS \ + OPENCLAW_BUNDLED_PLUGIN_TTS_LIVE_PROVIDER \ + OPENAI_API_KEY; do env_value="${!env_name:-}" if [[ -n "$env_value" && "$env_value" != "undefined" && "$env_value" != "null" ]]; then DOCKER_ENV_ARGS+=(-e "$env_name") diff --git a/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs b/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs new file mode 100644 index 00000000000..35962cd0c10 --- /dev/null +++ b/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs @@ -0,0 +1,654 @@ +import childProcess from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { setTimeout as delay } from "node:timers/promises"; + +const TOKEN = "bundled-plugin-runtime-smoke-token"; +const WATCHDOG_MS = readPositiveInt(process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_WATCHDOG_MS, 1000); +const READY_TIMEOUT_MS = readPositiveInt( + process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS, + 180000, +); +const RPC_TIMEOUT_MS = readPositiveInt(process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_RPC_MS, 30000); + +function readPositiveInt(raw, fallback) { + const parsed = Number.parseInt(String(raw || ""), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function manifestPath(pluginDir, env = process.env) { + return path.join(process.cwd(), "dist", "extensions", pluginDir, "openclaw.plugin.json"); +} + +function loadManifest(pluginDir) { + const file = manifestPath(pluginDir); + if (!fs.existsSync(file)) { + throw new Error(`missing bundled plugin manifest: ${file}`); + } + return readJson(file); +} + +function configPathFromEnv(env = process.env) { + return ( + env.OPENCLAW_CONFIG_PATH || path.join(env.HOME || os.homedir(), ".openclaw", "openclaw.json") + ); +} + +function readConfig(env = process.env) { + const configPath = configPathFromEnv(env); + return fs.existsSync(configPath) ? readJson(configPath) : {}; +} + +function writeConfig(config, env = process.env) { + writeJson(configPathFromEnv(env), config); +} + +function ensureGatewayConfig(config, port) { + return { + ...config, + gateway: { + ...(config.gateway ?? {}), + port, + bind: "loopback", + auth: { + mode: "token", + token: TOKEN, + }, + controlUi: { + ...(config.gateway?.controlUi ?? {}), + enabled: false, + }, + }, + }; +} + +function buildPluginPlan(manifest) { + const contracts = + manifest.contracts && typeof manifest.contracts === "object" ? manifest.contracts : {}; + const commandAliases = Array.isArray(manifest.commandAliases) ? manifest.commandAliases : []; + return { + channels: Array.isArray(manifest.channels) ? manifest.channels.filter(isNonEmptyString) : [], + speechProviders: Array.isArray(contracts.speechProviders) + ? contracts.speechProviders.filter(isNonEmptyString) + : [], + tools: Array.isArray(contracts.tools) ? contracts.tools.filter(isNonEmptyString) : [], + runtimeSlashAliases: commandAliases + .filter((alias) => alias?.kind === "runtime-slash") + .map((alias) => alias?.name) + .filter(isNonEmptyString), + }; +} + +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function runCommand(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = childProcess.spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + child.on("error", reject); + child.on("close", (status, signal) => { + if (status === 0) { + resolve({ stdout, stderr }); + return; + } + const detail = [stdout, stderr].filter(Boolean).join("\n").trim(); + reject( + new Error( + `${command} ${args.join(" ")} failed with ${signal || status}${detail ? `\n${detail}` : ""}`, + ), + ); + }); + }); +} + +function startGateway(params) { + const log = fs.openSync(params.logPath, "w"); + const child = childProcess.spawn( + "node", + [ + params.entrypoint, + "gateway", + "--port", + String(params.port), + "--bind", + "loopback", + "--allow-unconfigured", + ], + { + env: { + ...process.env, + ...params.env, + OPENCLAW_NO_ONBOARD: "1", + OPENCLAW_SKIP_CHANNELS: "1", + }, + stdio: ["ignore", log, log], + detached: false, + }, + ); + fs.closeSync(log); + return child; +} + +async function stopGateway(child) { + if (!child || child.exitCode !== null) { + return; + } + child.kill("SIGTERM"); + const started = Date.now(); + while (child.exitCode === null && Date.now() - started < 10000) { + await delay(100); + } + if (child.exitCode === null) { + child.kill("SIGKILL"); + } +} + +async function waitForReady(params) { + const started = Date.now(); + let lastError = ""; + while (Date.now() - started < READY_TIMEOUT_MS) { + if (params.child.exitCode !== null) { + throw new Error(`gateway exited before ready\n${tailFile(params.logPath)}`); + } + try { + const res = await fetch(`http://127.0.0.1:${params.port}/readyz`); + if (res.ok) { + return; + } + lastError = `readyz status ${res.status}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await delay(250); + } + throw new Error(`gateway did not become ready: ${lastError}\n${tailFile(params.logPath)}`); +} + +async function assertHttpOk(port, pathName) { + const res = await fetch(`http://127.0.0.1:${port}${pathName}`); + if (!res.ok) { + throw new Error(`${pathName} returned HTTP ${res.status}`); + } +} + +async function rpcCall(method, params, options) { + const args = [ + options.entrypoint, + "gateway", + "call", + method, + "--url", + `ws://127.0.0.1:${options.port}`, + "--token", + TOKEN, + "--timeout", + String(RPC_TIMEOUT_MS), + "--json", + "--params", + JSON.stringify(params ?? {}), + ]; + const { stdout } = await runCommand("node", args, { + env: { + ...process.env, + ...options.env, + OPENCLAW_NO_ONBOARD: "1", + }, + }); + return unwrapRpcPayload(parseJsonOutput(stdout)); +} + +function parseJsonOutput(stdout) { + const trimmed = stdout.trim(); + if (!trimmed) { + throw new Error("gateway call produced no JSON output"); + } + try { + return JSON.parse(trimmed); + } catch { + const jsonStart = trimmed.indexOf("{"); + if (jsonStart >= 0) { + try { + return JSON.parse(trimmed.slice(jsonStart)); + } catch { + // Fall through to the line-oriented fallback below. + } + } + const jsonLine = trimmed + .split(/\r?\n/u) + .reverse() + .find((line) => line.trim().startsWith("{")); + if (!jsonLine) { + throw new Error(`gateway call JSON output was not parseable:\n${trimmed}`); + } + return JSON.parse(jsonLine); + } +} + +function unwrapRpcPayload(raw) { + if (raw?.ok === false) { + throw new Error(`gateway RPC failed: ${JSON.stringify(raw.error ?? raw)}`); + } + return raw?.result ?? raw?.payload ?? raw?.data ?? raw; +} + +async function smokePlugin(pluginId, pluginDir, requiresConfig, pluginIndex) { + if (requiresConfig) { + console.log(`Runtime smoke skipped for ${pluginId}: plugin requires config`); + return; + } + const entrypoint = process.env.OPENCLAW_ENTRY; + if (!entrypoint) { + throw new Error("missing OPENCLAW_ENTRY"); + } + const manifest = loadManifest(pluginDir); + const plan = buildPluginPlan(manifest); + const port = + readPositiveInt(process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_PORT_BASE, 19000) + pluginIndex * 3; + const config = ensureGatewayConfig(readConfig(), port); + if (plan.speechProviders[0]) { + config.messages = { + ...(config.messages ?? {}), + tts: { + ...(config.messages?.tts ?? {}), + provider: plan.speechProviders[0], + }, + }; + } + writeConfig(config); + + const logPath = `/tmp/openclaw-plugin-runtime-${pluginIndex}-${pluginId}.log`; + const child = startGateway({ entrypoint, port, logPath, env: process.env }); + try { + await waitForReady({ child, port, logPath }); + await assertBaseGatewayProbes({ entrypoint, port, env: process.env }); + await runManifestProbes(plan, { entrypoint, port, env: process.env, pluginId }); + await runWatchdog({ child, logPath, port, entrypoint, env: process.env, pluginId }); + console.log(`Runtime smoke passed for ${pluginId}`); + } catch (error) { + console.error(tailFile(logPath)); + throw error; + } finally { + await stopGateway(child); + } +} + +async function assertBaseGatewayProbes(options) { + await assertHttpOk(options.port, "/healthz"); + await assertHttpOk(options.port, "/readyz"); + await rpcCall("health", {}, options); +} + +async function runManifestProbes(plan, options) { + for (const channel of plan.channels) { + const status = await rpcCall("channels.status", { probe: false, timeoutMs: 2000 }, options); + assertChannelVisible(status, channel); + } + if (plan.runtimeSlashAliases.length > 0) { + const commands = await rpcCall("commands.list", { scope: "both", includeArgs: true }, options); + for (const alias of plan.runtimeSlashAliases) { + assertCommandVisible(commands, alias); + } + } + if (plan.tools.length > 0) { + const catalog = await rpcCall("tools.catalog", { includePlugins: true }, options); + for (const tool of plan.tools) { + assertToolVisible(catalog, tool); + } + } + if (plan.speechProviders.length > 0) { + const providers = await rpcCall("tts.providers", {}, options); + const status = await rpcCall("tts.status", {}, options); + for (const provider of plan.speechProviders) { + assertSpeechProviderVisible(providers, provider, "tts.providers"); + assertSpeechProviderVisible(status, provider, "tts.status"); + } + } +} + +function assertChannelVisible(payload, channel) { + const channelMeta = payload.channelMeta; + const hasMeta = Array.isArray(channelMeta) + ? channelMeta.some((entry) => entry?.id === channel) + : Boolean(channelMeta?.[channel]); + if (hasMeta || payload.channels?.[channel] || payload.channelAccounts?.[channel]) { + return; + } + throw new Error( + `channels.status did not include ${channel}: ${JSON.stringify(payload).slice(0, 2000)}`, + ); +} + +function assertCommandVisible(payload, alias) { + const expected = alias.replace(/^\//u, "").toLowerCase(); + const commands = Array.isArray(payload.commands) ? payload.commands : []; + const found = commands.some((command) => { + const names = [ + command?.name, + command?.nativeName, + ...(Array.isArray(command?.textAliases) ? command.textAliases : []), + ] + .filter(isNonEmptyString) + .map((value) => value.replace(/^\//u, "").toLowerCase()); + return names.includes(expected); + }); + if (!found) { + throw new Error( + `commands.list did not include /${expected}: ${JSON.stringify(payload).slice(0, 2000)}`, + ); + } +} + +function assertToolVisible(payload, tool) { + const groups = Array.isArray(payload.groups) ? payload.groups : []; + const found = groups.some((group) => + (Array.isArray(group?.tools) ? group.tools : []).some((entry) => entry?.id === tool), + ); + if (!found) { + throw new Error( + `tools.catalog did not include ${tool}: ${JSON.stringify(payload).slice(0, 2000)}`, + ); + } +} + +function assertSpeechProviderVisible(payload, provider, label) { + const expected = provider.toLowerCase(); + const candidates = [ + ...(Array.isArray(payload.providers) ? payload.providers : []), + ...(Array.isArray(payload.providerStates) ? payload.providerStates : []), + ]; + const found = candidates.some((entry) => String(entry?.id ?? "").toLowerCase() === expected); + if (!found) { + throw new Error( + `${label} did not include ${provider}: ${JSON.stringify(payload).slice(0, 2000)}`, + ); + } +} + +async function runWatchdog(options) { + const readyIndex = findReadyLogIndex(options.logPath); + await delay(WATCHDOG_MS); + if (options.child.exitCode !== null) { + throw new Error( + `gateway exited after ready for ${options.pluginId}\n${tailFile(options.logPath)}`, + ); + } + await rpcCall("health", {}, options); + assertNoPostReadyRuntimeDepsWork(options.logPath, readyIndex); + assertNoRuntimeDepsLocks(); + await assertNoPackageManagerChildren(options.child.pid); +} + +function findReadyLogIndex(logPath) { + const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : ""; + const candidates = ["[gateway] ready", "listening on ws://", "[gateway] http server listening"]; + const indexes = candidates.map((needle) => log.indexOf(needle)).filter((index) => index >= 0); + return indexes.length > 0 ? Math.min(...indexes) : 0; +} + +function assertNoPostReadyRuntimeDepsWork(logPath, readyIndex) { + const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : ""; + const postReady = log.slice(Math.max(0, readyIndex)); + const forbidden = [ + /\[plugins\].*installed bundled runtime deps/iu, + /\[plugins\].*installing bundled runtime deps/iu, + /\[plugins\].*staging bundled runtime deps/iu, + /\b(?:npm|pnpm|yarn|corepack) install\b/iu, + ]; + const match = forbidden.find((pattern) => pattern.test(postReady)); + if (match) { + throw new Error(`post-ready runtime dependency work matched ${match}: ${tailText(postReady)}`); + } +} + +function assertNoRuntimeDepsLocks() { + const roots = [ + path.join( + process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME || os.homedir(), ".openclaw"), + "plugin-runtime-deps", + ), + path.join(process.cwd(), "dist", "extensions"), + ]; + for (const root of roots) { + if (!fs.existsSync(root)) { + continue; + } + const locks = findDirs(root, ".openclaw-runtime-deps.lock", 8); + if (locks.length > 0) { + throw new Error(`runtime dependency lock still exists: ${locks.join(", ")}`); + } + } +} + +function findDirs(root, basename, maxDepth) { + const results = []; + const visit = (dir, depth) => { + if (depth > maxDepth) { + return; + } + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const full = path.join(dir, entry.name); + if (entry.name === basename) { + results.push(full); + continue; + } + visit(full, depth + 1); + } + }; + visit(root, 0); + return results; +} + +async function assertNoPackageManagerChildren(pid) { + if (!pid || process.platform === "win32") { + return; + } + try { + const { stdout } = await runCommand("pgrep", [ + "-P", + String(pid), + "-af", + "npm|pnpm|yarn|corepack", + ]); + if (stdout.trim()) { + throw new Error( + `package manager child process still running under gateway ${pid}:\n${stdout}`, + ); + } + } catch (error) { + if (error instanceof Error && error.message.includes("failed with 1")) { + return; + } + throw error; + } +} + +async function smokeTtsGlobalDisable(pluginId, pluginDir, provider, pluginIndex) { + const entrypoint = process.env.OPENCLAW_ENTRY; + if (!entrypoint) { + throw new Error("missing OPENCLAW_ENTRY"); + } + const manifest = loadManifest(pluginDir); + const plan = buildPluginPlan(manifest); + const selectedProvider = provider || plan.speechProviders[0]; + if (!selectedProvider) { + console.log(`Global-disable TTS smoke skipped for ${pluginId}: no speech provider contract`); + return; + } + const port = + readPositiveInt(process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_PORT_BASE, 19000) + + pluginIndex * 3 + + 1; + const env = createIsolatedStateEnv(`tts-disabled-${pluginId}`); + writeConfig( + ensureGatewayConfig( + { + plugins: { + enabled: false, + }, + messages: { + tts: { + provider: selectedProvider, + }, + }, + }, + port, + ), + env, + ); + const logPath = `/tmp/openclaw-plugin-runtime-${pluginIndex}-${pluginId}-tts-disabled.log`; + const child = startGateway({ entrypoint, port, logPath, env }); + try { + await waitForReady({ child, port, logPath }); + await assertBaseGatewayProbes({ entrypoint, port, env }); + const providers = await rpcCall("tts.providers", {}, { entrypoint, port, env }); + assertSpeechProviderVisible(providers, selectedProvider, "tts.providers global-disable"); + await runWatchdog({ + child, + logPath, + port, + entrypoint, + env, + pluginId: `${pluginId}:tts-disabled`, + }); + console.log(`Global-disable TTS smoke passed for ${pluginId}/${selectedProvider}`); + } catch (error) { + console.error(tailFile(logPath)); + throw error; + } finally { + await stopGateway(child); + } +} + +async function smokeOpenAiTts(pluginIndex) { + const entrypoint = process.env.OPENCLAW_ENTRY; + if (!entrypoint) { + throw new Error("missing OPENCLAW_ENTRY"); + } + if (!process.env.OPENAI_API_KEY) { + console.log("OpenAI key-backed TTS smoke skipped: OPENAI_API_KEY is not set"); + return; + } + const port = + readPositiveInt(process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_PORT_BASE, 19000) + + pluginIndex * 3 + + 2; + const env = createIsolatedStateEnv("tts-openai-live"); + writeConfig( + ensureGatewayConfig( + { + plugins: { + enabled: true, + allow: ["openai"], + entries: { + openai: { enabled: true }, + }, + }, + messages: { + tts: { + provider: "openai", + providers: { + openai: { + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }, + }, + }, + }, + port, + ), + env, + ); + const logPath = `/tmp/openclaw-plugin-runtime-${pluginIndex}-openai-tts-live.log`; + const child = startGateway({ entrypoint, port, logPath, env }); + try { + await waitForReady({ child, port, logPath }); + await assertBaseGatewayProbes({ entrypoint, port, env }); + const result = await rpcCall( + "tts.convert", + { text: "ok", provider: "openai" }, + { entrypoint, port, env }, + ); + if (!isNonEmptyString(result.audioPath) || !fs.existsSync(result.audioPath)) { + throw new Error(`tts.convert did not produce an audio file: ${JSON.stringify(result)}`); + } + await runWatchdog({ child, logPath, port, entrypoint, env, pluginId: "openai:tts-live" }); + console.log("OpenAI key-backed TTS smoke passed"); + } catch (error) { + console.error(tailFile(logPath)); + throw error; + } finally { + await stopGateway(child); + } +} + +function createIsolatedStateEnv(label) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `openclaw-${label}-`)); + const home = path.join(root, "home"); + const stateDir = path.join(home, ".openclaw"); + const configPath = path.join(stateDir, "openclaw.json"); + fs.mkdirSync(stateDir, { recursive: true }); + return { + ...process.env, + HOME: home, + OPENCLAW_HOME: stateDir, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: configPath, + }; +} + +function tailFile(file) { + if (!fs.existsSync(file)) { + return ""; + } + return tailText(fs.readFileSync(file, "utf8")); +} + +function tailText(text) { + return text.split(/\r?\n/u).slice(-120).join("\n"); +} + +const [command, pluginId, pluginDir, requiresConfigRaw, pluginIndexRaw, provider] = + process.argv.slice(2); +const pluginIndex = Number.parseInt(pluginIndexRaw || "0", 10); + +if (command === "plugin") { + await smokePlugin(pluginId, pluginDir, requiresConfigRaw === "1", pluginIndex); +} else if (command === "tts-global-disable") { + await smokeTtsGlobalDisable(pluginId, pluginDir, provider, pluginIndex); +} else if (command === "tts-openai-live") { + await smokeOpenAiTts(pluginIndex); +} else { + throw new Error(`Unknown runtime smoke command: ${command || "(missing)"}`); +} diff --git a/scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh b/scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh index 4399ab296bd..c778557077a 100644 --- a/scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh +++ b/scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh @@ -17,6 +17,7 @@ export OPENCLAW_ENTRY openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" probe="scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs" +runtime_smoke="scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs" node "$probe" select > /tmp/bundled-plugin-sweep-ids mapfile -t plugin_entries < /tmp/bundled-plugin-sweep-ids @@ -40,6 +41,14 @@ for plugin_entry in "${plugin_entries[@]}"; do } install_finished_at="$(date +%s)" node "$probe" assert-installed "$plugin_id" "$plugin_dir" "$requires_config" + if [[ "${OPENCLAW_BUNDLED_PLUGIN_RUNTIME_SMOKE:-1}" != "0" ]]; then + echo "Running bundled plugin runtime smoke: $plugin_id ($plugin_dir)" + node "$runtime_smoke" plugin "$plugin_id" "$plugin_dir" "$requires_config" "$plugin_index" + node "$runtime_smoke" tts-global-disable "$plugin_id" "$plugin_dir" "$requires_config" "$plugin_index" + if [[ "$plugin_id" == "${OPENCLAW_BUNDLED_PLUGIN_TTS_LIVE_PROVIDER:-openai}" ]]; then + node "$runtime_smoke" tts-openai-live "$plugin_id" "$plugin_dir" "$requires_config" "$plugin_index" + fi + fi echo "Uninstalling bundled plugin: $plugin_id ($plugin_dir)" node "$OPENCLAW_ENTRY" plugins uninstall "$plugin_id" --force >"$uninstall_log" 2>&1 || {