#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" IMAGE_NAME="openclaw-plugins-e2e" echo "Building Docker image..." docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" echo "Running plugins Docker E2E..." docker run --rm -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 -e OPENAI_API_KEY -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail if [ -f dist/index.mjs ]; then OPENCLAW_ENTRY="dist/index.mjs" elif [ -f dist/index.js ]; then OPENCLAW_ENTRY="dist/index.js" else echo "Missing dist/index.(m)js (build output):" ls -la dist || true exit 1 fi export OPENCLAW_ENTRY home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX") export HOME="$home_dir" gateway_pid="" 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 if grep -q "listening on ws://" "$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:-15000}" 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) : 15000; const gatewayArgs = [ entry, "gateway", "call", "--url", "ws://127.0.0.1:18789", "--token", "plugin-e2e-token", "--timeout", "10000", "--json", ]; const callGateway = (method, params) => { try { return { ok: true, value: JSON.parse( execFileSync("node", [...gatewayArgs, method, "--params", JSON.stringify(params)], { encoding: "utf8", }), ), }; } 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 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)); async function main() { const runId = `plugin-e2e-${randomUUID()}`; const sendResult = callGateway("chat.send", { sessionKey, message, idempotencyKey: runId, }); if (!sendResult.ok) { throw sendResult.error; } const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const historyResult = callGateway("chat.history", { sessionKey }); if (!historyResult.ok) { await sleep(150); continue; } 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); } throw new Error(`timed out waiting for assistant reply for ${sessionKey}`); } main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }); NODE } trap 'stop_gateway' EXIT write_fixture_plugin() { local dir="$1" local id="$2" local version="$3" local method="$4" local name="$5" mkdir -p "$dir" cat > "$dir/package.json" < "$dir/index.js" < ({ ok: true })); }, }; JS cat > "$dir/openclaw.plugin.json" <<'JSON' { "id": "placeholder", "configSchema": { "type": "object", "properties": {} } } JSON node - <<'NODE' "$dir/openclaw.plugin.json" "$id" const fs = require("node:fs"); const file = process.argv[2]; const id = process.argv[3]; const parsed = JSON.parse(fs.readFileSync(file, "utf8")); parsed.id = id; fs.writeFileSync(file, `${JSON.stringify(parsed, null, 2)}\n`); NODE } mkdir -p "$HOME/.openclaw/extensions/demo-plugin" cat > "$HOME/.openclaw/extensions/demo-plugin/index.js" <<'JS' module.exports = { id: "demo-plugin", name: "Demo Plugin", description: "Docker E2E demo plugin", register(api) { api.registerTool(() => null, { name: "demo_tool" }); api.registerGatewayMethod("demo.ping", async () => ({ ok: true })); api.registerCli(() => {}, { commands: ["demo"] }); api.registerService({ id: "demo-service", start: () => {} }); }, }; JS cat > "$HOME/.openclaw/extensions/demo-plugin/openclaw.plugin.json" <<'JSON' { "id": "demo-plugin", "configSchema": { "type": "object", "properties": {} } } JSON node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins.json node - <<'NODE' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync("/tmp/plugins.json", "utf8")); const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin"); if (!plugin) throw new Error("plugin not found"); if (plugin.status !== "loaded") { throw new Error(`unexpected status: ${plugin.status}`); } const assertIncludes = (list, value, label) => { if (!Array.isArray(list) || !list.includes(value)) { throw new Error(`${label} missing: ${value}`); } }; assertIncludes(plugin.toolNames, "demo_tool", "tool"); assertIncludes(plugin.gatewayMethods, "demo.ping", "gateway method"); assertIncludes(plugin.cliCommands, "demo", "cli command"); assertIncludes(plugin.services, "demo-service", "service"); const diagErrors = (data.diagnostics || []).filter((diag) => diag.level === "error"); if (diagErrors.length > 0) { throw new Error(`diagnostics errors: ${diagErrors.map((diag) => diag.message).join("; ")}`); } console.log("ok"); NODE echo "Testing tgz install flow..." pack_dir="$(mktemp -d "/tmp/openclaw-plugin-pack.XXXXXX")" mkdir -p "$pack_dir/package" cat > "$pack_dir/package/package.json" <<'JSON' { "name": "@openclaw/demo-plugin-tgz", "version": "0.0.1", "openclaw": { "extensions": ["./index.js"] } } JSON cat > "$pack_dir/package/index.js" <<'JS' module.exports = { id: "demo-plugin-tgz", name: "Demo Plugin TGZ", register(api) { api.registerGatewayMethod("demo.tgz", async () => ({ ok: true })); }, }; JS cat > "$pack_dir/package/openclaw.plugin.json" <<'JSON' { "id": "demo-plugin-tgz", "configSchema": { "type": "object", "properties": {} } } JSON tar -czf /tmp/demo-plugin-tgz.tgz -C "$pack_dir" package node "$OPENCLAW_ENTRY" plugins install /tmp/demo-plugin-tgz.tgz node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins2.json node - <<'NODE' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync("/tmp/plugins2.json", "utf8")); const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-tgz"); if (!plugin) throw new Error("tgz plugin not found"); if (plugin.status !== "loaded") { throw new Error(`unexpected status: ${plugin.status}`); } if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("demo.tgz")) { throw new Error("expected gateway method demo.tgz"); } console.log("ok"); NODE echo "Testing install from local folder (plugins.load.paths)..." dir_plugin="$(mktemp -d "/tmp/openclaw-plugin-dir.XXXXXX")" cat > "$dir_plugin/package.json" <<'JSON' { "name": "@openclaw/demo-plugin-dir", "version": "0.0.1", "openclaw": { "extensions": ["./index.js"] } } JSON cat > "$dir_plugin/index.js" <<'JS' module.exports = { id: "demo-plugin-dir", name: "Demo Plugin DIR", register(api) { api.registerGatewayMethod("demo.dir", async () => ({ ok: true })); }, }; JS cat > "$dir_plugin/openclaw.plugin.json" <<'JSON' { "id": "demo-plugin-dir", "configSchema": { "type": "object", "properties": {} } } JSON node "$OPENCLAW_ENTRY" plugins install "$dir_plugin" node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins3.json node - <<'NODE' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync("/tmp/plugins3.json", "utf8")); const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-dir"); if (!plugin) throw new Error("dir plugin not found"); if (plugin.status !== "loaded") { throw new Error(`unexpected status: ${plugin.status}`); } if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("demo.dir")) { throw new Error("expected gateway method demo.dir"); } console.log("ok"); NODE echo "Testing install from npm spec (file:)..." file_pack_dir="$(mktemp -d "/tmp/openclaw-plugin-filepack.XXXXXX")" mkdir -p "$file_pack_dir/package" cat > "$file_pack_dir/package/package.json" <<'JSON' { "name": "@openclaw/demo-plugin-file", "version": "0.0.1", "openclaw": { "extensions": ["./index.js"] } } JSON cat > "$file_pack_dir/package/index.js" <<'JS' module.exports = { id: "demo-plugin-file", name: "Demo Plugin FILE", register(api) { api.registerGatewayMethod("demo.file", async () => ({ ok: true })); }, }; JS cat > "$file_pack_dir/package/openclaw.plugin.json" <<'JSON' { "id": "demo-plugin-file", "configSchema": { "type": "object", "properties": {} } } JSON node "$OPENCLAW_ENTRY" plugins install "file:$file_pack_dir/package" node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins4.json node - <<'NODE' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync("/tmp/plugins4.json", "utf8")); const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-file"); if (!plugin) throw new Error("file plugin not found"); if (plugin.status !== "loaded") { throw new Error(`unexpected status: ${plugin.status}`); } if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("demo.file")) { throw new Error("expected gateway method demo.file"); } console.log("ok"); NODE echo "Testing /plugin alias with Claude bundle restart semantics..." bundle_root="$HOME/.openclaw/extensions/claude-bundle-e2e" mkdir -p "$bundle_root/.claude-plugin" "$bundle_root/commands" cat > "$bundle_root/.claude-plugin/plugin.json" <<'JSON' { "name": "claude-bundle-e2e" } JSON cat > "$bundle_root/commands/office-hours.md" <<'MD' --- description: Help with architecture and rollout planning --- Act as an engineering advisor. Focus on: $ARGUMENTS MD 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 || {}), model: { primary: "openai/gpt-5.4" }, }, }; } 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"); NODE gateway_log="/tmp/openclaw-plugin-command-e2e.log" start_gateway "$gateway_log" wait_for_gateway_health echo "Testing /plugin install with auto-restart..." slash_install_dir="$(mktemp -d "/tmp/openclaw-plugin-slash-install.XXXXXX")" cat > "$slash_install_dir/package.json" <<'JSON' { "name": "@openclaw/slash-install-plugin", "version": "0.0.1", "openclaw": { "extensions": ["./index.js"] } } JSON cat > "$slash_install_dir/index.js" <<'JS' module.exports = { id: "slash-install-plugin", name: "Slash Install Plugin", register(api) { api.registerGatewayMethod("demo.slash.install", async () => ({ ok: true })); }, }; JS cat > "$slash_install_dir/openclaw.plugin.json" <<'JSON' { "id": "slash-install-plugin", "configSchema": { "type": "object", "properties": {} } } JSON run_gateway_chat_json \ "plugin-e2e-install" \ "/plugin install $slash_install_dir" \ /tmp/plugin-command-install.json \ 30000 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}`); } if (!text.includes("Restart the gateway to load plugins.")) { throw new Error(`expected restart hint, got:\n${text}`); } 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 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..." run_gateway_chat_json \ "plugin-e2e-live" \ "/office_hours Reply with exactly BUNDLE_OK and nothing else." \ /tmp/plugin-command-live.json \ 60000 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" write_fixture_plugin \ "$marketplace_root/plugins/marketplace-shortcut" \ "marketplace-shortcut" \ "0.0.1" \ "demo.marketplace.shortcut.v1" \ "Marketplace Shortcut" write_fixture_plugin \ "$marketplace_root/plugins/marketplace-direct" \ "marketplace-direct" \ "0.0.1" \ "demo.marketplace.direct.v1" \ "Marketplace Direct" cat > "$marketplace_root/.claude-plugin/marketplace.json" <<'JSON' { "name": "Fixture Marketplace", "version": "1.0.0", "plugins": [ { "name": "marketplace-shortcut", "version": "0.0.1", "description": "Shortcut install fixture", "source": "./plugins/marketplace-shortcut" }, { "name": "marketplace-direct", "version": "0.0.1", "description": "Explicit marketplace fixture", "source": { "type": "path", "path": "./plugins/marketplace-direct" } } ] } JSON cat > "$HOME/.claude/plugins/known_marketplaces.json" < /tmp/marketplace-list.json node - <<'NODE' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync("/tmp/marketplace-list.json", "utf8")); const names = (data.plugins || []).map((entry) => entry.name).sort(); if (data.name !== "Fixture Marketplace") { throw new Error(`unexpected marketplace name: ${data.name}`); } if (!names.includes("marketplace-shortcut") || !names.includes("marketplace-direct")) { throw new Error(`unexpected marketplace plugins: ${names.join(", ")}`); } console.log("ok"); NODE node "$OPENCLAW_ENTRY" plugins install marketplace-shortcut@claude-fixtures node "$OPENCLAW_ENTRY" plugins install marketplace-direct --marketplace claude-fixtures node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-marketplace.json node - <<'NODE' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace.json", "utf8")); const getPlugin = (id) => { const plugin = (data.plugins || []).find((entry) => entry.id === id); if (!plugin) throw new Error(`plugin not found: ${id}`); if (plugin.status !== "loaded") { throw new Error(`unexpected status for ${id}: ${plugin.status}`); } return plugin; }; const shortcut = getPlugin("marketplace-shortcut"); const direct = getPlugin("marketplace-direct"); if (shortcut.version !== "0.0.1") { throw new Error(`unexpected shortcut version: ${shortcut.version}`); } if (direct.version !== "0.0.1") { throw new Error(`unexpected direct version: ${direct.version}`); } if (!shortcut.gatewayMethods.includes("demo.marketplace.shortcut.v1")) { throw new Error("expected marketplace shortcut gateway method"); } if (!direct.gatewayMethods.includes("demo.marketplace.direct.v1")) { throw new Error("expected marketplace direct gateway method"); } console.log("ok"); NODE node - <<'NODE' const fs = require("node:fs"); const path = require("node:path"); const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); const config = JSON.parse(fs.readFileSync(configPath, "utf8")); for (const id of ["marketplace-shortcut", "marketplace-direct"]) { const record = config.plugins?.installs?.[id]; if (!record) throw new Error(`missing install record for ${id}`); if (record.source !== "marketplace") { throw new Error(`unexpected source for ${id}: ${record.source}`); } if (record.marketplaceSource !== "claude-fixtures") { throw new Error(`unexpected marketplace source for ${id}: ${record.marketplaceSource}`); } if (record.marketplacePlugin !== id) { throw new Error(`unexpected marketplace plugin for ${id}: ${record.marketplacePlugin}`); } } console.log("ok"); NODE write_fixture_plugin \ "$marketplace_root/plugins/marketplace-shortcut" \ "marketplace-shortcut" \ "0.0.2" \ "demo.marketplace.shortcut.v2" \ "Marketplace Shortcut" node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut --dry-run node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-marketplace-updated.json node - <<'NODE' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace-updated.json", "utf8")); const plugin = (data.plugins || []).find((entry) => entry.id === "marketplace-shortcut"); if (!plugin) throw new Error("updated marketplace plugin not found"); if (plugin.version !== "0.0.2") { throw new Error(`unexpected updated version: ${plugin.version}`); } if (!plugin.gatewayMethods.includes("demo.marketplace.shortcut.v2")) { throw new Error(`expected updated gateway method, got ${plugin.gatewayMethods.join(", ")}`); } console.log("ok"); NODE echo "Running bundle MCP CLI-agent e2e..." pnpm exec vitest run --config vitest.e2e.config.ts src/agents/cli-runner.bundle-mcp.e2e.test.ts EOF echo "OK"