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() {