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({ 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/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 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, 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.", 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[@]}" "$@" +} 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;