From f6a2cf15c09c61520ea7f2af98a5554c5d100e85 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 08:27:57 +0100 Subject: [PATCH 1/6] fix: keep browser test fetch out of runtime scan --- .../browser/src/browser/server.agent-contract-core.test.ts | 2 +- .../server.agent-contract-form-layout-act-commands.test.ts | 2 +- .../browser/src/browser/server.agent-contract.test-harness.ts | 2 +- .../browser/src/browser/server.auth-token-gates-http.test.ts | 2 +- .../server.evaluate-disabled-does-not-block-storage.test.ts | 2 +- .../src/browser/{test-fetch.ts => test-support/fetch.ts} | 0 6 files changed, 5 insertions(+), 5 deletions(-) rename extensions/browser/src/browser/{test-fetch.ts => test-support/fetch.ts} (100%) diff --git a/extensions/browser/src/browser/server.agent-contract-core.test.ts b/extensions/browser/src/browser/server.agent-contract-core.test.ts index bfe0eb7aac4..957c1d634bf 100644 --- a/extensions/browser/src/browser/server.agent-contract-core.test.ts +++ b/extensions/browser/src/browser/server.agent-contract-core.test.ts @@ -19,7 +19,7 @@ import { setBrowserControlServerReachable, startBrowserControlServerFromConfig, } from "./server.control-server.test-harness.js"; -import { getBrowserTestFetch } from "./test-fetch.js"; +import { getBrowserTestFetch } from "./test-support/fetch.js"; type ActErrorResponse = { error?: string; diff --git a/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts b/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts index 531f132107a..97a958cbbe2 100644 --- a/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts +++ b/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts @@ -12,7 +12,7 @@ import { getBrowserControlServerTestState, getPwMocks, } from "./server.control-server.test-harness.js"; -import { getBrowserTestFetch, type BrowserTestFetch } from "./test-fetch.js"; +import { getBrowserTestFetch, type BrowserTestFetch } from "./test-support/fetch.js"; const state = getBrowserControlServerTestState(); const pwMocks = getPwMocks(); diff --git a/extensions/browser/src/browser/server.agent-contract.test-harness.ts b/extensions/browser/src/browser/server.agent-contract.test-harness.ts index 898f16d67a8..eb93fd87237 100644 --- a/extensions/browser/src/browser/server.agent-contract.test-harness.ts +++ b/extensions/browser/src/browser/server.agent-contract.test-harness.ts @@ -3,7 +3,7 @@ import { installBrowserControlServerHooks, startBrowserControlServerFromConfig, } from "./server.control-server.test-harness.js"; -import { getBrowserTestFetch } from "./test-fetch.js"; +import { getBrowserTestFetch } from "./test-support/fetch.js"; export function installAgentContractHooks() { installBrowserControlServerHooks(); diff --git a/extensions/browser/src/browser/server.auth-token-gates-http.test.ts b/extensions/browser/src/browser/server.auth-token-gates-http.test.ts index e7f61904aab..8f5af493352 100644 --- a/extensions/browser/src/browser/server.auth-token-gates-http.test.ts +++ b/extensions/browser/src/browser/server.auth-token-gates-http.test.ts @@ -1,7 +1,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { isAuthorizedBrowserRequest } from "./http-auth.js"; -import { getBrowserTestFetch, type BrowserTestFetch } from "./test-fetch.js"; +import { getBrowserTestFetch, type BrowserTestFetch } from "./test-support/fetch.js"; let server: ReturnType | null = null; let port = 0; diff --git a/extensions/browser/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts b/extensions/browser/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts index 48f5dfef12d..67d6fd759da 100644 --- a/extensions/browser/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts +++ b/extensions/browser/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getBrowserTestFetch } from "./test-fetch.js"; import { getFreePort } from "./test-port.js"; +import { getBrowserTestFetch } from "./test-support/fetch.js"; let testPort = 0; let prevGatewayPort: string | undefined; diff --git a/extensions/browser/src/browser/test-fetch.ts b/extensions/browser/src/browser/test-support/fetch.ts similarity index 100% rename from extensions/browser/src/browser/test-fetch.ts rename to extensions/browser/src/browser/test-support/fetch.ts From 34bd962a2093b60a46defe83ab34cb0a1b742c91 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 08:28:49 +0100 Subject: [PATCH 2/6] refactor: share docker e2e harness runner --- ...bundled-plugin-install-uninstall-docker.sh | 4 +- scripts/e2e/crestodian-first-run-docker.sh | 4 +- scripts/e2e/crestodian-planner-docker.sh | 4 +- scripts/e2e/crestodian-rescue-docker.sh | 4 +- scripts/e2e/lib/plugin-update/probe.mjs | 137 +++++++++++++++++ .../lib/plugin-update/unchanged-scenario.sh | 142 +----------------- .../e2e/npm-onboard-channel-agent-docker.sh | 4 +- scripts/e2e/pi-bundle-mcp-tools-docker.sh | 4 +- scripts/e2e/plugin-update-unchanged-docker.sh | 4 +- scripts/e2e/session-runtime-context-docker.sh | 4 +- scripts/lib/docker-e2e-package.sh | 5 + 11 files changed, 157 insertions(+), 159 deletions(-) create mode 100644 scripts/e2e/lib/plugin-update/probe.mjs diff --git a/scripts/e2e/bundled-plugin-install-uninstall-docker.sh b/scripts/e2e/bundled-plugin-install-uninstall-docker.sh index 660267de7aa..0467abd91f0 100755 --- a/scripts/e2e/bundled-plugin-install-uninstall-docker.sh +++ b/scripts/e2e/bundled-plugin-install-uninstall-docker.sh @@ -7,7 +7,6 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-bundled-plugin-install-uninstal docker_e2e_build_or_reuse "$IMAGE_NAME" bundled-plugin-install-uninstall OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 bundled-plugin-install-uninstall empty)" -docker_e2e_harness_mount_args DOCKER_ENV_ARGS=( -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 @@ -25,9 +24,8 @@ done echo "Running bundled plugin install/uninstall Docker E2E..." RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-bundled-plugin-install-uninstall.XXXXXX")" -if ! docker run --rm \ +if ! docker_e2e_run_with_harness \ "${DOCKER_ENV_ARGS[@]}" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh >"$RUN_LOG" 2>&1 then diff --git a/scripts/e2e/crestodian-first-run-docker.sh b/scripts/e2e/crestodian-first-run-docker.sh index 20fc158ebe9..f2a112cba17 100644 --- a/scripts/e2e/crestodian-first-run-docker.sh +++ b/scripts/e2e/crestodian-first-run-docker.sh @@ -16,16 +16,14 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-first-run -docker_e2e_harness_mount_args OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 crestodian-first-run empty)" echo "Running in-container Crestodian first-run smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. set +e -docker run --rm \ +docker_e2e_run_with_harness \ --name "$CONTAINER_NAME" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\" diff --git a/scripts/e2e/crestodian-planner-docker.sh b/scripts/e2e/crestodian-planner-docker.sh index c518e24299f..c479c0718cc 100755 --- a/scripts/e2e/crestodian-planner-docker.sh +++ b/scripts/e2e/crestodian-planner-docker.sh @@ -16,16 +16,14 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-planner -docker_e2e_harness_mount_args OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 crestodian-planner empty)" echo "Running in-container Crestodian planner fallback smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. set +e -docker run --rm \ +docker_e2e_run_with_harness \ --name "$CONTAINER_NAME" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\" diff --git a/scripts/e2e/crestodian-rescue-docker.sh b/scripts/e2e/crestodian-rescue-docker.sh index d4de3b687ce..7ca6312a837 100755 --- a/scripts/e2e/crestodian-rescue-docker.sh +++ b/scripts/e2e/crestodian-rescue-docker.sh @@ -16,16 +16,14 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-rescue -docker_e2e_harness_mount_args OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 crestodian-rescue empty)" echo "Running in-container Crestodian rescue smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. set +e -docker run --rm \ +docker_e2e_run_with_harness \ --name "$CONTAINER_NAME" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\" diff --git a/scripts/e2e/lib/plugin-update/probe.mjs b/scripts/e2e/lib/plugin-update/probe.mjs new file mode 100644 index 00000000000..d021ed533f3 --- /dev/null +++ b/scripts/e2e/lib/plugin-update/probe.mjs @@ -0,0 +1,137 @@ +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; + +const home = os.homedir(); + +const readJson = (file) => { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + return {}; + } +}; + +const pluginRecordSnapshot = () => { + const config = readJson(openclawPath("openclaw.json")); + const index = readJson(openclawPath("plugins", "installs.json")); + const records = index.installRecords ?? index.records ?? config.plugins?.installs ?? {}; + const record = records["lossless-claw"] ?? records["@example/lossless-claw"]; + if (!record) { + throw new Error("missing plugin install record"); + } + const { source, spec, resolvedName, resolvedVersion, resolvedSpec, integrity, shasum } = record; + return { source, spec, resolvedName, resolvedVersion, resolvedSpec, integrity, shasum }; +}; + +function legacyCompat(version) { + const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:[-+].*)?/.exec(version); + const [year, month, day] = match?.slice(1, 4).map(Number) ?? []; + return ( + Boolean(match) && (year < 2026 || (year === 2026 && (month < 4 || (month === 4 && day <= 25)))) + ); +} + +function openclawPath(...parts) { + return path.join(home, ".openclaw", ...parts); +} + +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function seedInstallState() { + writeJson(openclawPath("extensions", "lossless-claw", "package.json"), { + name: "@example/lossless-claw", + version: "0.9.0", + }); + writeJson(process.env.OPENCLAW_CONFIG_PATH, { plugins: {} }); + writeJson(openclawPath("plugins", "installs.json"), { + version: 1, + warning: "DO NOT EDIT. This file is generated by OpenClaw plugin registry commands.", + hostContractVersion: "docker-e2e", + compatRegistryVersion: "docker-e2e", + migrationVersion: 1, + policyHash: "docker-e2e", + generatedAtMs: 1777118400000, + installRecords: { + "lossless-claw": { + source: "npm", + spec: "@example/lossless-claw@0.9.0", + installPath: "~/.openclaw/extensions/lossless-claw", + resolvedName: "@example/lossless-claw", + resolvedVersion: "0.9.0", + resolvedSpec: "@example/lossless-claw@0.9.0", + integrity: "sha512-same", + shasum: "same", + }, + }, + plugins: [], + diagnostics: [], + }); +} + +async function waitRegistry() { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (await registryHealthy()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("Local npm metadata registry failed to start"); +} + +function registryHealthy() { + return new Promise((resolve) => { + const req = http.get("http://127.0.0.1:4873/@example%2flossless-claw", (res) => { + resolve(res.statusCode === 200); + res.resume(); + }); + req.on("error", () => resolve(false)); + req.setTimeout(200, () => { + req.destroy(); + resolve(false); + }); + }); +} + +function assertSnapshot(beforePath) { + const before = readJson(beforePath); + const after = pluginRecordSnapshot(); + if (JSON.stringify(before) !== JSON.stringify(after)) { + throw new Error( + `plugin install record changed unexpectedly: ${JSON.stringify({ before, after })}`, + ); + } +} + +function assertOutput(logPath) { + const output = fs.readFileSync(logPath, "utf8"); + const failure = output.includes("Downloading @example/lossless-claw") + ? "Unexpected npm download/reinstall path" + : !output.includes("lossless-claw is up to date (0.9.0).") + ? "Expected up-to-date output missing" + : ""; + if (failure) { + throw new Error(`${failure}\n${output}`); + } +} + +const [command, arg] = process.argv.slice(2); +const commands = { + "legacy-compat": () => console.log(legacyCompat(arg || "") ? "1" : "0"), + seed: seedInstallState, + "wait-registry": waitRegistry, + snapshot: () => process.stdout.write(JSON.stringify(pluginRecordSnapshot(), null, 2)), + "assert-snapshot": () => assertSnapshot(arg), + "assert-output": () => assertOutput(arg), +}; +const run = commands[command]; +await ( + run ?? + (() => { + throw new Error(`Unknown plugin update probe command: ${command || "(missing)"}`); + }) +)(); diff --git a/scripts/e2e/lib/plugin-update/unchanged-scenario.sh b/scripts/e2e/lib/plugin-update/unchanged-scenario.sh index 2f8c0f0c455..1f498aeed7c 100644 --- a/scripts/e2e/lib/plugin-update/unchanged-scenario.sh +++ b/scripts/e2e/lib/plugin-update/unchanged-scenario.sh @@ -8,76 +8,20 @@ openclaw_e2e_install_package /tmp/openclaw-install.log "mounted OpenClaw package package_root="$(openclaw_e2e_package_root /tmp/npm-prefix)" entry="$(openclaw_e2e_package_entrypoint "$package_root")" +probe="scripts/e2e/lib/plugin-update/probe.mjs" package_version="$(node -p "require('$package_root/package.json').version")" -OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$( - PACKAGE_VERSION="$package_version" node -e 'const version = process.env.PACKAGE_VERSION || ""; const match = new RegExp("^(\\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");' -)" +OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$(node "$probe" legacy-compat "$package_version")" export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT export NPM_CONFIG_REGISTRY=http://127.0.0.1:4873 export PATH="/tmp/npm-prefix/bin:$PATH" -mkdir -p "$HOME/.openclaw/extensions/lossless-claw" -cat > "$HOME/.openclaw/extensions/lossless-claw/package.json" <<'JSON' -{ - "name": "@example/lossless-claw", - "version": "0.9.0" -} -JSON -cat > "$OPENCLAW_CONFIG_PATH" <<'JSON' -{ - "plugins": {} -} -JSON -mkdir -p "$HOME/.openclaw/plugins" -cat > "$HOME/.openclaw/plugins/installs.json" <<'JSON' -{ - "version": 1, - "warning": "DO NOT EDIT. This file is generated by OpenClaw plugin registry commands.", - "hostContractVersion": "docker-e2e", - "compatRegistryVersion": "docker-e2e", - "migrationVersion": 1, - "policyHash": "docker-e2e", - "generatedAtMs": 1777118400000, - "installRecords": { - "lossless-claw": { - "source": "npm", - "spec": "@example/lossless-claw@0.9.0", - "installPath": "~/.openclaw/extensions/lossless-claw", - "resolvedName": "@example/lossless-claw", - "resolvedVersion": "0.9.0", - "resolvedSpec": "@example/lossless-claw@0.9.0", - "integrity": "sha512-same", - "shasum": "same" - } - }, - "plugins": [], - "diagnostics": [] -} -JSON +node "$probe" seed node scripts/e2e/lib/plugin-update/registry-server.mjs >/tmp/openclaw-e2e-registry.log 2>&1 & registry_pid=$! trap 'kill "$registry_pid" >/dev/null 2>&1 || true' EXIT -registry_ready=0 -for _ in $(seq 1 50); do - if node --input-type=module -e ' - import http from "node:http"; - const req = http.get("http://127.0.0.1:4873/@example%2flossless-claw", (res) => { - process.exit(res.statusCode === 200 ? 0 : 1); - }); - req.on("error", () => process.exit(1)); - req.setTimeout(200, () => { - req.destroy(); - process.exit(1); - }); - '; then - registry_ready=1 - break - fi - sleep 0.1 -done -if [ "$registry_ready" -ne 1 ]; then +if ! node "$probe" wait-registry; then echo "Local npm metadata registry failed to start" cat /tmp/openclaw-e2e-registry.log || true exit 1 @@ -89,37 +33,7 @@ if [ "$OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT" != "1" ]; then fi plugin_update_timeout_seconds="${OPENCLAW_PLUGIN_UPDATE_TIMEOUT_SECONDS:-180}" -node --input-type=module > /tmp/plugin-update-before.json <<'NODE' -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -const readJson = (file) => { - try { - return JSON.parse(fs.readFileSync(file, "utf8")); - } catch { - return {}; - } -}; -const home = os.homedir(); -const config = readJson(path.join(home, ".openclaw", "openclaw.json")); -const index = readJson(path.join(home, ".openclaw", "plugins", "installs.json")); -const records = index.installRecords ?? index.records ?? config.plugins?.installs ?? {}; -const record = records["lossless-claw"] ?? records["@example/lossless-claw"]; -if (!record) { - throw new Error("missing seeded plugin install record"); -} -const snapshot = { - source: record.source, - spec: record.spec, - resolvedName: record.resolvedName, - resolvedVersion: record.resolvedVersion, - resolvedSpec: record.resolvedSpec, - integrity: record.integrity, - shasum: record.shasum, -}; -process.stdout.write(JSON.stringify(snapshot, null, 2)); -NODE +node "$probe" snapshot > /tmp/plugin-update-before.json set +e timeout "${plugin_update_timeout_seconds}s" node "$entry" plugins update @example/lossless-claw > /tmp/plugin-update-output.log 2>&1 @@ -143,48 +57,6 @@ if [ -n "$before_config_hash" ]; then fi fi -node --input-type=module <<'NODE' -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -const readJson = (file) => { - try { - return JSON.parse(fs.readFileSync(file, "utf8")); - } catch { - return {}; - } -}; -const home = os.homedir(); -const before = readJson("/tmp/plugin-update-before.json"); -const config = readJson(path.join(home, ".openclaw", "openclaw.json")); -const index = readJson(path.join(home, ".openclaw", "plugins", "installs.json")); -const records = index.installRecords ?? index.records ?? config.plugins?.installs ?? {}; -const record = records["lossless-claw"] ?? records["@example/lossless-claw"]; -if (!record) { - throw new Error("missing plugin install record after update"); -} -const after = { - source: record.source, - spec: record.spec, - resolvedName: record.resolvedName, - resolvedVersion: record.resolvedVersion, - resolvedSpec: record.resolvedSpec, - integrity: record.integrity, - shasum: record.shasum, -}; -if (JSON.stringify(before) !== JSON.stringify(after)) { - throw new Error(`plugin install record changed unexpectedly: ${JSON.stringify({ before, after })}`); -} -NODE -if grep -q "Downloading @example/lossless-claw" /tmp/plugin-update-output.log; then - echo "Unexpected npm download/reinstall path" - cat /tmp/plugin-update-output.log - exit 1 -fi -if ! grep -q "lossless-claw is up to date (0.9.0)." /tmp/plugin-update-output.log; then - echo "Expected up-to-date output missing" - cat /tmp/plugin-update-output.log - exit 1 -fi +node "$probe" assert-snapshot /tmp/plugin-update-before.json +node "$probe" assert-output /tmp/plugin-update-output.log cat /tmp/plugin-update-output.log diff --git a/scripts/e2e/npm-onboard-channel-agent-docker.sh b/scripts/e2e/npm-onboard-channel-agent-docker.sh index 0d87f53bd00..c816ce11bd2 100644 --- a/scripts/e2e/npm-onboard-channel-agent-docker.sh +++ b/scripts/e2e/npm-onboard-channel-agent-docker.sh @@ -38,17 +38,15 @@ prepare_package_tgz() { prepare_package_tgz docker_e2e_package_mount_args "$PACKAGE_TGZ" -docker_e2e_harness_mount_args run_log="$(docker_e2e_run_log npm-onboard-channel-agent)" OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 npm-onboard-channel-agent empty)" echo "Running npm tarball onboard/channel/agent Docker E2E ($CHANNEL)..." -if ! docker run --rm \ +if ! docker_e2e_run_with_harness \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e OPENCLAW_NPM_ONBOARD_CHANNEL="$CHANNEL" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' set -euo pipefail diff --git a/scripts/e2e/pi-bundle-mcp-tools-docker.sh b/scripts/e2e/pi-bundle-mcp-tools-docker.sh index f5bcb45b43e..bda39081537 100755 --- a/scripts/e2e/pi-bundle-mcp-tools-docker.sh +++ b/scripts/e2e/pi-bundle-mcp-tools-docker.sh @@ -16,16 +16,14 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" pi-bundle-mcp-tools -docker_e2e_harness_mount_args OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 pi-bundle-mcp-tools empty)" echo "Running in-container Pi bundle MCP tool availability smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. set +e -docker run --rm \ +docker_e2e_run_with_harness \ --name "$CONTAINER_NAME" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\" diff --git a/scripts/e2e/plugin-update-unchanged-docker.sh b/scripts/e2e/plugin-update-unchanged-docker.sh index 386bef49a20..9dec2ed4475 100755 --- a/scripts/e2e/plugin-update-unchanged-docker.sh +++ b/scripts/e2e/plugin-update-unchanged-docker.sh @@ -12,19 +12,17 @@ SKIP_BUILD="${OPENCLAW_PLUGIN_UPDATE_E2E_SKIP_BUILD:-0}" PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz plugin-update "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")" # Bare lanes mount the package artifact instead of baking app sources into the image. docker_e2e_package_mount_args "$PACKAGE_TGZ" -docker_e2e_harness_mount_args docker_e2e_build_or_reuse "$IMAGE_NAME" plugin-update "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD" OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 plugin-update empty)" echo "Running unchanged plugin update smoke..." -docker run --rm \ +docker_e2e_run_with_harness \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_SKIP_PROVIDERS=1 \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash scripts/e2e/lib/plugin-update/unchanged-scenario.sh diff --git a/scripts/e2e/session-runtime-context-docker.sh b/scripts/e2e/session-runtime-context-docker.sh index fb0e9eec10a..d0f68cc2129 100644 --- a/scripts/e2e/session-runtime-context-docker.sh +++ b/scripts/e2e/session-runtime-context-docker.sh @@ -17,15 +17,13 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" session-runtime-context -docker_e2e_harness_mount_args echo "Running session runtime context Docker E2E..." # Harness files are mounted read-only; the app under test comes from /app/dist. set +e -docker run --rm \ +docker_e2e_run_with_harness \ --name "$CONTAINER_NAME" \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc 'set -euo pipefail; tsx scripts/e2e/session-runtime-context-docker-client.ts' \ >"$RUN_LOG" 2>&1 diff --git a/scripts/lib/docker-e2e-package.sh b/scripts/lib/docker-e2e-package.sh index 6f699d31b29..c899462bb72 100644 --- a/scripts/lib/docker-e2e-package.sh +++ b/scripts/lib/docker-e2e-package.sh @@ -62,3 +62,8 @@ docker_e2e_package_mount_args() { docker_e2e_harness_mount_args() { DOCKER_E2E_HARNESS_ARGS=(-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" -v "$ROOT_DIR/scripts/lib:/app/scripts/lib:ro") } + +docker_e2e_run_with_harness() { + docker_e2e_harness_mount_args + docker run --rm "${DOCKER_E2E_HARNESS_ARGS[@]}" "$@" +} From b69b508d206b26030e7628ba1b6dd42e1842b9c8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 29 Apr 2026 00:29:09 -0700 Subject: [PATCH 3/6] docs(hooks/bundled/readme): cover session compaction and message events The bundled hooks README listed only command/agent/gateway events and ended with a stale "More event types coming soon (session lifecycle, agent errors, etc.)" line, but production code now triggers: - session:compact:before / session:compact:after via src/agents/pi-embedded-runner/compaction-hooks.ts - message:received via src/auto-reply/reply/dispatch-from-config.ts - message:sent via src/infra/outbound/deliver.ts Updates the "Event Types" list with the four real production event names, drops the stale coming-soon line, and aligns the InternalHookEvent interface example with the actual InternalHookEventType union (adds "message" and refreshes the action examples). HOOK.md authors that target session lifecycle or message routing now have a real surface to subscribe to instead of relying on tribal knowledge or the type definitions. --- src/hooks/bundled/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/hooks/bundled/README.md b/src/hooks/bundled/README.md index ad520d16d02..7eff826a226 100644 --- a/src/hooks/bundled/README.md +++ b/src/hooks/bundled/README.md @@ -170,8 +170,10 @@ Currently supported events: - **command:stop**: `/stop` command - **agent:bootstrap**: Before workspace bootstrap files are injected - **gateway:startup**: Gateway startup (after channels start) - -More event types coming soon (session lifecycle, agent errors, etc.). +- **session:compact:before**: Pre-compaction snapshot before the embedded runner rewrites session context +- **session:compact:after**: Post-compaction snapshot after the runner replaces session context +- **message:received**: Inbound channel message accepted for dispatch +- **message:sent**: Outbound channel message delivered (canonical payload only) ## Handler API @@ -179,8 +181,8 @@ Hook handlers receive an `InternalHookEvent` object: ```typescript interface InternalHookEvent { - type: "command" | "session" | "agent" | "gateway"; - action: string; // e.g., 'new', 'reset', 'stop' + type: "command" | "session" | "agent" | "gateway" | "message"; + action: string; // e.g., 'new', 'reset', 'stop', 'compact:before', 'received', 'sent' sessionKey: string; context: Record; timestamp: Date; From 275b0f00b079fe3f030bfc5db1b4e90ad020782a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 29 Apr 2026 00:30:13 -0700 Subject: [PATCH 4/6] ci: require maintainer permission for command reactions --- .../maintainer-command-reactions.yml | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/.github/workflows/maintainer-command-reactions.yml b/.github/workflows/maintainer-command-reactions.yml index 43ed3f06cb8..01225733617 100644 --- a/.github/workflows/maintainer-command-reactions.yml +++ b/.github/workflows/maintainer-command-reactions.yml @@ -40,12 +40,6 @@ jobs: script: | const comment = context.payload.comment; const issue = context.payload.issue; - const association = comment.author_association; - const maintainerAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); - if (!maintainerAssociations.has(association)) { - core.info(`Skipping non-maintainer command reaction for association ${association || "unknown"}.`); - return; - } if (!issue.pull_request) { core.info("Skipping command reaction because the comment is not on a pull request."); @@ -66,6 +60,28 @@ jobs: return; } + const maintainerPermissions = new Set(["admin", "maintain", "write"]); + let permission = "none"; + try { + const result = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: comment.user.login, + }); + permission = String(result.data.permission || "none").toLowerCase(); + } catch (error) { + if (error.status !== 404) { + core.info(`Could not resolve repository permission for ${comment.user.login}: ${error.message}`); + } + } + + if (!maintainerPermissions.has(permission)) { + core.info( + `Skipping non-maintainer command reaction for ${comment.user.login}; repository permission is ${permission}.`, + ); + return; + } + async function react(content) { try { await github.rest.reactions.createForIssueComment({ From a31342ab6b572978f0ba76f15306236ce8844bc2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 08:33:13 +0100 Subject: [PATCH 5/6] fix(ollama): preserve aborts with stream timeouts --- CHANGELOG.md | 1 + extensions/ollama/src/stream-runtime.test.ts | 28 +++++++++++++++++++- extensions/ollama/src/stream.ts | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b80fe19ff7a..5965e9ddfc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Ollama: compose caller abort signals with guarded-fetch timeouts for native `/api/chat` streams, so `/stop` and early cancellation still interrupt local Ollama requests that also carry provider timeout budgets. Refs #74133. Thanks @obviyus. - CLI/logs: fall back to the configured Gateway file log when implicit loopback Gateway connections close or time out before or during `logs.tail`, so `openclaw logs` still works while diagnosing local-model Gateway disconnects. Refs #74078. Thanks @sakalaboator. - MCP/plugins: stringify non-array plugin tool results with chat-content coercion instead of default object stringification, so MCP callers receive useful JSON/text content from plugin tools. Thanks @vincentkoc. - Active Memory/QMD: run QMD boot refresh through a one-shot subprocess path, preserve interactive file watching, and align watcher dependency/build ignores with QMD's scanner so gateway startup avoids arming long-lived QMD watchers. Thanks @codexGW. diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index 840e14f68ca..8206a96ba02 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -23,6 +23,7 @@ type GuardedFetchCall = { url: string; init?: RequestInit; policy?: unknown; + signal?: AbortSignal; timeoutMs?: number; auditContext?: string; }; @@ -333,6 +334,29 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => { ); }); + it("passes caller abort signals at guard level when a timeout is present", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}', + ], + async (fetchMock) => { + const signal = new AbortController().signal; + const stream = await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + options: { signal, timeoutMs: 123_456 }, + }); + + await collectStreamEvents(stream); + + const request = getGuardedFetchCall(fetchMock); + expect(request.timeoutMs).toBe(123_456); + expect(request.signal).toBe(signal); + expect(request.init?.signal).toBeUndefined(); + }, + ); + }); + it("maps native Ollama max thinking to think=high on the wire", async () => { await withMockNdjsonFetch( [ @@ -1018,6 +1042,7 @@ async function createOllamaTestStream(params: { maxTokens?: number; temperature?: number; signal?: AbortSignal; + timeoutMs?: number; headers?: Record; }; }) { @@ -1401,8 +1426,9 @@ describe("createOllamaStreamFn", () => { const request = getGuardedFetchCall(fetchMock); expect(request.url).toBe("http://ollama-host:11434/api/chat"); expect(request.auditContext).toBe("ollama-stream.chat"); + expect(request.signal).toBe(signal); const requestInit = request.init ?? {}; - expect(requestInit.signal).toBe(signal); + expect(requestInit.signal).toBeUndefined(); if (typeof requestInit.body !== "string") { throw new Error("Expected string request body"); } diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index b5fcd916f7e..bbd6b6e11c0 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -1000,9 +1000,9 @@ export function createOllamaStreamFn( method: "POST", headers, body: JSON.stringify(body), - signal: options?.signal, }, policy: ssrfPolicy, + ...(options?.signal ? { signal: options.signal } : {}), timeoutMs: resolveOllamaRequestTimeoutMs( model, options as { requestTimeoutMs?: unknown; timeoutMs?: unknown } | undefined, From f8faf40a9eab756d79599f7e4a3fc760ed3ec09d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 08:36:00 +0100 Subject: [PATCH 6/6] fix: exclude test support from raw fetch guard --- scripts/check-no-raw-channel-fetch.mjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index 1d8e9c0c02c..84f9fa2aef1 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -18,8 +18,6 @@ const allowedRawFetchCallsites = new Set([ bundledPluginCallsite("bluebubbles", "src/types.ts", 204), bundledPluginCallsite("browser", "src/browser/cdp.helpers.ts", 268), bundledPluginCallsite("browser", "src/browser/client-fetch.ts", 192), - bundledPluginCallsite("browser", "src/browser/test-fetch.ts", 24), - bundledPluginCallsite("browser", "src/browser/test-fetch.ts", 27), bundledPluginCallsite("chutes", "models.ts", 535), bundledPluginCallsite("chutes", "models.ts", 542), bundledPluginCallsite("discord", "src/monitor/gateway-plugin.ts", 417), @@ -96,6 +94,7 @@ export async function main() { sourceRoots, extraTestSuffixes: [".browser.test.ts", ".node.test.ts"], findCallLines: findRawFetchCallLines, + skipRelativePath: (relPath) => relPath.includes("/test-support/"), allowCallsite: (callsite) => allowedRawFetchCallsites.has(callsite), header: "Found raw fetch() usage in channel/plugin runtime sources outside allowlist:", footer: "Use fetchWithSsrFGuard() or existing channel/plugin SDK wrappers for network calls.",