diff --git a/scripts/e2e/agents-delete-shared-workspace-docker.sh b/scripts/e2e/agents-delete-shared-workspace-docker.sh index 521aad58190..cf64e95ea71 100644 --- a/scripts/e2e/agents-delete-shared-workspace-docker.sh +++ b/scripts/e2e/agents-delete-shared-workspace-docker.sh @@ -9,17 +9,11 @@ SKIP_BUILD="${OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_E2E_SKIP_BUILD:-0}" DOCKER_COMMAND_TIMEOUT="${OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_DOCKER_COMMAND_TIMEOUT:-300s}" OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 agents-delete-shared-workspace empty)" -docker_cmd() { - if command -v timeout >/dev/null 2>&1; then - timeout "$DOCKER_COMMAND_TIMEOUT" "$@" - return - fi - "$@" -} - docker_e2e_build_or_reuse "$IMAGE_NAME" agents-delete-shared-workspace "$ROOT_DIR/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD" +docker_e2e_harness_mount_args -run_logged agents-delete-shared-workspace docker_cmd docker run --rm \ +run_logged agents-delete-shared-workspace docker_e2e_docker_cmd run --rm \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ --entrypoint bash \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_SKIP_PROVIDERS=1 \ @@ -33,6 +27,7 @@ run_logged agents-delete-shared-workspace docker_cmd docker run --rm \ "$IMAGE_NAME" \ -lc ' set -euo pipefail +source scripts/lib/openclaw-e2e-instance.sh run_openclaw() { if command -v openclaw >/dev/null 2>&1; then @@ -47,7 +42,7 @@ run_openclaw() { exit 1 } -eval "$(printf "%s" "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" | base64 -d)" +openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" export SHARED_WORKSPACE="$HOME/workspace-shared" output_file="$HOME/delete.json" trap '\''rm -rf "$HOME"'\'' EXIT diff --git a/scripts/e2e/browser-cdp-snapshot-docker.sh b/scripts/e2e/browser-cdp-snapshot-docker.sh index 5f5e357beef..6b1d4beda9f 100755 --- a/scripts/e2e/browser-cdp-snapshot-docker.sh +++ b/scripts/e2e/browser-cdp-snapshot-docker.sh @@ -14,18 +14,14 @@ TOKEN="browser-cdp-e2e-token" CONTAINER_NAME="openclaw-browser-cdp-e2e-$$" DOCKER_COMMAND_TIMEOUT="${OPENCLAW_BROWSER_CDP_SNAPSHOT_DOCKER_COMMAND_TIMEOUT:-900s}" -docker_cmd() { - timeout "$DOCKER_COMMAND_TIMEOUT" "$@" -} - cleanup() { - docker_cmd docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true } trap cleanup EXIT if [ "${OPENCLAW_SKIP_DOCKER_BUILD:-0}" = "1" ] || [ "$SKIP_BUILD" = "1" ]; then echo "Reusing Docker image: $IMAGE_NAME" - docker_cmd docker image inspect "$IMAGE_NAME" >/dev/null + docker_e2e_docker_cmd image inspect "$IMAGE_NAME" >/dev/null else docker_e2e_build_or_reuse "$BASE_IMAGE" browser-cdp-base "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "0" build_dir="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-browser-cdp-build.XXXXXX")" @@ -45,7 +41,7 @@ OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 browser-cdp-sn echo "Starting browser CDP snapshot container..." docker_e2e_harness_mount_args -docker_cmd docker run -d \ +docker_e2e_docker_cmd run -d \ "${DOCKER_E2E_HARNESS_ARGS[@]}" \ --name "$CONTAINER_NAME" \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ @@ -95,26 +91,7 @@ chromium --headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage \\ --remote-debugging-port=$CDP_PORT \\ --user-data-dir=/tmp/openclaw-browser-cdp/chrome \\ about:blank >/tmp/browser-cdp-chromium.log 2>&1 & -node --input-type=module - <<'NODE' >/tmp/browser-cdp-fixture.log 2>&1 & -import http from 'node:http'; -const html = \` - - -
- - Docs -
Clickable Card
- -
- -\`; -http - .createServer((_req, res) => { - res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); - res.end(html); - }) - .listen($FIXTURE_PORT, '127.0.0.1'); -NODE +FIXTURE_PORT=$FIXTURE_PORT node scripts/e2e/lib/browser-cdp-snapshot/fixture-server.mjs >/tmp/browser-cdp-fixture.log 2>&1 & openclaw_e2e_exec_gateway \"\$entry\" $PORT loopback /tmp/browser-cdp-gateway.log" >/dev/null echo "Waiting for Chromium and Gateway..." @@ -129,7 +106,7 @@ if ! docker_e2e_wait_container_bash "$CONTAINER_NAME" 180 0.5 " fi echo "Running browser CDP snapshot smoke..." -docker_cmd docker exec "$CONTAINER_NAME" bash -lc " +docker_e2e_docker_cmd exec "$CONTAINER_NAME" bash -lc " set -euo pipefail source /tmp/openclaw-test-state-env source scripts/lib/openclaw-e2e-instance.sh @@ -139,25 +116,7 @@ node \"\$entry\" browser \"\${base_args[@]}\" --browser-profile docker-cdp docto grep -q 'OK live-snapshot' /tmp/browser-cdp-doctor.txt node \"\$entry\" browser \"\${base_args[@]}\" --browser-profile docker-cdp open http://127.0.0.1:$FIXTURE_PORT/ >/tmp/browser-cdp-open.txt node \"\$entry\" browser \"\${base_args[@]}\" --browser-profile docker-cdp snapshot --interactive --urls --out /tmp/browser-cdp-snapshot.txt >/tmp/browser-cdp-snapshot.out -node --input-type=module - <<'NODE' -import fs from 'node:fs'; -const snapshot = fs.readFileSync('/tmp/browser-cdp-snapshot.txt', 'utf8'); -for (const needle of [ - 'button \"Save\"', - 'link \"Docs\"', - 'https://docs.openclaw.ai/browser-cdp-live', - 'generic \"Clickable Card\"', - 'cursor:pointer', - 'Iframe \"Child\"', - 'button \"Inside\"', -]) { - if (!snapshot.includes(needle)) { - console.error(snapshot); - throw new Error('missing snapshot needle: ' + needle); - } -} -console.log('ok'); -NODE +node scripts/e2e/lib/browser-cdp-snapshot/assert-snapshot.mjs /tmp/browser-cdp-snapshot.txt " echo "Browser CDP snapshot Docker E2E passed." diff --git a/scripts/e2e/crestodian-first-run-docker.sh b/scripts/e2e/crestodian-first-run-docker.sh index f2a112cba17..cf8d904de8e 100644 --- a/scripts/e2e/crestodian-first-run-docker.sh +++ b/scripts/e2e/crestodian-first-run-docker.sh @@ -26,7 +26,8 @@ docker_e2e_run_with_harness \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail - eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\" + source scripts/lib/openclaw-e2e-instance.sh + openclaw_e2e_eval_test_state_from_b64 \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" tsx scripts/e2e/crestodian-first-run-docker-client.ts " >"$RUN_LOG" 2>&1 status=${PIPESTATUS[0]} diff --git a/scripts/e2e/crestodian-planner-docker.sh b/scripts/e2e/crestodian-planner-docker.sh index c479c0718cc..c4093a0b2a4 100755 --- a/scripts/e2e/crestodian-planner-docker.sh +++ b/scripts/e2e/crestodian-planner-docker.sh @@ -26,7 +26,8 @@ docker_e2e_run_with_harness \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail - eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\" + source scripts/lib/openclaw-e2e-instance.sh + openclaw_e2e_eval_test_state_from_b64 \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" tsx scripts/e2e/crestodian-planner-docker-client.ts " >"$RUN_LOG" 2>&1 status=${PIPESTATUS[0]} diff --git a/scripts/e2e/crestodian-rescue-docker.sh b/scripts/e2e/crestodian-rescue-docker.sh index 7ca6312a837..22ca3d6fcfb 100755 --- a/scripts/e2e/crestodian-rescue-docker.sh +++ b/scripts/e2e/crestodian-rescue-docker.sh @@ -26,7 +26,8 @@ docker_e2e_run_with_harness \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail - eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\" + source scripts/lib/openclaw-e2e-instance.sh + openclaw_e2e_eval_test_state_from_b64 \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" tsx scripts/e2e/crestodian-rescue-docker-client.ts " >"$RUN_LOG" 2>&1 status=${PIPESTATUS[0]} diff --git a/scripts/e2e/gateway-network-docker.sh b/scripts/e2e/gateway-network-docker.sh index 6b9af960c49..d99cefc2201 100644 --- a/scripts/e2e/gateway-network-docker.sh +++ b/scripts/e2e/gateway-network-docker.sh @@ -13,24 +13,20 @@ GW_NAME="openclaw-gateway-e2e-$$" DOCKER_COMMAND_TIMEOUT="${OPENCLAW_GATEWAY_NETWORK_DOCKER_COMMAND_TIMEOUT:-600s}" CLIENT_TIMEOUT="${OPENCLAW_GATEWAY_NETWORK_CLIENT_TIMEOUT:-90s}" -docker_cmd() { - timeout "$DOCKER_COMMAND_TIMEOUT" "$@" -} - cleanup() { - docker_cmd docker rm -f "$GW_NAME" >/dev/null 2>&1 || true - docker_cmd docker network rm "$NET_NAME" >/dev/null 2>&1 || true + docker_e2e_docker_cmd rm -f "$GW_NAME" >/dev/null 2>&1 || true + docker_e2e_docker_cmd network rm "$NET_NAME" >/dev/null 2>&1 || true } trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" gateway-network "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD" echo "Creating Docker network..." -docker_cmd docker network create "$NET_NAME" >/dev/null +docker_e2e_docker_cmd network create "$NET_NAME" >/dev/null echo "Starting gateway container..." docker_e2e_harness_mount_args -docker_cmd docker run -d \ +docker_e2e_docker_cmd run -d \ "${DOCKER_E2E_HARNESS_ARGS[@]}" \ --name "$GW_NAME" \ --network "$NET_NAME" \ @@ -51,67 +47,11 @@ fi echo "Running client container (connect + health)..." run_logged gateway-network-client timeout "$CLIENT_TIMEOUT" docker run --rm \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ --network "$NET_NAME" \ -e "GW_URL=ws://$GW_NAME:$PORT" \ -e "GW_TOKEN=$TOKEN" \ "$IMAGE_NAME" \ - bash -lc "node --input-type=module - <<'NODE' -import { WebSocket } from \"ws\"; - -const PROTOCOL_VERSION = 3; - -const url = process.env.GW_URL; -const token = process.env.GW_TOKEN; -if (!url || !token) throw new Error(\"missing GW_URL/GW_TOKEN\"); - -const ws = new WebSocket(url); -await new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error(\"ws open timeout\")), 30000); - ws.once(\"open\", () => { - clearTimeout(t); - resolve(); - }); -}); - -function onceFrame(filter, timeoutMs = 30000) { - return new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error(\"timeout\")), timeoutMs); - const handler = (data) => { - const obj = JSON.parse(String(data)); - if (!filter(obj)) return; - clearTimeout(t); - ws.off(\"message\", handler); - resolve(obj); - }; - ws.on(\"message\", handler); - }); -} - -ws.send( - JSON.stringify({ - type: \"req\", - id: \"c1\", - method: \"connect\", - params: { - minProtocol: PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: { - id: \"test\", - displayName: \"docker-net-e2e\", - version: \"dev\", - platform: process.platform, - mode: \"test\", - }, - caps: [], - auth: { token }, - }, - }), - ); - const connectRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"c1\"); - if (!connectRes.ok) throw new Error(\"connect failed: \" + (connectRes.error?.message ?? \"unknown\")); - - ws.close(); - console.log(\"ok\"); -NODE" + node scripts/e2e/lib/gateway-network/client.mjs echo "OK" diff --git a/scripts/e2e/lib/browser-cdp-snapshot/assert-snapshot.mjs b/scripts/e2e/lib/browser-cdp-snapshot/assert-snapshot.mjs new file mode 100644 index 00000000000..1cf13405b58 --- /dev/null +++ b/scripts/e2e/lib/browser-cdp-snapshot/assert-snapshot.mjs @@ -0,0 +1,21 @@ +import fs from "node:fs"; + +const snapshotPath = process.argv[2] ?? "/tmp/browser-cdp-snapshot.txt"; +const snapshot = fs.readFileSync(snapshotPath, "utf8"); + +for (const needle of [ + 'button "Save"', + 'link "Docs"', + "https://docs.openclaw.ai/browser-cdp-live", + 'generic "Clickable Card"', + "cursor:pointer", + 'Iframe "Child"', + 'button "Inside"', +]) { + if (!snapshot.includes(needle)) { + console.error(snapshot); + throw new Error(`missing snapshot needle: ${needle}`); + } +} + +console.log("ok"); diff --git a/scripts/e2e/lib/browser-cdp-snapshot/fixture-server.mjs b/scripts/e2e/lib/browser-cdp-snapshot/fixture-server.mjs new file mode 100644 index 00000000000..f12c6fb3947 --- /dev/null +++ b/scripts/e2e/lib/browser-cdp-snapshot/fixture-server.mjs @@ -0,0 +1,25 @@ +import http from "node:http"; + +const port = Number(process.env.FIXTURE_PORT); +if (!Number.isFinite(port) || port <= 0) { + throw new Error(`invalid FIXTURE_PORT: ${process.env.FIXTURE_PORT ?? "unset"}`); +} + +const html = ` + + +
+ + Docs +
Clickable Card
+ +
+ +`; + +http + .createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end(html); + }) + .listen(port, "127.0.0.1"); diff --git a/scripts/e2e/lib/gateway-network/client.mjs b/scripts/e2e/lib/gateway-network/client.mjs new file mode 100644 index 00000000000..80befe7f4f9 --- /dev/null +++ b/scripts/e2e/lib/gateway-network/client.mjs @@ -0,0 +1,63 @@ +import { WebSocket } from "ws"; + +const PROTOCOL_VERSION = 3; + +const url = process.env.GW_URL; +const token = process.env.GW_TOKEN; +if (!url || !token) { + throw new Error("missing GW_URL/GW_TOKEN"); +} + +const ws = new WebSocket(url); +await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("ws open timeout")), 30_000); + ws.once("open", () => { + clearTimeout(timer); + resolve(); + }); +}); + +function onceFrame(filter, timeoutMs = 30_000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs); + const handler = (data) => { + const obj = JSON.parse(String(data)); + if (!filter(obj)) { + return; + } + clearTimeout(timer); + ws.off("message", handler); + resolve(obj); + }; + ws.on("message", handler); + }); +} + +ws.send( + JSON.stringify({ + type: "req", + id: "c1", + method: "connect", + params: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "test", + displayName: "docker-net-e2e", + version: "dev", + platform: process.platform, + mode: "test", + }, + caps: [], + auth: { token }, + }, + }), +); + +const connectRes = await onceFrame((frame) => frame?.type === "res" && frame?.id === "c1"); +if (!connectRes.ok) { + throw new Error(`connect failed: ${connectRes.error?.message ?? "unknown"}`); +} + +ws.close(); +console.log("ok"); diff --git a/scripts/e2e/lib/kitchen-sink-plugin/clawhub-fixture-server.cjs b/scripts/e2e/lib/kitchen-sink-plugin/clawhub-fixture-server.cjs new file mode 100644 index 00000000000..19571c996c7 --- /dev/null +++ b/scripts/e2e/lib/kitchen-sink-plugin/clawhub-fixture-server.cjs @@ -0,0 +1,183 @@ +const crypto = require("node:crypto"); +const http = require("node:http"); +const path = require("node:path"); +const { createRequire } = require("node:module"); + +const portFile = process.argv[2]; +const requireFromApp = createRequire(path.join(process.cwd(), "package.json")); +const JSZip = requireFromApp("jszip"); +const packageName = "openclaw-kitchen-sink"; +const pluginId = "openclaw-kitchen-sink-fixture"; +const version = "0.1.3"; + +async function main() { + const zip = new JSZip(); + zip.file( + "package/package.json", + `${JSON.stringify( + { + name: packageName, + version, + openclaw: { extensions: ["./index.js"] }, + }, + null, + 2, + )}\n`, + { date: new Date(0) }, + ); + zip.file( + "package/index.js", + `module.exports = { + id: "${pluginId}", + name: "OpenClaw Kitchen Sink", + register(api) { + api.registerProvider({ + id: "kitchen-sink-provider", + label: "Kitchen Sink Provider", + docsPath: "/providers/kitchen-sink", + auth: [], + }); + api.registerChannel({ + plugin: { + id: "kitchen-sink-channel", + meta: { + id: "kitchen-sink-channel", + label: "Kitchen Sink Channel", + selectionLabel: "Kitchen Sink", + docsPath: "/channels/kitchen-sink", + blurb: "Kitchen sink ClawHub fixture channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +}; +`, + { date: new Date(0) }, + ); + zip.file( + "package/openclaw.plugin.json", + `${JSON.stringify( + { + id: pluginId, + name: "OpenClaw Kitchen Sink", + channels: ["kitchen-sink-channel"], + providers: ["kitchen-sink-provider"], + configSchema: { + type: "object", + properties: {}, + }, + }, + null, + 2, + )}\n`, + { date: new Date(0) }, + ); + + const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }); + const sha256hash = crypto.createHash("sha256").update(archive).digest("hex"); + const packageDetail = { + package: { + name: packageName, + displayName: "OpenClaw Kitchen Sink", + family: "code-plugin", + runtimeId: pluginId, + channel: "official", + isOfficial: true, + summary: "Kitchen sink plugin fixture for prerelease CI.", + ownerHandle: "openclaw", + createdAt: 0, + updatedAt: 0, + latestVersion: version, + tags: { latest: version }, + capabilityTags: ["test-fixture"], + executesCode: true, + compatibility: { + pluginApiRange: ">=2026.4.11", + minGatewayVersion: "2026.4.11", + }, + capabilities: { + executesCode: true, + runtimeId: pluginId, + capabilityTags: ["test-fixture"], + channels: ["kitchen-sink-channel"], + providers: ["kitchen-sink-provider"], + }, + verification: { + tier: "source-linked", + sourceRepo: "https://github.com/openclaw/kitchen-sink", + hasProvenance: false, + scanStatus: "passed", + }, + }, + }; + const versionDetail = { + package: { + name: packageName, + displayName: "OpenClaw Kitchen Sink", + family: "code-plugin", + }, + version: { + version, + createdAt: 0, + changelog: "Fixture package for kitchen-sink plugin prerelease CI.", + distTags: ["latest"], + sha256hash, + compatibility: packageDetail.package.compatibility, + capabilities: packageDetail.package.capabilities, + verification: packageDetail.package.verification, + }, + }; + + const json = (response, value, status = 200) => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(`${JSON.stringify(value)}\n`); + }; + + const server = http.createServer((request, response) => { + const url = new URL(request.url, "http://127.0.0.1"); + if (request.method !== "GET") { + response.writeHead(405); + response.end("method not allowed"); + return; + } + if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}`) { + json(response, packageDetail); + return; + } + if ( + url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${version}` + ) { + json(response, versionDetail); + return; + } + if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/beta`) { + json(response, { error: "version not found" }, 404); + return; + } + if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/download`) { + response.writeHead(200, { + "content-type": "application/zip", + "content-length": String(archive.length), + }); + response.end(archive); + return; + } + response.writeHead(404, { "content-type": "text/plain" }); + response.end(`not found: ${url.pathname}`); + }); + + server.listen(0, "127.0.0.1", () => { + require("node:fs").writeFileSync(portFile, String(server.address().port)); + }); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh b/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh index 409a11c871c..daef182dc24 100644 --- a/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh +++ b/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh @@ -49,191 +49,7 @@ start_kitchen_sink_clawhub_fixture_server() { local server_port_file="$fixture_dir/clawhub-fixture-port" local server_pid_file="$fixture_dir/clawhub-fixture-pid" - node - "$server_port_file" <<'NODE' >"$server_log" 2>&1 & -const crypto = require("node:crypto"); -const http = require("node:http"); -const path = require("node:path"); -const { createRequire } = require("node:module"); - -const portFile = process.argv[2]; -const requireFromApp = createRequire(path.join(process.cwd(), "package.json")); -const JSZip = requireFromApp("jszip"); -const packageName = "openclaw-kitchen-sink"; -const pluginId = "openclaw-kitchen-sink-fixture"; -const version = "0.1.3"; - -async function main() { - const zip = new JSZip(); - zip.file( - "package/package.json", - `${JSON.stringify( - { - name: packageName, - version, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - )}\n`, - { date: new Date(0) }, - ); - zip.file( - "package/index.js", - `module.exports = { - id: "${pluginId}", - name: "OpenClaw Kitchen Sink", - register(api) { - api.registerProvider({ - id: "kitchen-sink-provider", - label: "Kitchen Sink Provider", - docsPath: "/providers/kitchen-sink", - auth: [], - }); - api.registerChannel({ - plugin: { - id: "kitchen-sink-channel", - meta: { - id: "kitchen-sink-channel", - label: "Kitchen Sink Channel", - selectionLabel: "Kitchen Sink", - docsPath: "/channels/kitchen-sink", - blurb: "Kitchen sink ClawHub fixture channel", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, - }, - }); - }, -}; -`, - { date: new Date(0) }, - ); - zip.file( - "package/openclaw.plugin.json", - `${JSON.stringify( - { - id: pluginId, - name: "OpenClaw Kitchen Sink", - channels: ["kitchen-sink-channel"], - providers: ["kitchen-sink-provider"], - configSchema: { - type: "object", - properties: {}, - }, - }, - null, - 2, - )}\n`, - { date: new Date(0) }, - ); - - const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }); - const sha256hash = crypto.createHash("sha256").update(archive).digest("hex"); - const packageDetail = { - package: { - name: packageName, - displayName: "OpenClaw Kitchen Sink", - family: "code-plugin", - runtimeId: pluginId, - channel: "official", - isOfficial: true, - summary: "Kitchen sink plugin fixture for prerelease CI.", - ownerHandle: "openclaw", - createdAt: 0, - updatedAt: 0, - latestVersion: version, - tags: { latest: version }, - capabilityTags: ["test-fixture"], - executesCode: true, - compatibility: { - pluginApiRange: ">=2026.4.11", - minGatewayVersion: "2026.4.11", - }, - capabilities: { - executesCode: true, - runtimeId: pluginId, - capabilityTags: ["test-fixture"], - channels: ["kitchen-sink-channel"], - providers: ["kitchen-sink-provider"], - }, - verification: { - tier: "source-linked", - sourceRepo: "https://github.com/openclaw/kitchen-sink", - hasProvenance: false, - scanStatus: "passed", - }, - }, - }; - const versionDetail = { - package: { - name: packageName, - displayName: "OpenClaw Kitchen Sink", - family: "code-plugin", - }, - version: { - version, - createdAt: 0, - changelog: "Fixture package for kitchen-sink plugin prerelease CI.", - distTags: ["latest"], - sha256hash, - compatibility: packageDetail.package.compatibility, - capabilities: packageDetail.package.capabilities, - verification: packageDetail.package.verification, - }, - }; - - const json = (response, value, status = 200) => { - response.writeHead(status, { "content-type": "application/json" }); - response.end(`${JSON.stringify(value)}\n`); - }; - - const server = http.createServer((request, response) => { - const url = new URL(request.url, "http://127.0.0.1"); - if (request.method !== "GET") { - response.writeHead(405); - response.end("method not allowed"); - return; - } - if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}`) { - json(response, packageDetail); - return; - } - if ( - url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${version}` - ) { - json(response, versionDetail); - return; - } - if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/beta`) { - json(response, { error: "version not found" }, 404); - return; - } - if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/download`) { - response.writeHead(200, { - "content-type": "application/zip", - "content-length": String(archive.length), - }); - response.end(archive); - return; - } - response.writeHead(404, { "content-type": "text/plain" }); - response.end(`not found: ${url.pathname}`); - }); - - server.listen(0, "127.0.0.1", () => { - require("node:fs").writeFileSync(portFile, String(server.address().port)); - }); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); -NODE + node scripts/e2e/lib/kitchen-sink-plugin/clawhub-fixture-server.cjs "$server_port_file" >"$server_log" 2>&1 & local server_pid="$!" echo "$server_pid" >"$server_pid_file" diff --git a/scripts/e2e/lib/openwebui/http-probe.mjs b/scripts/e2e/lib/openwebui/http-probe.mjs new file mode 100644 index 00000000000..331d0e4beb8 --- /dev/null +++ b/scripts/e2e/lib/openwebui/http-probe.mjs @@ -0,0 +1,23 @@ +const [url, expectedRaw = "200"] = process.argv.slice(2); +if (!url) { + throw new Error("usage: http-probe.mjs [status|lt500]"); +} + +const timeoutMs = Number(process.env.OPENCLAW_HTTP_PROBE_TIMEOUT_MS ?? 30_000); +const controller = new AbortController(); +const timer = setTimeout(() => controller.abort(), timeoutMs); + +try { + const headers = {}; + if (process.env.OPENCLAW_HTTP_PROBE_BEARER) { + headers.authorization = `Bearer ${process.env.OPENCLAW_HTTP_PROBE_BEARER}`; + } + const res = await fetch(url, { headers, signal: controller.signal }).catch(() => null); + const ok = + expectedRaw === "lt500" + ? Boolean(res && res.status < 500) + : res?.status === Number(expectedRaw); + process.exit(ok ? 0 : 1); +} finally { + clearTimeout(timer); +} diff --git a/scripts/e2e/lib/plugins/clawhub-fixture-server.cjs b/scripts/e2e/lib/plugins/clawhub-fixture-server.cjs new file mode 100644 index 00000000000..ef87afac3a6 --- /dev/null +++ b/scripts/e2e/lib/plugins/clawhub-fixture-server.cjs @@ -0,0 +1,137 @@ +const crypto = require("node:crypto"); +const http = require("node:http"); +const path = require("node:path"); +const { createRequire } = require("node:module"); + +const portFile = process.argv[2]; +const requireFromApp = createRequire(path.join(process.cwd(), "package.json")); +const JSZip = requireFromApp("jszip"); +const packageName = "openclaw-kitchen-sink"; +const pluginId = "openclaw-kitchen-sink-fixture"; +const version = "0.1.0"; + +async function main() { + const zip = new JSZip(); + zip.file( + "package/package.json", + `${JSON.stringify( + { + name: packageName, + version, + openclaw: { extensions: ["./index.js"] }, + }, + null, + 2, + )}\n`, + { date: new Date(0) }, + ); + zip.file( + "package/index.js", + `module.exports = { + id: "${pluginId}", + name: "OpenClaw Kitchen Sink", + description: "Docker E2E kitchen-sink plugin fixture", + register(api) { + api.on("before_agent_start", async (event, context) => ({ + kitchenSink: true, + observedEventKeys: Object.keys(event || {}), + observedContextKeys: Object.keys(context || {}), + })); + api.registerTool(() => null, { name: "kitchen_sink_tool" }); + api.registerGatewayMethod("kitchen-sink.ping", async () => ({ ok: true })); + api.registerCli(() => {}, { commands: ["kitchen-sink"] }); + api.registerService({ id: "kitchen-sink-service", start: () => {} }); + }, +}; +`, + { date: new Date(0) }, + ); + zip.file( + "package/openclaw.plugin.json", + `${JSON.stringify( + { + id: pluginId, + configSchema: { + type: "object", + properties: {}, + }, + }, + null, + 2, + )}\n`, + { date: new Date(0) }, + ); + + const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }); + const sha256hash = crypto.createHash("sha256").update(archive).digest("hex"); + + const json = (response, value) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify(value)}\n`); + }; + + const server = http.createServer((request, response) => { + const url = new URL(request.url, "http://127.0.0.1"); + if (request.method !== "GET") { + response.writeHead(405); + response.end("method not allowed"); + return; + } + if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}`) { + json(response, { + package: { + name: packageName, + displayName: "OpenClaw Kitchen Sink", + family: "code-plugin", + channel: "official", + isOfficial: true, + runtimeId: pluginId, + latestVersion: version, + createdAt: 0, + updatedAt: 0, + compatibility: { + pluginApiRange: ">=2026.4.26", + minGatewayVersion: "2026.4.26", + }, + }, + }); + return; + } + if ( + url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${version}` + ) { + json(response, { + version: { + version, + createdAt: 0, + changelog: "Kitchen-sink fixture package for Docker plugin E2E.", + sha256hash, + compatibility: { + pluginApiRange: ">=2026.4.26", + minGatewayVersion: "2026.4.26", + }, + }, + }); + return; + } + if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/download`) { + response.writeHead(200, { + "content-type": "application/zip", + "content-length": String(archive.length), + }); + response.end(archive); + return; + } + response.writeHead(404, { "content-type": "text/plain" }); + response.end(`not found: ${url.pathname}`); + }); + + server.listen(0, "127.0.0.1", () => { + require("node:fs").writeFileSync(portFile, String(server.address().port)); + }); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/e2e/lib/plugins/clawhub.sh b/scripts/e2e/lib/plugins/clawhub.sh new file mode 100644 index 00000000000..8cc6cac1c90 --- /dev/null +++ b/scripts/e2e/lib/plugins/clawhub.sh @@ -0,0 +1,195 @@ +run_plugins_clawhub_scenario() { + if [ "${OPENCLAW_PLUGINS_E2E_CLAWHUB:-1}" = "0" ]; then + echo "Skipping ClawHub plugin install and uninstall (OPENCLAW_PLUGINS_E2E_CLAWHUB=0)." + else + echo "Testing ClawHub kitchen-sink plugin install and uninstall..." + CLAWHUB_PLUGIN_SPEC="${OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC:-clawhub:openclaw-kitchen-sink}" + CLAWHUB_PLUGIN_ID="${OPENCLAW_PLUGINS_E2E_CLAWHUB_ID:-openclaw-kitchen-sink-fixture}" + export CLAWHUB_PLUGIN_SPEC CLAWHUB_PLUGIN_ID + + start_clawhub_fixture_server() { + local fixture_dir="$1" + local server_log="$fixture_dir/clawhub-fixture.log" + local server_port_file="$fixture_dir/clawhub-fixture-port" + local server_pid_file="$fixture_dir/clawhub-fixture-pid" + + node scripts/e2e/lib/plugins/clawhub-fixture-server.cjs "$server_port_file" >"$server_log" 2>&1 & + local server_pid="$!" + echo "$server_pid" >"$server_pid_file" + + for _ in $(seq 1 100); do + if [[ -s "$server_port_file" ]]; then + export OPENCLAW_CLAWHUB_URL="http://127.0.0.1:$(cat "$server_port_file")" + trap 'if [[ -f "'"$server_pid_file"'" ]]; then kill "$(cat "'"$server_pid_file"'")" 2>/dev/null || true; fi' EXIT + return 0 + fi + if ! kill -0 "$server_pid" 2>/dev/null; then + cat "$server_log" + return 1 + fi + sleep 0.1 + done + + cat "$server_log" + echo "Timed out waiting for ClawHub fixture server." >&2 + return 1 + } + + if [[ -z "${OPENCLAW_CLAWHUB_URL:-}" && -z "${CLAWHUB_URL:-}" ]]; then + # Keep the release-path smoke hermetic; live ClawHub can rate-limit CI. + clawhub_fixture_dir="$(mktemp -d "/tmp/openclaw-clawhub-fixture.XXXXXX")" + start_clawhub_fixture_server "$clawhub_fixture_dir" + fi + + 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 configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; +const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; +if (!allowLegacyCompat && !index.installRecords) { + throw new Error("expected modern installRecords in installed plugin index"); +} +const installRecords = allowLegacyCompat + ? index.installRecords ?? index.records ?? config.plugins?.installs ?? {} + : index.installRecords ?? {}; +const record = 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")) : {}; +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; +const installRecords = index.installRecords ?? index.records ?? config.plugins?.installs ?? {}; +if (installRecords[pluginId]) { + throw new Error(`ClawHub install record still present after uninstall: ${pluginId}`); +} + +const configAfterUninstallPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const configAfterUninstall = fs.existsSync(configAfterUninstallPath) + ? JSON.parse(fs.readFileSync(configAfterUninstallPath, "utf8")) + : {}; +if (configAfterUninstall.plugins?.entries?.[pluginId]) { + throw new Error(`ClawHub config entry still present after uninstall: ${pluginId}`); +} +if ((configAfterUninstall.plugins?.allow || []).includes(pluginId)) { + throw new Error(`ClawHub allowlist entry still present after uninstall: ${pluginId}`); +} +if ((configAfterUninstall.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 +} diff --git a/scripts/e2e/lib/plugins/fixtures.sh b/scripts/e2e/lib/plugins/fixtures.sh new file mode 100644 index 00000000000..593dd5b9da1 --- /dev/null +++ b/scripts/e2e/lib/plugins/fixtures.sh @@ -0,0 +1,87 @@ +record_fixture_plugin_trust() { + local plugin_id="$1" + local plugin_root="$2" + local enabled="$3" + node - "$plugin_id" "$plugin_root" "$enabled" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const pluginId = process.argv[2]; +const pluginRoot = process.argv[3]; +const enabled = process.argv[4] === "1"; +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) + ? JSON.parse(fs.readFileSync(configPath, "utf8")) + : {}; +const plugins = (config.plugins ??= {}); +const entries = (plugins.entries ??= {}); +entries[pluginId] = { ...(entries[pluginId] ?? {}), enabled }; +delete plugins.installs; +plugins.allow = Array.from(new Set([...(plugins.allow ?? []), pluginId])).sort(); +fs.mkdirSync(path.dirname(configPath), { recursive: true }); +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + +const ledgerPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); +const ledger = fs.existsSync(ledgerPath) + ? JSON.parse(fs.readFileSync(ledgerPath, "utf8")) + : { + version: 1, + warning: + "DO NOT EDIT. This file is generated by OpenClaw plugin install/update/uninstall commands. Use `openclaw plugins install/update/uninstall` instead.", + records: {}, + }; +ledger.updatedAtMs = Date.now(); +ledger.records ??= {}; +ledger.records[pluginId] = { + ...(ledger.records[pluginId] ?? {}), + source: "path", + installPath: pluginRoot, + sourcePath: pluginRoot, +}; +fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); +fs.writeFileSync(ledgerPath, `${JSON.stringify(ledger, null, 2)}\n`, "utf8"); +NODE +} + +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 - "$dir/openclaw.plugin.json" "$id" <<'NODE' +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 +} diff --git a/scripts/e2e/lib/plugins/marketplace.sh b/scripts/e2e/lib/plugins/marketplace.sh new file mode 100644 index 00000000000..5e63c17e1ac --- /dev/null +++ b/scripts/e2e/lib/plugins/marketplace.sh @@ -0,0 +1,173 @@ +run_plugins_marketplace_scenario() { + 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 + + run_logged install-marketplace-shortcut node "$OPENCLAW_ENTRY" plugins install marketplace-shortcut@claude-fixtures + run_logged install-marketplace-direct node "$OPENCLAW_ENTRY" plugins install marketplace-direct --marketplace claude-fixtures + node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-marketplace.json + node "$OPENCLAW_ENTRY" plugins inspect marketplace-shortcut --json >/tmp/plugins-marketplace-shortcut-inspect.json + node "$OPENCLAW_ENTRY" plugins inspect marketplace-direct --json >/tmp/plugins-marketplace-direct-inspect.json + + node - <<'NODE' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace.json", "utf8")); +const shortcutInspect = JSON.parse( + fs.readFileSync("/tmp/plugins-marketplace-shortcut-inspect.json", "utf8"), +); +const directInspect = JSON.parse( + fs.readFileSync("/tmp/plugins-marketplace-direct-inspect.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 (!shortcutInspect.gatewayMethods.includes("demo.marketplace.shortcut.v1")) { + throw new Error("expected marketplace shortcut gateway method"); +} +if (!directInspect.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 indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); +const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; +const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; +if (!allowLegacyCompat && !index.installRecords) { + throw new Error("expected modern installRecords in installed plugin index"); +} +const installRecords = allowLegacyCompat + ? index.installRecords ?? index.records ?? config.plugins?.installs ?? {} + : index.installRecords ?? {}; +for (const id of ["marketplace-shortcut", "marketplace-direct"]) { + const record = installRecords[id]; + if (!record) { + if (allowLegacyCompat) { + console.log(`legacy package did not persist marketplace install record for ${id}`); + continue; + } + throw new Error(`missing marketplace 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" + run_logged update-marketplace-shortcut-dry-run node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut --dry-run + run_logged update-marketplace-shortcut node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut + node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-marketplace-updated.json + node "$OPENCLAW_ENTRY" plugins inspect marketplace-shortcut --json >/tmp/plugins-marketplace-updated-inspect.json + + node - <<'NODE' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace-updated.json", "utf8")); +const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace-updated-inspect.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 (!inspect.gatewayMethods.includes("demo.marketplace.shortcut.v2")) { + throw new Error(`expected updated gateway method, got ${inspect.gatewayMethods.join(", ")}`); +} +console.log("ok"); +NODE +} diff --git a/scripts/e2e/lib/plugins/sweep.sh b/scripts/e2e/lib/plugins/sweep.sh index d5fc1c57826..37a696d3b75 100644 --- a/scripts/e2e/lib/plugins/sweep.sh +++ b/scripts/e2e/lib/plugins/sweep.sh @@ -13,94 +13,9 @@ openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing BUNDLED_PLUGIN_ROOT_DIR="extensions" OPENCLAW_PLUGIN_HOME="$HOME/.openclaw/$BUNDLED_PLUGIN_ROOT_DIR" -record_fixture_plugin_trust() { - local plugin_id="$1" - local plugin_root="$2" - local enabled="$3" - node - "$plugin_id" "$plugin_root" "$enabled" <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const pluginId = process.argv[2]; -const pluginRoot = process.argv[3]; -const enabled = process.argv[4] === "1"; -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) - ? JSON.parse(fs.readFileSync(configPath, "utf8")) - : {}; -const plugins = (config.plugins ??= {}); -const entries = (plugins.entries ??= {}); -entries[pluginId] = { ...(entries[pluginId] ?? {}), enabled }; -delete plugins.installs; -plugins.allow = Array.from(new Set([...(plugins.allow ?? []), pluginId])).sort(); -fs.mkdirSync(path.dirname(configPath), { recursive: true }); -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); - -const ledgerPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); -const ledger = fs.existsSync(ledgerPath) - ? JSON.parse(fs.readFileSync(ledgerPath, "utf8")) - : { - version: 1, - warning: - "DO NOT EDIT. This file is generated by OpenClaw plugin install/update/uninstall commands. Use `openclaw plugins install/update/uninstall` instead.", - records: {}, - }; -ledger.updatedAtMs = Date.now(); -ledger.records ??= {}; -ledger.records[pluginId] = { - ...(ledger.records[pluginId] ?? {}), - source: "path", - installPath: pluginRoot, - sourcePath: pluginRoot, -}; -fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); -fs.writeFileSync(ledgerPath, `${JSON.stringify(ledger, null, 2)}\n`, "utf8"); -NODE -} - -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 - "$dir/openclaw.plugin.json" "$id" <<'NODE' -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 -} - +source scripts/e2e/lib/plugins/fixtures.sh +source scripts/e2e/lib/plugins/marketplace.sh +source scripts/e2e/lib/plugins/clawhub.sh demo_plugin_id="demo-plugin" demo_plugin_root="$OPENCLAW_PLUGIN_HOME/$demo_plugin_id" mkdir -p "$demo_plugin_root" @@ -405,506 +320,6 @@ if (!inspect.gatewayMethods.includes("demo.slash.install")) { console.log("ok"); NODE -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 - -run_logged install-marketplace-shortcut node "$OPENCLAW_ENTRY" plugins install marketplace-shortcut@claude-fixtures -run_logged install-marketplace-direct node "$OPENCLAW_ENTRY" plugins install marketplace-direct --marketplace claude-fixtures -node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-marketplace.json -node "$OPENCLAW_ENTRY" plugins inspect marketplace-shortcut --json >/tmp/plugins-marketplace-shortcut-inspect.json -node "$OPENCLAW_ENTRY" plugins inspect marketplace-direct --json >/tmp/plugins-marketplace-direct-inspect.json - -node - <<'NODE' -const fs = require("node:fs"); - -const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace.json", "utf8")); -const shortcutInspect = JSON.parse( - fs.readFileSync("/tmp/plugins-marketplace-shortcut-inspect.json", "utf8"), -); -const directInspect = JSON.parse( - fs.readFileSync("/tmp/plugins-marketplace-direct-inspect.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 (!shortcutInspect.gatewayMethods.includes("demo.marketplace.shortcut.v1")) { - throw new Error("expected marketplace shortcut gateway method"); -} -if (!directInspect.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 indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); -const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; -const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; -if (!allowLegacyCompat && !index.installRecords) { - throw new Error("expected modern installRecords in installed plugin index"); -} -const installRecords = allowLegacyCompat - ? index.installRecords ?? index.records ?? config.plugins?.installs ?? {} - : index.installRecords ?? {}; -for (const id of ["marketplace-shortcut", "marketplace-direct"]) { - const record = installRecords[id]; - if (!record) { - if (allowLegacyCompat) { - console.log(`legacy package did not persist marketplace install record for ${id}`); - continue; - } - throw new Error(`missing marketplace 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" -run_logged update-marketplace-shortcut-dry-run node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut --dry-run -run_logged update-marketplace-shortcut node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut -node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-marketplace-updated.json -node "$OPENCLAW_ENTRY" plugins inspect marketplace-shortcut --json >/tmp/plugins-marketplace-updated-inspect.json - -node - <<'NODE' -const fs = require("node:fs"); - -const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace-updated.json", "utf8")); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace-updated-inspect.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 (!inspect.gatewayMethods.includes("demo.marketplace.shortcut.v2")) { - throw new Error(`expected updated gateway method, got ${inspect.gatewayMethods.join(", ")}`); -} -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 kitchen-sink plugin install and uninstall..." - CLAWHUB_PLUGIN_SPEC="${OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC:-clawhub:openclaw-kitchen-sink}" - CLAWHUB_PLUGIN_ID="${OPENCLAW_PLUGINS_E2E_CLAWHUB_ID:-openclaw-kitchen-sink-fixture}" - export CLAWHUB_PLUGIN_SPEC CLAWHUB_PLUGIN_ID - - start_clawhub_fixture_server() { - local fixture_dir="$1" - local server_log="$fixture_dir/clawhub-fixture.log" - local server_port_file="$fixture_dir/clawhub-fixture-port" - local server_pid_file="$fixture_dir/clawhub-fixture-pid" - - node - "$server_port_file" <<'NODE' >"$server_log" 2>&1 & -const crypto = require("node:crypto"); -const http = require("node:http"); -const path = require("node:path"); -const { createRequire } = require("node:module"); - -const portFile = process.argv[2]; -const requireFromApp = createRequire(path.join(process.cwd(), "package.json")); -const JSZip = requireFromApp("jszip"); -const packageName = "openclaw-kitchen-sink"; -const pluginId = "openclaw-kitchen-sink-fixture"; -const version = "0.1.0"; - -async function main() { - const zip = new JSZip(); - zip.file( - "package/package.json", - `${JSON.stringify( - { - name: packageName, - version, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - )}\n`, - { date: new Date(0) }, - ); - zip.file( - "package/index.js", - `module.exports = { - id: "${pluginId}", - name: "OpenClaw Kitchen Sink", - description: "Docker E2E kitchen-sink plugin fixture", - register(api) { - api.on("before_agent_start", async (event, context) => ({ - kitchenSink: true, - observedEventKeys: Object.keys(event || {}), - observedContextKeys: Object.keys(context || {}), - })); - api.registerTool(() => null, { name: "kitchen_sink_tool" }); - api.registerGatewayMethod("kitchen-sink.ping", async () => ({ ok: true })); - api.registerCli(() => {}, { commands: ["kitchen-sink"] }); - api.registerService({ id: "kitchen-sink-service", start: () => {} }); - }, -}; -`, - { date: new Date(0) }, - ); - zip.file( - "package/openclaw.plugin.json", - `${JSON.stringify( - { - id: pluginId, - configSchema: { - type: "object", - properties: {}, - }, - }, - null, - 2, - )}\n`, - { date: new Date(0) }, - ); - - const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }); - const sha256hash = crypto.createHash("sha256").update(archive).digest("hex"); - - const json = (response, value) => { - response.writeHead(200, { "content-type": "application/json" }); - response.end(`${JSON.stringify(value)}\n`); - }; - - const server = http.createServer((request, response) => { - const url = new URL(request.url, "http://127.0.0.1"); - if (request.method !== "GET") { - response.writeHead(405); - response.end("method not allowed"); - return; - } - if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}`) { - json(response, { - package: { - name: packageName, - displayName: "OpenClaw Kitchen Sink", - family: "code-plugin", - channel: "official", - isOfficial: true, - runtimeId: pluginId, - latestVersion: version, - createdAt: 0, - updatedAt: 0, - compatibility: { - pluginApiRange: ">=2026.4.26", - minGatewayVersion: "2026.4.26", - }, - }, - }); - return; - } - if ( - url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${version}` - ) { - json(response, { - version: { - version, - createdAt: 0, - changelog: "Kitchen-sink fixture package for Docker plugin E2E.", - sha256hash, - compatibility: { - pluginApiRange: ">=2026.4.26", - minGatewayVersion: "2026.4.26", - }, - }, - }); - return; - } - if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/download`) { - response.writeHead(200, { - "content-type": "application/zip", - "content-length": String(archive.length), - }); - response.end(archive); - return; - } - response.writeHead(404, { "content-type": "text/plain" }); - response.end(`not found: ${url.pathname}`); - }); - - server.listen(0, "127.0.0.1", () => { - require("node:fs").writeFileSync(portFile, String(server.address().port)); - }); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); -NODE - local server_pid="$!" - echo "$server_pid" >"$server_pid_file" - - for _ in $(seq 1 100); do - if [[ -s "$server_port_file" ]]; then - export OPENCLAW_CLAWHUB_URL="http://127.0.0.1:$(cat "$server_port_file")" - trap 'if [[ -f "'"$server_pid_file"'" ]]; then kill "$(cat "'"$server_pid_file"'")" 2>/dev/null || true; fi' EXIT - return 0 - fi - if ! kill -0 "$server_pid" 2>/dev/null; then - cat "$server_log" - return 1 - fi - sleep 0.1 - done - - cat "$server_log" - echo "Timed out waiting for ClawHub fixture server." >&2 - return 1 - } - - if [[ -z "${OPENCLAW_CLAWHUB_URL:-}" && -z "${CLAWHUB_URL:-}" ]]; then - # Keep the release-path smoke hermetic; live ClawHub can rate-limit CI. - clawhub_fixture_dir="$(mktemp -d "/tmp/openclaw-clawhub-fixture.XXXXXX")" - start_clawhub_fixture_server "$clawhub_fixture_dir" - fi - - 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 configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; -const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; -if (!allowLegacyCompat && !index.installRecords) { - throw new Error("expected modern installRecords in installed plugin index"); -} -const installRecords = allowLegacyCompat - ? index.installRecords ?? index.records ?? config.plugins?.installs ?? {} - : index.installRecords ?? {}; -const record = 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")) : {}; -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; -const installRecords = index.installRecords ?? index.records ?? config.plugins?.installs ?? {}; -if (installRecords[pluginId]) { - throw new Error(`ClawHub install record still present after uninstall: ${pluginId}`); -} - -const configAfterUninstallPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const configAfterUninstall = fs.existsSync(configAfterUninstallPath) - ? JSON.parse(fs.readFileSync(configAfterUninstallPath, "utf8")) - : {}; -if (configAfterUninstall.plugins?.entries?.[pluginId]) { - throw new Error(`ClawHub config entry still present after uninstall: ${pluginId}`); -} -if ((configAfterUninstall.plugins?.allow || []).includes(pluginId)) { - throw new Error(`ClawHub allowlist entry still present after uninstall: ${pluginId}`); -} -if ((configAfterUninstall.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 +run_plugins_clawhub_scenario diff --git a/scripts/e2e/openai-image-auth-docker.sh b/scripts/e2e/openai-image-auth-docker.sh index 176469c530d..e65bec757fe 100644 --- a/scripts/e2e/openai-image-auth-docker.sh +++ b/scripts/e2e/openai-image-auth-docker.sh @@ -20,7 +20,8 @@ docker_e2e_run_logged_with_harness openai-image-auth \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ -i "$IMAGE_NAME" bash -lc ' set -euo pipefail -eval "$(printf "%s" "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" | base64 -d)" +source scripts/lib/openclaw-e2e-instance.sh +openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" export OPENCLAW_SKIP_CHANNELS=1 export OPENCLAW_SKIP_GMAIL_WATCHER=1 export OPENCLAW_SKIP_CRON=1 diff --git a/scripts/e2e/openwebui-docker.sh b/scripts/e2e/openwebui-docker.sh index 6d7809d995d..c7dfbcd68b4 100755 --- a/scripts/e2e/openwebui-docker.sh +++ b/scripts/e2e/openwebui-docker.sh @@ -24,10 +24,6 @@ OW_NAME="openclaw-openwebui-$$" DOCKER_COMMAND_TIMEOUT="${OPENCLAW_OPENWEBUI_DOCKER_COMMAND_TIMEOUT:-600s}" DOCKER_PULL_TIMEOUT="${OPENCLAW_OPENWEBUI_DOCKER_PULL_TIMEOUT:-600s}" -docker_cmd() { - timeout "$DOCKER_COMMAND_TIMEOUT" "$@" -} - OPENAI_API_KEY_VALUE="${OPENAI_API_KEY:-}" if [[ "$OPENAI_API_KEY_VALUE" == "undefined" || "$OPENAI_API_KEY_VALUE" == "null" ]]; then OPENAI_API_KEY_VALUE="" @@ -42,9 +38,9 @@ if [[ -z "$OPENAI_API_KEY_VALUE" ]]; then fi cleanup() { - docker_cmd docker rm -f "$OW_NAME" >/dev/null 2>&1 || true - docker_cmd docker rm -f "$GW_NAME" >/dev/null 2>&1 || true - docker_cmd docker network rm "$NET_NAME" >/dev/null 2>&1 || true + docker_e2e_docker_cmd rm -f "$OW_NAME" >/dev/null 2>&1 || true + docker_e2e_docker_cmd rm -f "$GW_NAME" >/dev/null 2>&1 || true + docker_e2e_docker_cmd network rm "$NET_NAME" >/dev/null 2>&1 || true } trap cleanup EXIT @@ -54,12 +50,12 @@ echo "Pulling Open WebUI image: $OPENWEBUI_IMAGE" timeout "$DOCKER_PULL_TIMEOUT" docker pull "$OPENWEBUI_IMAGE" >/dev/null echo "Creating Docker network..." -docker_cmd docker network create "$NET_NAME" >/dev/null +docker_e2e_docker_cmd network create "$NET_NAME" >/dev/null echo "Starting gateway container..." # Harness files are mounted read-only; the app under test comes from /app/dist. docker_e2e_harness_mount_args -docker_cmd docker run -d \ +docker_e2e_docker_cmd run -d \ "${DOCKER_E2E_HARNESS_ARGS[@]}" \ --name "$GW_NAME" \ --network "$NET_NAME" \ @@ -124,12 +120,7 @@ EOF ' >/dev/null echo "Waiting for gateway HTTP surface..." -if ! docker_e2e_wait_container_bash_while_running "$OW_NAME" "$GW_NAME" 240 1 "node --input-type=module -e ' - const res = await fetch(\"http://127.0.0.1:$PORT/v1/models\", { - headers: { authorization: \"Bearer $TOKEN\" }, - }).catch(() => null); - process.exit(res?.status === 200 ? 0 : 1); -'"; then +if ! docker_e2e_wait_container_bash "$GW_NAME" 240 1 "OPENCLAW_HTTP_PROBE_BEARER='$TOKEN' node scripts/e2e/lib/openwebui/http-probe.mjs 'http://127.0.0.1:$PORT/v1/models' 200"; then echo "Gateway failed to start" docker_e2e_docker_cmd inspect "$GW_NAME" --format '{{json .State}}' 2>/dev/null || true docker_e2e_tail_container_file_if_running "$GW_NAME" /tmp/openwebui-gateway.log 200 @@ -137,7 +128,7 @@ if ! docker_e2e_wait_container_bash_while_running "$OW_NAME" "$GW_NAME" 240 1 "n fi echo "Starting Open WebUI container..." -docker_cmd docker run -d \ +docker_e2e_docker_cmd run -d \ --name "$OW_NAME" \ --network "$NET_NAME" \ -e ENV=prod \ @@ -161,31 +152,14 @@ docker_cmd docker run -d \ "$OPENWEBUI_IMAGE" >/dev/null echo "Waiting for Open WebUI..." -if ! docker_e2e_wait_container_bash "$GW_NAME" 240 1 "node --input-type=module -e ' - const res = await fetch(\"http://$OW_NAME:$WEBUI_PORT/\").catch(() => null); - process.exit(res && res.status < 500 ? 0 : 1); -'"; then +if ! docker_e2e_wait_container_bash_while_running "$OW_NAME" "$GW_NAME" 240 1 "node scripts/e2e/lib/openwebui/http-probe.mjs 'http://$OW_NAME:$WEBUI_PORT/' lt500"; then echo "Open WebUI failed to start" docker_e2e_docker_cmd logs "$OW_NAME" 2>&1 | tail -n 200 || true exit 1 fi echo "Waiting for gateway model endpoint after Open WebUI startup..." -if ! docker_e2e_wait_container_bash "$GW_NAME" 90 5 "node --input-type=module -e ' - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 8000); - try { - const res = await fetch(\"http://$GW_NAME:$PORT/v1/models\", { - headers: { authorization: \"Bearer $TOKEN\" }, - signal: controller.signal, - }); - process.exit(res.status === 200 ? 0 : 1); - } catch { - process.exit(1); - } finally { - clearTimeout(timeout); - } -'"; then +if ! docker_e2e_wait_container_bash "$GW_NAME" 90 5 "OPENCLAW_HTTP_PROBE_BEARER='$TOKEN' OPENCLAW_HTTP_PROBE_TIMEOUT_MS=8000 node scripts/e2e/lib/openwebui/http-probe.mjs 'http://$GW_NAME:$PORT/v1/models' 200"; then echo "Gateway model endpoint did not stay reachable after Open WebUI startup" docker_e2e_docker_cmd inspect "$GW_NAME" --format '{{json .State}}' 2>/dev/null || true docker_e2e_tail_container_file_if_running "$GW_NAME" /tmp/openwebui-gateway.log 200 @@ -194,7 +168,7 @@ if ! docker_e2e_wait_container_bash "$GW_NAME" 90 5 "node --input-type=module -e fi echo "Running Open WebUI -> OpenClaw smoke..." -if ! docker_cmd docker exec \ +if ! docker_e2e_docker_cmd exec \ -e "OPENWEBUI_BASE_URL=http://$OW_NAME:$WEBUI_PORT" \ -e "OPENWEBUI_ADMIN_EMAIL=$ADMIN_EMAIL" \ -e "OPENWEBUI_ADMIN_PASSWORD=$ADMIN_PASSWORD" \ diff --git a/scripts/e2e/pi-bundle-mcp-tools-docker.sh b/scripts/e2e/pi-bundle-mcp-tools-docker.sh index bda39081537..2f2b16fca61 100755 --- a/scripts/e2e/pi-bundle-mcp-tools-docker.sh +++ b/scripts/e2e/pi-bundle-mcp-tools-docker.sh @@ -26,7 +26,8 @@ docker_e2e_run_with_harness \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail - eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\" + source scripts/lib/openclaw-e2e-instance.sh + openclaw_e2e_eval_test_state_from_b64 \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" tsx scripts/e2e/pi-bundle-mcp-tools-docker-client.ts " >"$RUN_LOG" 2>&1 status=${PIPESTATUS[0]} diff --git a/scripts/e2e/update-channel-switch-docker.sh b/scripts/e2e/update-channel-switch-docker.sh index c003f88b4df..64d0a6c4e3c 100755 --- a/scripts/e2e/update-channel-switch-docker.sh +++ b/scripts/e2e/update-channel-switch-docker.sh @@ -15,15 +15,15 @@ docker_e2e_package_mount_args "$PACKAGE_TGZ" OPENCLAW_TEST_STATE_SCRIPT_B64="$( node "$ROOT_DIR/scripts/lib/openclaw-test-state.mjs" shell \ --label update-channel-switch \ - --scenario update-stable \ - | base64 \ - | tr -d '\n' + --scenario update-stable | + base64 | + tr -d '\n' )" docker_e2e_build_or_reuse "$IMAGE_NAME" update-channel-switch "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD" echo "Running update channel switch E2E..." -docker run --rm \ +docker_e2e_run_with_harness \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_SKIP_PROVIDERS=1 \ @@ -31,6 +31,7 @@ docker run --rm \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc 'set -euo pipefail +source scripts/lib/openclaw-e2e-instance.sh export npm_config_loglevel=error export npm_config_fund=false @@ -140,31 +141,11 @@ pkg_tgz_path="$package_tgz" npm install -g --prefix /tmp/npm-prefix --omit=optional "$pkg_tgz_path" package_version="$(node -p "JSON.parse(require(\"node:fs\").readFileSync(\"/tmp/npm-prefix/lib/node_modules/openclaw/package.json\", \"utf8\")).version")" OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$( - node - "$package_version" <<"NODE" -const version = process.argv[2] || ""; -const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:[-+].*)?$/.exec(version); -if (!match) { - console.log("0"); - process.exit(0); -} -const value = [Number(match[1]), Number(match[2]), Number(match[3])]; -const max = [2026, 4, 25]; -for (let i = 0; i < value.length; i += 1) { - if (value[i] < max[i]) { - console.log("1"); - process.exit(0); - } - if (value[i] > max[i]) { - console.log("0"); - process.exit(0); - } -} -console.log("1"); -NODE + node scripts/e2e/lib/package-compat.mjs "$package_version" )" export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT -eval "$(printf "%s" "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" | base64 -d)" +openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" export OPENCLAW_GIT_DIR="$git_root" export OPENCLAW_UPDATE_DEV_TARGET_REF="$fixture_sha" diff --git a/scripts/lib/docker-e2e-container.sh b/scripts/lib/docker-e2e-container.sh index 307bb5aa7b4..e2c144fbb97 100644 --- a/scripts/lib/docker-e2e-container.sh +++ b/scripts/lib/docker-e2e-container.sh @@ -4,7 +4,11 @@ # while polling readiness from the host. docker_e2e_docker_cmd() { - timeout "${DOCKER_COMMAND_TIMEOUT:-600s}" docker "$@" + if command -v timeout >/dev/null 2>&1; then + timeout "${DOCKER_COMMAND_TIMEOUT:-600s}" docker "$@" + return + fi + docker "$@" } docker_e2e_container_running() {