From 48acdd3d85eaf4c1f6fbee557e38a483f5e33944 Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Tue, 19 May 2026 21:05:37 +0530 Subject: [PATCH 01/28] harden update restart script creation [AI] (#84088) * fix: harden update restart script creation * docs: add changelog entry for PR merge --- CHANGELOG.md | 1 + src/cli/update-cli/restart-helper.test.ts | 61 +++++++++++++++++++++++ src/cli/update-cli/restart-helper.ts | 17 +++++-- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96058bf18e6e..c8603944d92d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- harden update restart script creation [AI]. (#84088) Thanks @pgondhi987. - Docker: keep the bundled Codex plugin in official release image keep lists so the default OpenAI agent harness remains available after Docker pruning. Fixes #83613. (#83626) Thanks @YuanHanzhong. - CLI/channels: preserve the first line of `openclaw channels logs` output when the rolling tail window starts exactly on a line boundary, mirroring the already-fixed `readLogSlice` behavior in `src/logging/log-tail.ts`. - Control UI: treat terminal session status as authoritative over stale active-run flags so completed terminal runs stop showing abort/live UI. (#84057) diff --git a/src/cli/update-cli/restart-helper.test.ts b/src/cli/update-cli/restart-helper.test.ts index 23c099c8c14b..106c5cb87957 100644 --- a/src/cli/update-cli/restart-helper.test.ts +++ b/src/cli/update-cli/restart-helper.test.ts @@ -34,6 +34,11 @@ describe("restart-helper", () => { throw error; } }); + await fs.rmdir(path.dirname(scriptPath)).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + }); } async function makeTempDir(prefix: string) { @@ -135,9 +140,63 @@ exit 0 expect(content).toContain("systemctl --user restart 'openclaw-gateway.service'"); // Script should self-cleanup expect(content).toContain('rm -f "$0"'); + expect(content).toContain('rmdir "$script_dir" 2>/dev/null || true'); await cleanupScript(scriptPath); }); + it("creates restart scripts in a private temp directory with exclusive creation", async () => { + Object.defineProperty(process, "platform", { value: "linux" }); + const timestamp = 1_727_201_234_567; + const oldCandidatePath = path.join(os.tmpdir(), `openclaw-restart-${timestamp}.sh`); + const victimDir = await makeTempDir("openclaw-restart-helper-victim-"); + const victimPath = path.join(victimDir, "restart.sh"); + await fs.rm(oldCandidatePath, { force: true }); + await fs.writeFile(victimPath, "preexisting script\n", "utf-8"); + + let candidateIsSymlink = false; + try { + await fs.symlink(victimPath, oldCandidatePath); + candidateIsSymlink = true; + } catch { + await fs.writeFile(oldCandidatePath, "preexisting script\n", { flag: "wx" }); + } + + const dateSpy = vi.spyOn(Date, "now").mockReturnValue(timestamp); + const writeFileSpy = vi.spyOn(fs, "writeFile"); + + try { + const { scriptPath } = await prepareAndReadScript({ + OPENCLAW_PROFILE: "default", + }); + const scriptDir = path.dirname(scriptPath); + const relativeScriptDir = path.relative(os.tmpdir(), scriptDir); + + expect(scriptPath).not.toBe(oldCandidatePath); + expect(scriptDir).not.toBe(os.tmpdir()); + expect(relativeScriptDir).not.toBe(""); + expect(relativeScriptDir.startsWith("..")).toBe(false); + expect(path.isAbsolute(relativeScriptDir)).toBe(false); + expect(path.basename(scriptDir)).toMatch(/^openclaw-restart-/); + expect(writeFileSpy).toHaveBeenLastCalledWith( + scriptPath, + expect.any(String), + expect.objectContaining({ flag: "wx", mode: 0o755 }), + ); + await expect(fs.readFile(victimPath, "utf-8")).resolves.toBe("preexisting script\n"); + if (!candidateIsSymlink) { + await expect(fs.readFile(oldCandidatePath, "utf-8")).resolves.toBe( + "preexisting script\n", + ); + } + await cleanupScript(scriptPath); + } finally { + dateSpy.mockRestore(); + writeFileSpy.mockRestore(); + await fs.rm(oldCandidatePath, { force: true }); + await fs.rm(victimDir, { recursive: true, force: true }); + } + }); + it("uses OPENCLAW_SYSTEMD_UNIT override for systemd scripts", async () => { Object.defineProperty(process, "platform", { value: "linux" }); const { scriptPath, content } = await prepareAndReadScript({ @@ -203,6 +262,7 @@ exit 1 expect(content).toContain("launchctl bootstrap 'gui/501'"); expect(content).toContain("Bootstrap loads RunAtLoad agents"); expect(content).toContain('rm -f "$0"'); + expect(content).toContain('rmdir "$script_dir" 2>/dev/null || true'); await cleanupScript(scriptPath); }); @@ -379,6 +439,7 @@ exit 0 expect(content).toContain("openclaw restart launched startup fallback"); expectWindowsRestartWaitOrdering(content); expect(content).toContain('del "%~f0" >nul 2>&1'); + expect(content).toContain('rmdir "%OPENCLAW_RESTART_SCRIPT_DIR%" >nul 2>&1'); await cleanupScript(scriptPath); }); diff --git a/src/cli/update-cli/restart-helper.ts b/src/cli/update-cli/restart-helper.ts index 7e76228f6278..efa96090cd8c 100644 --- a/src/cli/update-cli/restart-helper.ts +++ b/src/cli/update-cli/restart-helper.ts @@ -68,7 +68,6 @@ export async function prepareRestartScript( env: NodeJS.ProcessEnv = process.env, gatewayPort: number = DEFAULT_GATEWAY_PORT, ): Promise { - const tmpDir = os.tmpdir(); const timestamp = Date.now(); const platform = process.platform; @@ -110,8 +109,10 @@ else fi fi # Self-cleanup +script_dir=$(dirname "$0") exec 3>&- rm -f "$0" +rmdir "$script_dir" 2>/dev/null || true exit "$status" `; } else if (platform === "darwin") { @@ -157,7 +158,9 @@ else printf '[%s] openclaw restart failed source=update status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2 fi # Self-cleanup (log is retained under the OpenClaw state logs directory). +script_dir=$(dirname "$0") rm -f "$0" +rmdir "$script_dir" 2>/dev/null || true exit "$status" `; } else if (platform === "win32") { @@ -177,9 +180,11 @@ REM Keep this as a cmd wrapper so Group Policy script execution policies REM cannot block the update restart handoff before schtasks.exe runs. setlocal set "OPENCLAW_RESTART_SCRIPT=%~f0" +set "OPENCLAW_RESTART_SCRIPT_DIR=%~dp0." powershell -NoProfile -ExecutionPolicy Bypass -Command "$p=$env:OPENCLAW_RESTART_SCRIPT; $s=Get-Content -Raw -LiteralPath $p; $m='# POWERSHELL'; $i=$s.IndexOf($m); if ($i -lt 0) { exit 1 }; Invoke-Expression $s.Substring($i)" set "status=%ERRORLEVEL%" del "%~f0" >nul 2>&1 +rmdir "%OPENCLAW_RESTART_SCRIPT_DIR%" >nul 2>&1 exit /b %status% # POWERSHELL # Wait briefly to ensure file locks are released after update. @@ -370,8 +375,14 @@ exit $status return null; } - const scriptPath = path.join(tmpDir, filename); - await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 }); + const scriptDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-restart-")); + const scriptPath = path.join(scriptDir, filename); + try { + await fs.writeFile(scriptPath, scriptContent, { mode: 0o755, flag: "wx" }); + } catch (error) { + await fs.rm(scriptDir, { recursive: true, force: true }).catch(() => {}); + throw error; + } return scriptPath; } catch { // If we can't write the script, we'll fall back to the standard restart method From 9e9feb52f43c5b20975fc64263e1a7eeabb726b0 Mon Sep 17 00:00:00 2001 From: yujiawei <123054@qq.com> Date: Tue, 19 May 2026 04:08:46 +0000 Subject: [PATCH 02/28] fix(llm-idle-timeout): honor models.providers..timeoutSeconds for cloud providers The schema.help text for `models.providers.*.timeoutSeconds` documents the key as the user-facing knob for "slow local or self-hosted model servers". In practice the option is also the only configurable lever for the LLM idle/first-token watchdog. However `resolveLlmIdleTimeoutMs` was still running the explicit provider timeout through `clampImplicitTimeoutMs`, clamping it back down to the implicit ~120s `DEFAULT_LLM_IDLE_TIMEOUT_MS` ceiling for any non-cron, non-local provider. Consequence (matches #77744 and #78361): - User sets `models.providers.llamacpp.timeoutSeconds: 14400` (or 600 for a slow Gemini/Opus turn with a large tool payload). - Hot reload accepts the value, runtime resolves `modelRequestTimeoutMs = 14_400_000`. - Idle watchdog still trips at ~120s with "LLM idle timeout (120s): no response from model", aborting an otherwise-healthy upstream that is mid-prefill or buffering thinking tokens. Fix: when the caller passes an explicit `modelRequestTimeoutMs` (sourced from `models.providers..timeoutSeconds` / `model.requestTimeoutMs`), treat it as a deliberate ceiling for cloud providers too. The run-timeout / agent-timeout bounds still apply via `timeoutBounds`, so a shorter explicit run timeout always wins. The implicit default watchdog still kicks in when the user has not set a provider timeout, preserving the network-silence-as-hang guard for default configs. Updated the two corresponding test cases that asserted the old clamp-on-cloud behavior; all 71 tests in `llm-idle-timeout.test.ts` and the wider 430-test `src/agents/pi-embedded-runner/run/` lane pass. Schema help text refreshed to call out that the same knob raises the idle watchdog ceiling. Refs: #77744, #78361 --- .../run/llm-idle-timeout.test.ts | 11 ++++++----- .../pi-embedded-runner/run/llm-idle-timeout.ts | 15 +++++++++++---- src/config/schema.help.ts | 2 +- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts b/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts index c86a89958e14..fad1b90de0eb 100644 --- a/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts +++ b/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts @@ -43,13 +43,14 @@ describe("resolveLlmIdleTimeoutMs", () => { expect(resolveLlmIdleTimeoutMs({ runTimeoutMs: 2_147_000_000 })).toBe(0); }); - it("caps remote provider request timeouts at the default idle watchdog", () => { - expect(resolveLlmIdleTimeoutMs({ modelRequestTimeoutMs: 300_000 })).toBe( - DEFAULT_LLM_IDLE_TIMEOUT_MS, - ); + it("honors an explicit models.providers..timeoutSeconds for cloud providers (#77744, #78361)", () => { + // models.providers..timeoutSeconds is documented as the user-facing + // knob to extend slow model responses. The idle watchdog must respect it + // instead of clamping back to DEFAULT_LLM_IDLE_TIMEOUT_MS. + expect(resolveLlmIdleTimeoutMs({ modelRequestTimeoutMs: 300_000 })).toBe(300_000); }); - it("uses remote provider request timeouts when shorter than the default idle watchdog", () => { + it("honors short explicit provider request timeouts", () => { expect(resolveLlmIdleTimeoutMs({ modelRequestTimeoutMs: 30_000 })).toBe(30_000); }); diff --git a/src/agents/pi-embedded-runner/run/llm-idle-timeout.ts b/src/agents/pi-embedded-runner/run/llm-idle-timeout.ts index 49f82c929bdf..d039542f9063 100644 --- a/src/agents/pi-embedded-runner/run/llm-idle-timeout.ts +++ b/src/agents/pi-embedded-runner/run/llm-idle-timeout.ts @@ -154,11 +154,18 @@ export function resolveLlmIdleTimeoutMs(params?: { Number.isFinite(modelRequestTimeoutMs) && modelRequestTimeoutMs > 0 ) { + // `modelRequestTimeoutMs` is wired from `models.providers..timeoutSeconds`, + // which is an explicit per-provider opt-in. The schema help describes it as + // "Use this for slow local or self-hosted model servers instead of changing + // global agent timeouts." so we honor it as a deliberate ceiling rather + // than clamping it back down to the implicit `DEFAULT_LLM_IDLE_TIMEOUT_MS` + // network-silence-as-hang guard. Without this, users hitting #77744 / + // #78361 set provider timeoutSeconds to e.g. 600s, observe the value is + // accepted and hot-reloaded, yet the idle watchdog still aborts at 120s. + // The agent/run timeoutBounds still apply so an explicit shorter run + // timeout always wins. const boundedTimeoutMs = Math.min(modelRequestTimeoutMs, ...timeoutBounds); - if (params?.trigger === "cron" || isLocalProvider) { - return clampTimeoutMs(boundedTimeoutMs); - } - return clampImplicitTimeoutMs(boundedTimeoutMs); + return clampTimeoutMs(boundedTimeoutMs); } if (typeof runTimeoutMs === "number" && Number.isFinite(runTimeoutMs) && runTimeoutMs > 0) { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index f176a2fb62d6..edadc0c7c6a6 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -952,7 +952,7 @@ export const FIELD_HELP: Record = { "models.providers.*.maxTokens": "Default maximum output token budget applied to models under this provider when a model entry does not set maxTokens.", "models.providers.*.timeoutSeconds": - "Optional per-provider model request timeout in seconds. Applies to provider HTTP fetches, including connect, headers, body, and total request abort handling. Use this for slow local or self-hosted model servers instead of changing global agent timeouts.", + "Optional per-provider model request timeout in seconds. Applies to provider HTTP fetches, including connect, headers, body, and total request abort handling, and also raises the LLM idle/stream watchdog ceiling for this provider above the implicit ~120s default. Use this for slow local or self-hosted model servers, or for cloud providers that buffer reasoning tokens silently on the wire (Gemini preview, large-tool-payload Claude/Opus), instead of changing global agent timeouts.", "models.providers.*.injectNumCtxForOpenAICompat": "Controls whether OpenClaw injects `options.num_ctx` for Ollama providers configured with the OpenAI-compatible adapter (`openai-completions`). Default is true. Set false only if your proxy/upstream rejects unknown `options` payload fields.", "models.providers.*.params": From 6899eff155ec321c3aca98ecf9c3f309d78d123d Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 19 May 2026 17:13:36 +0100 Subject: [PATCH 03/28] test: cover provider timeout bare hostnames --- .../pi-embedded-runner/run/llm-idle-timeout.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts b/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts index fad1b90de0eb..ff797f1c4731 100644 --- a/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts +++ b/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts @@ -50,6 +50,15 @@ describe("resolveLlmIdleTimeoutMs", () => { expect(resolveLlmIdleTimeoutMs({ modelRequestTimeoutMs: 300_000 })).toBe(300_000); }); + it("honors explicit provider timeouts for self-hosted bare hostnames", () => { + expect( + resolveLlmIdleTimeoutMs({ + model: { baseUrl: "http://cerebro-mac:8080/v1" }, + modelRequestTimeoutMs: 600_000, + }), + ).toBe(600_000); + }); + it("honors short explicit provider request timeouts", () => { expect(resolveLlmIdleTimeoutMs({ modelRequestTimeoutMs: 30_000 })).toBe(30_000); }); From 78d226bb3b6933c28a32e12bfa69435d8d7237d1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 19 May 2026 17:14:02 +0100 Subject: [PATCH 04/28] docs: add provider timeout changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8603944d92d..57f6307444e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Memory/search: close local embedding providers when active-memory searches time out so pending local model loads and embedding contexts are aborted and released. (#83858) Thanks @brokemac79. - Agents: include bounded trajectory queued-writer diagnostics in `pi-trajectory-flush` timeout warnings so flush stalls show pending writes, queued bytes, and append state. Fixes #82961. (#82962) Thanks @galiniliev. - Agents/subagents: recover stale completion announces by retrying unsupported transcript-wait wakes without transcript waiting and forcing a message-tool handoff when the requester run is already stale. Fixes #83699. (#83700) Thanks @galiniliev. +- Agents: honor explicit `models.providers..timeoutSeconds` values above the default idle watchdog for cloud and self-hosted providers, so long first-token waits no longer fall back at ~120s when the provider timeout is higher. (#83979) Thanks @yujiawei. - Agents/subagents: skip stale embedded-run wake probes for dormant completion requesters, so late subagent completions go straight to requester-agent/direct handoff instead of producing `reason=no_active_run` queue noise. (#82964) Thanks @galiniliev. - CLI: retry config snapshot reads after a transient failure so one rejected read no longer poisons later commands in the same process. (#83931) Thanks @honor2030. - Media: decode URL path basenames before using them as remote media fallback filenames, so files like `My%20Report.pdf` are surfaced as `My Report.pdf`. Fixes #84050. (#84052) Thanks @jbetala7. From edcf862da5d02d484678935f5be36115317eda09 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 19 May 2026 22:59:28 +0530 Subject: [PATCH 05/28] fix(mantis): finish interrupted telegram proof sessions --- .../mantis-telegram-desktop-proof.yml | 28 ++++++-- scripts/e2e/telegram-user-crabbox-proof.ts | 68 +++++++++++-------- ...is-telegram-desktop-proof-workflow.test.ts | 19 ++++++ 3 files changed, 80 insertions(+), 35 deletions(-) diff --git a/.github/workflows/mantis-telegram-desktop-proof.yml b/.github/workflows/mantis-telegram-desktop-proof.yml index 95c72f2dc436..ad4cb00a072b 100644 --- a/.github/workflows/mantis-telegram-desktop-proof.yml +++ b/.github/workflows/mantis-telegram-desktop-proof.yml @@ -484,6 +484,7 @@ jobs: - name: Release leaked Telegram proof leases if: ${{ always() }} env: + CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }} OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }} OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }} shell: bash @@ -492,17 +493,34 @@ jobs: if [[ ! -d .artifacts/qa-e2e ]]; then exit 0 fi + status=0 + mapfile -d '' session_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/session.json' -type f -print0) + for session_file in "${session_files[@]}"; do + lease_file="${session_file%/session.json}/.session/lease.json" + if [[ ! -f "$lease_file" ]]; then + continue + fi + if ! sudo -u codex env \ + OPENCLAW_QA_CONVEX_SECRET_CI="$OPENCLAW_QA_CONVEX_SECRET_CI" \ + OPENCLAW_QA_CONVEX_SITE_URL="$OPENCLAW_QA_CONVEX_SITE_URL" \ + OPENCLAW_TELEGRAM_USER_CRABBOX_BIN=/usr/local/bin/crabbox \ + OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER="$CRABBOX_PROVIDER" \ + node --import tsx "$GITHUB_WORKSPACE/scripts/e2e/telegram-user-crabbox-proof.ts" \ + finish --session "$session_file" --preview-crop telegram-window; then + status=1 + fi + done mapfile -d '' lease_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/.session/lease.json' -type f -print0) - if [[ "${#lease_files[@]}" -eq 0 ]]; then - exit 0 - fi for lease_file in "${lease_files[@]}"; do - sudo -u codex env \ + if ! sudo -u codex env \ OPENCLAW_QA_CONVEX_SECRET_CI="$OPENCLAW_QA_CONVEX_SECRET_CI" \ OPENCLAW_QA_CONVEX_SITE_URL="$OPENCLAW_QA_CONVEX_SITE_URL" \ node --import tsx "$GITHUB_WORKSPACE/scripts/e2e/telegram-user-credential.ts" \ - release --lease-file "$lease_file" + release --lease-file "$lease_file"; then + status=1 + fi done + exit "$status" - name: Inspect Mantis evidence manifest id: inspect diff --git a/scripts/e2e/telegram-user-crabbox-proof.ts b/scripts/e2e/telegram-user-crabbox-proof.ts index f7928e5b970b..1a08a7d5576c 100644 --- a/scripts/e2e/telegram-user-crabbox-proof.ts +++ b/scripts/e2e/telegram-user-crabbox-proof.ts @@ -847,38 +847,46 @@ async function startLocalSutDaemon(params: { const requestLog = path.join(params.outputDir, "mock-openai-requests.ndjson"); const mockLog = path.join(params.outputDir, "mock-openai.log"); const gatewayLog = path.join(params.outputDir, "gateway.log"); - const mockPid = spawnDaemon({ - command: "node", - args: ["scripts/e2e/mock-openai-server.mjs"], - cwd: params.repoRoot, - env: mockServerEnv({ ...params, requestLog }), - logPath: mockLog, - }); - if (!mockPid) { - throw new Error("mock-openai did not start."); - } - await waitForLog(mockLog, /mock-openai listening/u, "mock-openai", 10_000); + let mockPid: number | undefined; + let gatewayPid: number | undefined; + try { + mockPid = spawnDaemon({ + command: "node", + args: ["scripts/e2e/mock-openai-server.mjs"], + cwd: params.repoRoot, + env: mockServerEnv({ ...params, requestLog }), + logPath: mockLog, + }); + if (!mockPid) { + throw new Error("mock-openai did not start."); + } + await waitForLog(mockLog, /mock-openai listening/u, "mock-openai", 10_000); - const gatewayPid = spawnDaemon({ - command: "pnpm", - args: ["openclaw", "gateway", "--port", String(params.gatewayPort)], - cwd: params.repoRoot, - env: gatewayEnv({ ...config, sutToken: params.sutToken }), - logPath: gatewayLog, - }); - if (!gatewayPid) { - throw new Error("gateway did not start."); + gatewayPid = spawnDaemon({ + command: "pnpm", + args: ["openclaw", "gateway", "--port", String(params.gatewayPort)], + cwd: params.repoRoot, + env: gatewayEnv({ ...config, sutToken: params.sutToken }), + logPath: gatewayLog, + }); + if (!gatewayPid) { + throw new Error("gateway did not start."); + } + await waitForLog(gatewayLog, /\[gateway\] ready/u, "gateway", 60_000); + return { + ...config, + drained, + gatewayLog, + gatewayPid, + mockLog, + mockPid, + requestLog, + }; + } catch (error) { + killPidTree(gatewayPid); + killPidTree(mockPid); + throw error; } - await waitForLog(gatewayLog, /\[gateway\] ready/u, "gateway", 60_000); - return { - ...config, - drained, - gatewayLog, - gatewayPid, - mockLog, - mockPid, - requestLog, - }; } function extractLeaseId(output: string) { diff --git a/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts b/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts index 348c39b17bd0..e214284d79a0 100644 --- a/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts +++ b/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts @@ -155,13 +155,32 @@ describe("Mantis Telegram Desktop proof workflow", () => { expect(cleanupStep.env?.OPENCLAW_QA_CONVEX_SITE_URL).toContain( "secrets.OPENCLAW_QA_CONVEX_SITE_URL", ); + expect(cleanupStep.env?.CRABBOX_PROVIDER).toContain( + "needs.resolve_request.outputs.crabbox_provider", + ); expect(cleanupStep.run).toContain("sudo find .artifacts/qa-e2e"); + expect(cleanupStep.run).toContain("*/telegram-user-crabbox/*/session.json"); + expect(cleanupStep.run).toContain("telegram-user-crabbox-proof.ts"); + expect(cleanupStep.run).toContain( + 'finish --session "$session_file" --preview-crop telegram-window', + ); expect(cleanupStep.run).toContain("*/telegram-user-crabbox/*/.session/lease.json"); expect(cleanupStep.run).toContain("telegram-user-credential.ts"); expect(cleanupStep.run).toContain("release --lease-file"); + expect(cleanupStep.run).toContain("status=1"); expect(cleanupStep.run).toContain("sudo -u codex env"); }); + it("cleans partially started proof daemons when local SUT startup fails", () => { + const proofScript = readFileSync(PROOF_SCRIPT, "utf8"); + + expect(proofScript).toContain("let mockPid: number | undefined;"); + expect(proofScript).toContain("let gatewayPid: number | undefined;"); + expect(proofScript).toContain("killPidTree(gatewayPid);"); + expect(proofScript).toContain("killPidTree(mockPid);"); + expect(proofScript).toContain("throw error;"); + }); + it("uses the OpenClaw Mantis mention as the comment trigger", () => { const workflow = readFileSync(WORKFLOW, "utf8"); const liveWorkflow = readFileSync(LIVE_WORKFLOW, "utf8"); From 323c9760d32780276abefb2020cea85da426da11 Mon Sep 17 00:00:00 2001 From: samzong Date: Wed, 20 May 2026 01:50:36 +0800 Subject: [PATCH 06/28] [Docs] Document gateway benchmark probes (#83866) Summary: - The PR updates `docs/cli/gateway.md` and `docs/reference/test.md` to document Gateway startup/restart benchmark prerequisites, commands, case IDs, probes, output semantics, and platform limits. - Reproducibility: not applicable. as a runtime bug; docs correctness is source-checkable against the benchmar ... ipts, and readiness source. The current PR head corrected the earlier startup-hook readiness wording issue. Automerge notes: - PR branch already contained follow-up commit before automerge: docs(gateway): correct benchmark readiness wording Validation: - ClawSweeper review passed for head 5bd0f6c46305f04635555c93df61e7e2b80f44fa. - Required merge gates passed before the squash merge. Prepared head SHA: 5bd0f6c46305f04635555c93df61e7e2b80f44fa Review: https://github.com/openclaw/openclaw/pull/83866#issuecomment-4483820005 Co-authored-by: samzong Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com> --- docs/cli/gateway.md | 4 +- docs/reference/test.md | 88 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index e93fb1e6a7fd..be402be9828c 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -127,7 +127,9 @@ Inline `--password` can be exposed in local process listings. Prefer `--password - Set `OPENCLAW_GATEWAY_STARTUP_TRACE=1` to log phase timings during Gateway startup, including per-phase `eventLoopMax` delay and plugin lookup-table timings for installed-index, manifest registry, startup planning, and owner-map work. - Set `OPENCLAW_GATEWAY_RESTART_TRACE=1` to log restart-scoped `restart trace:` lines for restart signal handling, active-work drain, shutdown phases, next start, ready timing, and memory metrics. - Set `OPENCLAW_DIAGNOSTICS=timeline` with `OPENCLAW_DIAGNOSTICS_TIMELINE_PATH=` to write a best-effort JSONL startup diagnostics timeline for external QA harnesses. You can also enable the flag with `diagnostics.flags: ["timeline"]` in config; the path is still env-provided. Add `OPENCLAW_DIAGNOSTICS_EVENT_LOOP=1` to include event-loop samples. -- Run `pnpm test:startup:gateway -- --runs 5 --warmup 1` to benchmark Gateway startup. The benchmark records first process output, `/healthz`, `/readyz`, startup trace timings, event-loop delay, and plugin lookup-table timing details. +- Run `pnpm build` first, then `pnpm test:startup:gateway -- --runs 5 --warmup 1` to benchmark Gateway startup against the built CLI entry. The benchmark records first process output, `/healthz`, `/readyz`, startup trace timings, event-loop delay, and plugin lookup-table timing details. +- Run `pnpm build` first, then `pnpm test:restart:gateway -- --case skipChannels --runs 1 --restarts 5` to benchmark in-process Gateway restart against the built CLI entry on macOS or Linux. The restart benchmark uses SIGUSR1, enables both startup and restart traces in the child process, and records next `/healthz`, next `/readyz`, downtime, ready timing, CPU, RSS, and restart trace metrics. +- Treat `/healthz` as liveness and `/readyz` as usable readiness. Trace lines and benchmark output are for owner attribution; do not treat one trace span or one sample as a complete performance conclusion. ## Query a running Gateway diff --git a/docs/reference/test.md b/docs/reference/test.md index 6996aefa3aa9..a27e38dfac99 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -124,6 +124,94 @@ Checked-in fixture: - Refresh with `pnpm test:startup:bench:update` - Compare current results against the fixture with `pnpm test:startup:bench:check` +## Gateway startup bench + +Script: [`scripts/bench-gateway-startup.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/bench-gateway-startup.ts) + +The benchmark defaults to the built CLI entry at `dist/entry.js`; run +`pnpm build` before using the package-script commands. To measure the source +runner instead, pass `--entry scripts/run-node.mjs` and keep those results +separate from built-entry baselines. + +Usage: + +- `pnpm test:startup:gateway -- --runs 5 --warmup 1` +- `pnpm test:startup:gateway -- --case default --runs 10 --warmup 1` +- `pnpm test:startup:gateway -- --case skipChannels --case fiftyPlugins --runs 5` +- `node --import tsx scripts/bench-gateway-startup.ts --case default --runs 5 --output .artifacts/gateway-startup.json` +- `node --import tsx scripts/bench-gateway-startup.ts --case default --runs 3 --cpu-prof-dir .artifacts/gateway-startup-cpu` + +Case ids: + +- `default`: normal Gateway startup. +- `skipChannels`: Gateway startup with channel startup skipped. +- `oneInternalHook`: one configured internal hook. +- `allInternalHooks`: all internal hooks. +- `fiftyPlugins`: 50 manifest plugins. +- `fiftyStartupLazyPlugins`: 50 startup-lazy manifest plugins. + +Output includes first process output, `/healthz`, `/readyz`, HTTP listen log time, +Gateway ready log time, CPU time, CPU core ratio, max RSS, heap, startup trace +metrics, event-loop delay, and plugin lookup-table detail metrics. The script +enables `OPENCLAW_GATEWAY_STARTUP_TRACE=1` in the child Gateway environment. + +Read `/healthz` as liveness: the HTTP server can answer. Read `/readyz` as +usable readiness: startup plugin sidecars, channels, and ready-critical +post-attach work have settled. Gateway startup hooks are dispatched +asynchronously and are not part of the readiness guarantee. Ready log time is the +Gateway's internal ready log timestamp; it is useful for process-side +attribution but is not a substitute for the external `/readyz` probe. + +Use JSON output or `--output` when comparing changes. Use `--cpu-prof-dir` only +after the trace output points at import, compile, or CPU-bound work that cannot +be explained from phase timings alone. Do not compare source-runner results with +built `dist/entry.js` results as the same baseline. + +## Gateway restart bench + +Script: [`scripts/bench-gateway-restart.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/bench-gateway-restart.ts) + +The restart benchmark is supported on macOS and Linux only. It uses SIGUSR1 for +in-process restarts and fails immediately on Windows. + +The benchmark defaults to the built CLI entry at `dist/entry.js`; run +`pnpm build` before using the package-script commands. To measure the source +runner instead, pass `--entry scripts/run-node.mjs` and keep those results +separate from built-entry baselines. + +Usage: + +- `pnpm test:restart:gateway -- --case skipChannels --runs 1 --restarts 5` +- `pnpm test:restart:gateway -- --case default --runs 3 --restarts 3 --warmup 1` +- `pnpm test:restart:gateway -- --case skipChannelsAcpxProbe --case skipChannelsNoAcpxProbe --runs 1 --restarts 5` +- `node --import tsx scripts/bench-gateway-restart.ts --case fiftyPlugins --runs 1 --restarts 5 --output .artifacts/gateway-restart.json` +- `node --import tsx scripts/bench-gateway-restart.ts --json` + +Case ids: + +- `skipChannels`: restart with channels skipped. +- `skipChannelsAcpxProbe`: restart with channels skipped and ACPX startup probe on. +- `skipChannelsNoAcpxProbe`: restart with channels skipped and ACPX startup probe off. +- `default`: normal restart. +- `fiftyPlugins`: restart with 50 manifest plugins. + +Output includes next `/healthz`, next `/readyz`, downtime, restart ready timing, +CPU, RSS, startup trace metrics for the replacement process, and restart trace +metrics for signal handling, active-work drain, close phases, next start, ready +timing, and memory snapshots. The script enables +`OPENCLAW_GATEWAY_STARTUP_TRACE=1` and `OPENCLAW_GATEWAY_RESTART_TRACE=1` in the +child Gateway environment. + +Use this benchmark when a change touches restart signaling, close handlers, +startup-after-restart, sidecar shutdown, service handoff, or readiness after +restart. Start with `skipChannels` when isolating Gateway mechanics from channel +startup. Use `default` or plugin-heavy cases only after the narrow case explains +the restart path. + +Trace metrics are attribution hints, not verdicts. A restart change should be +judged from multiple samples, the matching owner span, `/healthz` and `/readyz` +behavior, and the user-visible restart contract. + ## Onboarding E2E (Docker) Docker is optional; this is only needed for containerized onboarding smoke tests. From e00cb664ad4bd346866dfb1ad863e7b6c72dd7e6 Mon Sep 17 00:00:00 2001 From: "Md. Al-Mosabbir Rakib" <34891461+mosabbirrakib@users.noreply.github.com> Date: Tue, 19 May 2026 23:51:48 +0600 Subject: [PATCH 07/28] docs: clarify /new vs /reset semantics in slash-commands (#81073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - The PR changes one bullet in `docs/tools/slash-commands.md` to distinguish `/new` from `/reset` and remove the misleading alias wording. - Reproducibility: yes. Reading current main reproduces the misleading docs line at `docs/tools/slash-commands.md:127`, and adjacent source/tests show `/new` and `/reset` take different paths in the Control UI. Automerge notes: - PR branch already contained follow-up commit before automerge: docs/slash-commands: drop inaccurate Control UI/ACP cross-reference (… - PR branch already contained follow-up commit before automerge: Merge branch 'main' into docs/fix-reset-alias-misleading Validation: - ClawSweeper review passed for head bb92b6050aab85335061028404d59c762cee3125. - Required merge gates passed before the squash merge. Prepared head SHA: bb92b6050aab85335061028404d59c762cee3125 Review: https://github.com/openclaw/openclaw/pull/81073#issuecomment-4432165259 Co-authored-by: Md. Al-Mosabbir Rakib Co-authored-by: Md. Al-Mosabbir Rakib <34891461+mosabbirrakib@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com> --- docs/tools/slash-commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 4404c284031f..5679c8d31067 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -124,7 +124,7 @@ Current source-of-truth: - - `/new [model]` starts a new session; `/reset` is the reset alias. + - `/new [model]` archives the current session and starts a fresh one; `/reset` wipes the current session in place. They are not aliases. - Control UI intercepts typed `/new` to create and switch to a fresh dashboard session, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; in that case `/new` resets the main session in place. Typed `/reset` still runs the Gateway's in-place reset. - `/reset soft [message]` keeps the current transcript, drops reused CLI backend session ids, and reruns startup/system-prompt loading in-place. - `/compact [instructions]` compacts the session context. See [Compaction](/concepts/compaction). From 94d8391c0323b10b3ae14e9b940d184a0a5ffdc2 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Tue, 19 May 2026 20:59:09 +0300 Subject: [PATCH 08/28] [codex] restore QR bootstrap operator handoff (#83684) Merged via squash. Prepared head SHA: 2dc955cfb787f7c5904730b2267e6ad972fc417b Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Reviewed-by: @ngutman --- CHANGELOG.md | 1 + .../ai/openclaw/app/gateway/GatewaySession.kt | 1 - .../ai/openclaw/app/node/ConnectionManager.kt | 9 +- .../app/gateway/GatewaySessionInvokeTest.kt | 2 +- .../app/node/ConnectionManagerTest.kt | 14 ++ .../Onboarding/GatewayOnboardingReset.swift | 30 +++ .../Onboarding/OnboardingWizardView.swift | 14 ++ apps/ios/Sources/RootCanvas.swift | 15 ++ apps/ios/Sources/Settings/SettingsTab.swift | 20 +- apps/ios/SwiftSources.input.xcfilelist | 1 + .../Sources/OpenClawKit/GatewayChannel.swift | 1 - .../GatewayConnectionProblem.swift | 4 + .../OpenClawKitTests/GatewayErrorsTests.swift | 13 ++ .../GatewayNodeSessionTests.swift | 1 - docs/channels/pairing.md | 11 +- docs/cli/qr.md | 4 +- docs/gateway/protocol.md | 40 ++-- src/gateway/server.auth.control-ui.suite.ts | 218 ++++++++++++------ .../server/ws-connection/message-handler.ts | 159 ++++++++++++- src/infra/device-bootstrap.test.ts | 32 ++- src/infra/device-pairing.test.ts | 48 ++-- src/pairing/setup-code.test.ts | 4 +- src/shared/device-bootstrap-profile.test.ts | 7 +- src/shared/device-bootstrap-profile.ts | 8 +- 24 files changed, 509 insertions(+), 148 deletions(-) create mode 100644 apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f6307444e5..19594e01a779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - WhatsApp: clarify inbound group diagnostics so observed but unregistered groups point to `channels.whatsapp.groups` without changing routing or sender authorization. (#83846) Thanks @neeravmakwana. - WhatsApp: drain pending outbound deliveries on a 30s periodic timer in addition to the reconnect handler, so messages enqueued while the provider is already connected no longer wait for the next reconnect to send. (#79083) Thanks @Oviemudiaga. - CLI/TUI: include gateway plugin slash commands in TUI autocomplete, so connected sessions can suggest plugin-owned commands exposed by the running Gateway. (#83640) Thanks @se7en-agent. +- Gateway/mobile: restore QR setup-code handoff of bounded operator tokens for iOS and Android onboarding while keeping admin and pairing scopes out of bootstrap. (#83684) Thanks @ngutman. ## 2026.5.19 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index 75fda7c2941e..26a0379a8af4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -605,7 +605,6 @@ class GatewaySession( setOf( "operator.approvals", "operator.read", - "operator.talk.secrets", "operator.write", ) scopes.filter { allowedOperatorScopes.contains(it) }.distinct().sorted() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt index 3ba5af815036..5c2471e26c9a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt @@ -162,7 +162,14 @@ class ConnectionManager( fun buildOperatorConnectOptions(): GatewayConnectOptions = GatewayConnectOptions( role = "operator", - scopes = listOf("operator.read", "operator.write", "operator.talk.secrets"), + // QR bootstrap hands Android a bounded operator token that includes approvals; keep the + // default operator reconnect request aligned so the post-bootstrap loop can approve work. + scopes = + listOf( + "operator.approvals", + "operator.read", + "operator.write", + ), caps = emptyList(), commands = emptyList(), permissions = emptyMap(), diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt index ab4a27f8bd43..270a2b4f5fea 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt @@ -365,7 +365,7 @@ class GatewaySessionInvokeTest { assertEquals(emptyList(), nodeEntry?.scopes) assertEquals("bootstrap-operator-token", operatorEntry?.token) assertEquals( - listOf("operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"), + listOf("operator.approvals", "operator.read", "operator.write"), operatorEntry?.scopes, ) } finally { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt index b2402f544c38..49df1ab14c3a 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt @@ -368,6 +368,20 @@ class ConnectionManagerTest { assertEquals(false, params?.allowTOFU) } + @Test + fun buildOperatorConnectOptions_requestsQrBootstrapHandoffScopes() { + val options = newManager().buildOperatorConnectOptions() + + assertEquals( + listOf( + "operator.approvals", + "operator.read", + "operator.write", + ), + options.scopes, + ) + } + @Test fun buildNodeConnectOptions_advertisesRequestableSmsSearchWithoutSmsCapability() { val options = diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift new file mode 100644 index 000000000000..0a82bbe3b81d --- /dev/null +++ b/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift @@ -0,0 +1,30 @@ +import Foundation +import OpenClawKit + +enum GatewayOnboardingReset { + static func reset( + appModel: NodeAppModel, + instanceId: String, + defaults: UserDefaults = .standard) + { + appModel.disconnectGateway() + + let trimmedInstanceId = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedInstanceId.isEmpty { + GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId) + } + + GatewaySettingsStore.clearLastGatewayConnection() + GatewaySettingsStore.clearPreferredGatewayStableID() + GatewaySettingsStore.clearLastDiscoveredGatewayStableID() + GatewayTLSStore.clearAllFingerprints() + OnboardingStateStore.reset(defaults: defaults) + + defaults.set(false, forKey: "gateway.onboardingComplete") + defaults.set(false, forKey: "gateway.hasConnectedOnce") + defaults.set(false, forKey: "gateway.manual.enabled") + defaults.set("", forKey: "gateway.manual.host") + defaults.set("", forKey: "gateway.setupCode") + defaults.set(defaults.integer(forKey: "onboarding.requestID") + 1, forKey: "onboarding.requestID") + } +} diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 61abd4460190..856ee745fcfe 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -1016,10 +1016,24 @@ struct OnboardingWizardView: View { } private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String { + if problem.suggestsOnboardingReset { return "Scan QR again" } problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection" } private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async { + if problem.suggestsOnboardingReset { + GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId) + self.gatewayToken = "" + self.gatewayPassword = "" + self.connectingGatewayID = nil + self.connectMessage = nil + self.issue = .none + self.pairingRequestId = nil + self.statusLine = "Scan a fresh setup QR code from this gateway." + self.step = .connect + self.showQRScanner = true + return + } if problem.canTrustRotatedCertificate { self.connectingGatewayID = "trust-certificate" self.connectMessage = "Updating gateway certificate…" diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 7a92b47ad627..fdf5d9310a17 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -15,6 +15,7 @@ struct RootCanvas: View { @AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0 @AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false @AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false + @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = "" @AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false @AppStorage("gateway.manual.host") private var manualGatewayHost: String = "" @@ -102,6 +103,9 @@ struct RootCanvas: View { }, retryGatewayConnection: { Task { await self.gatewayController.connectLastKnown() } + }, + resetOnboarding: { + self.resetOnboardingFromGatewayProblem() }) .preferredColorScheme(.dark) @@ -429,6 +433,13 @@ struct RootCanvas: View { guard shouldPresent else { return } self.presentedSheet = .quickSetup } + + private func resetOnboardingFromGatewayProblem() { + GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId) + self.presentedSheet = nil + self.onboardingAllowSkip = false + self.showOnboarding = true + } } private struct HomeCanvasPayload: Codable { @@ -469,6 +480,7 @@ private struct CanvasContent: View { var openChat: () -> Void var openSettings: () -> Void var retryGatewayConnection: () -> Void + var resetOnboarding: () -> Void private var brightenButtons: Bool { self.systemColorScheme == .light @@ -578,12 +590,15 @@ private struct CanvasContent: View { private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String { if problem.canTrustRotatedCertificate { return "Trust certificate" } + if problem.suggestsOnboardingReset { return "Reset onboarding" } return problem.retryable ? "Retry" : "Open Settings" } private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) { if problem.canTrustRotatedCertificate { Task { await self.gatewayController.trustRotatedGatewayCertificate(from: problem) } + } else if problem.suggestsOnboardingReset { + self.resetOnboarding() } else if problem.retryable { self.retryGatewayConnection() } else { diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 5524c81e1242..8796f5f9d3ef 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -1057,10 +1057,15 @@ struct SettingsTab: View { } private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String { + if problem.suggestsOnboardingReset { return "Reset onboarding" } problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection" } private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async { + if problem.suggestsOnboardingReset { + self.resetOnboarding() + return + } if problem.canTrustRotatedCertificate { _ = await self.gatewayController.trustRotatedGatewayCertificate(from: problem) return @@ -1070,7 +1075,6 @@ struct SettingsTab: View { private func resetOnboarding() { // Disconnect first so RootCanvas doesn't instantly mark onboarding complete again. - self.appModel.disconnectGateway() self.connectingGatewayID = nil self.setupStatusText = nil self.setupCode = "" @@ -1082,19 +1086,7 @@ struct SettingsTab: View { self.gatewayToken = "" self.gatewayPassword = "" - let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedInstanceId.isEmpty { - GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId) - } - - // Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks). - GatewaySettingsStore.clearLastGatewayConnection() - GatewaySettingsStore.clearPreferredGatewayStableID() - GatewaySettingsStore.clearLastDiscoveredGatewayStableID() - // Resetting onboarding should also forget trusted gateway TLS fingerprints. - // Otherwise a restarted dev gateway can stay stuck in a local TLS cancel loop. - GatewayTLSStore.clearAllFingerprints() - OnboardingStateStore.reset() + GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId) // RootCanvas also short-circuits onboarding when these are true. self.onboardingComplete = false diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 9cfb07dc6d55..33be521e391f 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -35,6 +35,7 @@ Sources/Model/NodeAppModel+WatchNotifyNormalization.swift Sources/Model/NodeAppModel.swift Sources/Model/WatchReplyCoordinator.swift Sources/Motion/MotionService.swift +Sources/Onboarding/GatewayOnboardingReset.swift Sources/Onboarding/GatewayOnboardingView.swift Sources/Onboarding/OnboardingStateStore.swift Sources/Onboarding/OnboardingWizardView.swift diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 51e5ac9e6a17..8d2d70405fa5 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -601,7 +601,6 @@ public actor GatewayChannelActor { let allowedOperatorScopes: Set = [ "operator.approvals", "operator.read", - "operator.talk.secrets", "operator.write", ] return Array(Set(scopes.filter { allowedOperatorScopes.contains($0) })).sorted() diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift index 6ee313ba6500..44ba145447c9 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift @@ -121,6 +121,10 @@ public struct GatewayConnectionProblem: Equatable, Sendable { } } + public var suggestsOnboardingReset: Bool { + self.kind == .gatewayAuthTokenMismatch + } + public var statusText: String { switch self.kind { case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift index b6bfa1383e91..1ac5cc00c36c 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift @@ -70,6 +70,19 @@ import Testing #expect(problem?.needsCredentialUpdate == false) } + @Test func tokenMismatchSuggestsOnboardingReset() { + let error = GatewayConnectAuthError( + message: "token mismatch", + detailCode: GatewayConnectAuthDetailCode.authTokenMismatch.rawValue, + canRetryWithDeviceToken: false) + + let problem = GatewayConnectionProblemMapper.map(error: error) + + #expect(problem?.kind == .gatewayAuthTokenMismatch) + #expect(problem?.suggestsOnboardingReset == true) + #expect(problem?.needsCredentialUpdate == true) + } + @Test func cancelledTransportDoesNotReplaceStructuredPairingProblem() { let pairing = GatewayConnectAuthError( message: "pairing required", diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index f9dac81baff7..06b0b9934e55 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -405,7 +405,6 @@ struct GatewayNodeSessionTests { #expect(operatorEntry.scopes == [ "operator.approvals", "operator.read", - "operator.talk.secrets", "operator.write", ]) diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 7102e3643a85..a4a5504347f0 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -123,10 +123,13 @@ The setup code is a base64-encoded JSON payload that contains: That bootstrap token carries the built-in pairing bootstrap profile: -- the built-in setup profile allows only the `node` role -- after approval, the handed-off `node` token stays `scopes: []` -- the built-in setup-code flow does not hand off an `operator` token -- operator access requires a separate approved operator pairing or token flow +- the built-in setup profile allows the fresh QR/setup-code baseline only: + `node` plus a bounded `operator` handoff +- the handed-off `node` token stays `scopes: []` +- the handed-off `operator` token is limited to `operator.approvals`, + `operator.read`, and `operator.write` +- `operator.admin` and `operator.pairing` are not granted by QR/setup-code + bootstrap; they require a separate approved operator pairing or token flow - later token rotation/revocation remains bounded by both the device's approved role contract and the caller session's operator scopes diff --git a/docs/cli/qr.md b/docs/cli/qr.md index 52be232d987b..45e5719ba8a6 100644 --- a/docs/cli/qr.md +++ b/docs/cli/qr.md @@ -35,8 +35,8 @@ openclaw qr --url wss://gateway.example/ws - `--token` and `--password` are mutually exclusive. - The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password. -- Built-in setup-code bootstrap is node-only. After approval, the primary node token lands with `scopes: []`. -- The built-in setup-code flow does not return a handed-off operator token; operator access requires a separate approved operator pairing or token flow. +- Built-in setup-code bootstrap returns a primary `node` token with `scopes: []` plus a bounded `operator` handoff token for trusted mobile onboarding. +- The handed-off operator token is limited to `operator.approvals`, `operator.read`, and `operator.write`; `operator.admin`, `operator.pairing`, and `operator.talk.secrets` require a separate approved operator pairing or token flow. - Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs. Private LAN addresses and `.local` Bonjour hosts remain supported over `ws://`, but Tailscale/public mobile routes should use Tailscale Serve/Funnel or a `wss://` gateway URL. - With `--remote`, OpenClaw requires either `gateway.remote.url` or `gateway.tailscale.mode=serve|funnel`. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 15cc55ef2549..f9dd5180d6f1 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -147,24 +147,33 @@ When a device token is issued, `hello-ok` also includes: } ``` -Built-in QR/setup-code bootstrap is node-only. After the owner approves the -pending node request, `hello-ok.auth` includes the primary node token: +Built-in QR/setup-code bootstrap is a fresh mobile handoff path. A successful +baseline setup-code connect returns a primary node token plus one bounded +operator token: ```json { "auth": { "deviceToken": "…", "role": "node", - "scopes": [] + "scopes": [], + "deviceTokens": [ + { + "deviceToken": "…", + "role": "operator", + "scopes": ["operator.approvals", "operator.read", "operator.write"] + } + ] } } ``` -The built-in setup-code flow does not include additional `deviceTokens` entries -or hand off an operator token. Client authors should treat the optional -`hello-ok.auth.deviceTokens` field as legacy/custom bootstrap extension data: -persist it only when present on a trusted transport, and do not require it for -built-in pairing. +The operator handoff is intentionally bounded so QR onboarding can start the +mobile operator loop without granting `operator.admin`, `operator.pairing`, or +`operator.talk.secrets`. Those scopes require a separate approved operator +pairing or token flow. Clients should persist `hello-ok.auth.deviceTokens` only +when the connect used bootstrap auth on trusted transport such as `wss://` or +loopback/local pairing. ### Node example @@ -691,17 +700,16 @@ rather than the pre-handshake defaults. `AUTH_TOKEN_MISMATCH` retry is gated to **trusted endpoints only** — loopback, or `wss://` with a pinned `tlsFingerprint`. Public `wss://` without pinning does not qualify. -- Built-in setup-code bootstrap returns only the primary node - `hello-ok.auth.deviceToken`; clients must not expect an additional operator - token in `hello-ok.auth.deviceTokens`. -- While built-in setup-code bootstrap is waiting for approval, `PAIRING_REQUIRED` +- Built-in setup-code bootstrap returns the primary node + `hello-ok.auth.deviceToken` plus a bounded operator token in + `hello-ok.auth.deviceTokens` for trusted mobile handoff. The operator token + excludes `operator.admin`, `operator.pairing`, and `operator.talk.secrets`. +- While a non-baseline setup-code bootstrap is waiting for approval, `PAIRING_REQUIRED` details include `recommendedNextStep: "wait_then_retry"`, `retryable: true`, and `pauseReconnect: false`. Clients should keep reconnecting with the same bootstrap token until the request is approved or the token becomes invalid. -- If an older or custom trusted bootstrap flow includes optional - `hello-ok.auth.deviceTokens` entries, persist them only when the connect used - bootstrap auth on a trusted transport such as `wss://` or loopback/local - pairing. +- Persist `hello-ok.auth.deviceTokens` only when the connect used bootstrap auth + on a trusted transport such as `wss://` or loopback/local pairing. - If a client supplies an **explicit** `deviceToken` or explicit `scopes`, that caller-requested scope set remains authoritative; cached scopes are only reused when the client is reusing the stored per-device token. diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 0576ec828fe5..92c43f35ec86 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -1028,11 +1028,11 @@ export function registerControlUiAndPairingSuite(): void { } }); - test("requires approval before qr setup code returns a durable node token", async () => { + test("qr setup code returns node token plus bounded operator handoff", async () => { const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { approveDevicePairing, getPairedDevice, listDevicePairing, verifyDeviceToken } = + const { getPairedDevice, listDevicePairing, verifyDeviceToken } = await import("../infra/device-pairing.js"); const { server, port, prevToken } = await startControlUiServer("secret"); @@ -1058,47 +1058,8 @@ export function registerControlUiAndPairingSuite(): void { client, deviceIdentityPath: identityPath, }); - expect(initial.ok).toBe(false); - expect(initial.error?.message ?? "").toContain("pairing required"); - const initialDetails = initial.error?.details as - | { - code?: string; - pauseReconnect?: boolean; - recommendedNextStep?: string; - retryable?: boolean; - } - | undefined; - expect(initialDetails?.code).toBe(ConnectErrorDetailCodes.PAIRING_REQUIRED); - expect(initialDetails?.recommendedNextStep).toBe("wait_then_retry"); - expect(initialDetails?.retryable).toBe(true); - expect(initialDetails?.pauseReconnect).toBe(false); - - const pendingAfterInitial = await listDevicePairing(); - const pendingForDevice = pendingAfterInitial.pending.filter( - (entry) => entry.deviceId === identity.deviceId, - ); - expect(pendingForDevice).toHaveLength(1); - expect(pendingForDevice[0]?.role).toBe("node"); - expect(pendingForDevice[0]?.roles).toEqual(["node"]); - expect(await getPairedDevice(identity.deviceId)).toBeNull(); - expect( - await approveDevicePairing(pendingForDevice[0]?.requestId ?? "", { - callerScopes: ["operator.pairing"], - }), - ).toMatchObject({ status: "approved" }); - wsBootstrap.close(); - - const wsApproved = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const approvedConnect = await connectReq(wsApproved, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client, - deviceIdentityPath: identityPath, - }); - expect(approvedConnect.ok).toBe(true); - const approvedPayload = approvedConnect.payload as + expect(initial.ok).toBe(true); + const approvedPayload = initial.payload as | { type?: string; auth?: { @@ -1120,26 +1081,47 @@ export function registerControlUiAndPairingSuite(): void { } expect(approvedPayload?.auth?.role).toBe("node"); expect(approvedPayload?.auth?.scopes ?? []).toEqual([]); - expect(approvedPayload?.auth?.deviceTokens ?? []).toEqual([]); + const operatorHandoff = approvedPayload?.auth?.deviceTokens?.find( + (entry) => entry.role === "operator", + ); + const issuedOperatorToken = operatorHandoff?.deviceToken; + if (!issuedOperatorToken) { + throw new Error("expected handed-off operator device token"); + } + expect(operatorHandoff?.scopes).toEqual([ + "operator.approvals", + "operator.read", + "operator.write", + ]); + expect(operatorHandoff?.scopes).not.toContain("operator.admin"); + expect(operatorHandoff?.scopes).not.toContain("operator.pairing"); + + const pendingAfterInitial = await listDevicePairing(); + const pendingForDevice = pendingAfterInitial.pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pendingForDevice).toEqual([]); + wsBootstrap.close(); const afterBootstrap = await listDevicePairing(); expect( afterBootstrap.pending.filter((entry) => entry.deviceId === identity.deviceId), ).toEqual([]); const paired = await getPairedDevice(identity.deviceId); - expect(paired?.roles).toEqual(["node"]); - expect(paired?.approvedScopes).toEqual([]); + expect(paired?.roles).toEqual(["node", "operator"]); + expect(paired?.approvedScopes).toEqual([ + "operator.approvals", + "operator.read", + "operator.write", + ]); expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken); - expect(paired?.tokens?.operator).toBeUndefined(); - - await new Promise((resolve) => { - if (wsApproved.readyState === WebSocket.CLOSED) { - resolve(); - return; - } - wsApproved.once("close", () => resolve()); - wsApproved.close(); - }); + expect(paired?.tokens?.node?.scopes).toEqual([]); + expect(paired?.tokens?.operator?.token).toBe(issuedOperatorToken); + expect(paired?.tokens?.operator?.scopes).toEqual([ + "operator.approvals", + "operator.read", + "operator.write", + ]); const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); const replay = await connectReq(wsReplay, { @@ -1189,23 +1171,122 @@ export function registerControlUiAndPairingSuite(): void { await expect( verifyDeviceToken({ deviceId: identity.deviceId, - token: issuedDeviceToken, + token: issuedOperatorToken, role: "operator", - scopes: [ - "operator.approvals", - "operator.read", - "operator.talk.secrets", - "operator.write", - ], + scopes: ["operator.approvals", "operator.read", "operator.write"], }), - ).resolves.toEqual({ ok: false, reason: "token-missing" }); + ).resolves.toEqual({ ok: true }); + await expect( + verifyDeviceToken({ + deviceId: identity.deviceId, + token: issuedOperatorToken, + role: "operator", + scopes: ["operator.admin"], + }), + ).resolves.toEqual({ ok: false, reason: "scope-mismatch" }); + await expect( + verifyDeviceToken({ + deviceId: identity.deviceId, + token: issuedOperatorToken, + role: "operator", + scopes: ["operator.pairing"], + }), + ).resolves.toEqual({ ok: false, reason: "scope-mismatch" }); } finally { await server.close(); restoreGatewayToken(prevToken); } }); - test("rejected qr setup code cannot recreate pending node pairing", async () => { + test("qr bootstrap retry keeps bounded operator handoff after paired approval", async () => { + const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } = + await import("../infra/device-bootstrap.js"); + const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); + const { approveBootstrapDevicePairing, requestDevicePairing } = + await import("../infra/device-pairing.js"); + const { PAIRING_SETUP_BOOTSTRAP_PROFILE } = + await import("../shared/device-bootstrap-profile.js"); + const { server, port, prevToken } = await startControlUiServer("secret"); + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-bootstrap-node-retry-", + ); + const client = { + id: "openclaw-ios", + version: "2026.3.30", + platform: "iOS 26.3.1", + mode: "node", + deviceFamily: "iPhone", + }; + + try { + const issued = await issueDeviceBootstrapToken(); + const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); + const pending = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey, + role: "node", + roles: ["node", "operator"], + scopes: ["operator.approvals", "operator.read", "operator.write"], + clientId: client.id, + clientMode: client.mode, + displayName: client.id, + platform: client.platform, + deviceFamily: client.deviceFamily, + silent: true, + }); + await approveBootstrapDevicePairing( + pending.request.requestId, + PAIRING_SETUP_BOOTSTRAP_PROFILE, + ); + + const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const retry = await connectReq(wsRetry, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(retry.ok).toBe(true); + const payload = retry.payload as + | { + auth?: { + deviceToken?: string; + deviceTokens?: Array<{ deviceToken?: string; role?: string; scopes?: string[] }>; + }; + } + | undefined; + expect(payload?.auth?.deviceToken).toBeTruthy(); + const operatorHandoff = payload?.auth?.deviceTokens?.find( + (entry) => entry.role === "operator", + ); + expect(operatorHandoff?.deviceToken).toBeTruthy(); + expect(operatorHandoff?.scopes).toEqual([ + "operator.approvals", + "operator.read", + "operator.write", + ]); + expect(operatorHandoff?.scopes).not.toContain("operator.admin"); + expect(operatorHandoff?.scopes).not.toContain("operator.pairing"); + wsRetry.close(); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: identity.deviceId, + publicKey, + role: "node", + scopes: [], + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("rejected non-baseline bootstrap request cannot recreate pending node pairing", async () => { const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); const { listDevicePairing, rejectDevicePairing } = await import("../infra/device-pairing.js"); const { server, port, prevToken } = await startControlUiServer("secret"); @@ -1221,7 +1302,12 @@ export function registerControlUiAndPairingSuite(): void { }; try { - const issued = await issueDeviceBootstrapToken(); + const issued = await issueDeviceBootstrapToken({ + profile: { + roles: ["node"], + scopes: [], + }, + }); const wsInitial = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); const initial = await connectReq(wsInitial, { skipDefaultAuth: true, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 883be96f8e61..01a059ba301b 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -3,6 +3,7 @@ import os from "node:os"; import type { RawData, WebSocket } from "ws"; import { getRuntimeConfig } from "../../../config/io.js"; import { + getBoundDeviceBootstrapProfile, getDeviceBootstrapTokenProfile, redeemDeviceBootstrapTokenProfile, revokeDeviceBootstrapToken, @@ -14,6 +15,7 @@ import { normalizeDevicePublicKeyBase64Url, } from "../../../infra/device-identity.js"; import { + approveBootstrapDevicePairing, approveDevicePairing, ensureDeviceToken, getPairedDevice, @@ -41,6 +43,12 @@ import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; import { rawDataToString } from "../../../infra/ws.js"; import { logRejectedLargePayload } from "../../../logging/diagnostic-payload.js"; import type { createSubsystemLogger } from "../../../logging/subsystem.js"; +import { + BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, + PAIRING_SETUP_BOOTSTRAP_PROFILE, + resolveBootstrapProfileScopesForRole, + type DeviceBootstrapProfile, +} from "../../../shared/device-bootstrap-profile.js"; import { roleScopesAllow } from "../../../shared/operator-scope-compat.js"; import { isBrowserOperatorUiClient, @@ -146,6 +154,19 @@ type SubsystemLogger = ReturnType; const DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000; +function sameBootstrapProfile( + left: DeviceBootstrapProfile, + right: DeviceBootstrapProfile, +): boolean { + if (left.roles.length !== right.roles.length || left.scopes.length !== right.scopes.length) { + return false; + } + return ( + left.roles.every((role, index) => role === right.roles[index]) && + left.scopes.every((scope, index) => scope === right.scopes[index]) + ); +} + export type WsOriginCheckMetrics = { hostHeaderFallbackAccepted: number; }; @@ -957,6 +978,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar authMethod === "bootstrap-token" && bootstrapTokenCandidate ? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate }) : null; + let handoffBootstrapProfile: DeviceBootstrapProfile | null = null; const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({ isControlUi, role, @@ -1076,14 +1098,50 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar autoApproveCidrs: configSnapshot.gateway?.nodes?.pairing?.autoApproveCidrs, }, ); + const boundBootstrapProfile = + authMethod === "bootstrap-token" && + bootstrapTokenCandidate && + reason === "not-paired" && + role === "node" && + scopes.length === 0 && + !existingPairedDevice && + !isControlUi && + !isBrowserOperatorUi && + !isWebchat && + connectParams.client.mode === GATEWAY_CLIENT_MODES.NODE + ? await getBoundDeviceBootstrapProfile({ + token: bootstrapTokenCandidate, + deviceId: device.id, + publicKey: devicePublicKey, + }) + : null; + const allowSilentBootstrapPairing = + boundBootstrapProfile !== null && + sameBootstrapProfile(boundBootstrapProfile, PAIRING_SETUP_BOOTSTRAP_PROFILE); + // This is the native QR/setup-code onboarding seam. Mobile clients + // connect as node with bootstrap auth, then clear bootstrap auth and + // start their operator loop only if hello-ok includes the bounded + // operator token below. Keep this limited to the exact fresh baseline + // profile; admin/pairing scopes still require an explicit owner flow. + const bootstrapPairingRoles = allowSilentBootstrapPairing + ? Array.from(new Set([role, ...boundBootstrapProfile.roles])) + : undefined; const pairing = await requestDevicePairing({ deviceId: device.id, publicKey: devicePublicKey, ...clientPairingMetadata, + ...(bootstrapPairingRoles + ? { + roles: bootstrapPairingRoles, + scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], + } + : {}), silent: reason === "scope-upgrade" ? false - : allowSilentLocalPairing || allowSilentTrustedCidrsNodePairing, + : allowSilentLocalPairing || + allowSilentTrustedCidrsNodePairing || + allowSilentBootstrapPairing, }); const context = buildRequestContext(); let approved: Awaited> | undefined; @@ -1104,10 +1162,19 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar return replacementPending?.requestId; }; if (pairing.request.silent === true) { - approved = await approveDevicePairing(pairing.request.requestId, { - callerScopes: scopes, - }); + approved = + allowSilentBootstrapPairing && boundBootstrapProfile + ? await approveBootstrapDevicePairing( + pairing.request.requestId, + boundBootstrapProfile, + ) + : await approveDevicePairing(pairing.request.requestId, { + callerScopes: scopes, + }); if (approved?.status === "approved") { + if (allowSilentBootstrapPairing && boundBootstrapProfile) { + handoffBootstrapProfile = boundBootstrapProfile; + } logGateway.info( `device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`, ); @@ -1307,6 +1374,37 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar } } + const retryBootstrapHandoffProfile = + authMethod === "bootstrap-token" && + bootstrapTokenCandidate && + role === "node" && + scopes.length === 0 && + !isControlUi && + !isBrowserOperatorUi && + !isWebchat && + connectParams.client.mode === GATEWAY_CLIENT_MODES.NODE && + pairedRoles.includes("operator") && + roleScopesAllow({ + role: "operator", + requestedScopes: BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, + allowedScopes: pairedScopes, + }) + ? await getBoundDeviceBootstrapProfile({ + token: bootstrapTokenCandidate, + deviceId: device.id, + publicKey: devicePublicKey, + }) + : null; + if ( + retryBootstrapHandoffProfile && + sameBootstrapProfile(retryBootstrapHandoffProfile, PAIRING_SETUP_BOOTSTRAP_PROFILE) + ) { + // If the first QR bootstrap hello-ok failed to reach mobile, the + // bootstrap token is restored while the paired device already has + // node+operator grants. Preserve the same bounded handoff on retry. + handoffBootstrapProfile = retryBootstrapHandoffProfile; + } + // Metadata pinning is approval-bound. Reconnects can update access metadata // and same-family mobile OS version labels, but real platform/device-family // changes must stay on the approved pairing record. @@ -1324,6 +1422,52 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar shouldIssueDeviceToken && device && hasServerApprovedDeviceTokenBaseline ? await ensureDeviceToken({ deviceId: device.id, role, scopes }) : null; + const bootstrapDeviceTokens: Array<{ + deviceToken: string; + role: string; + scopes: string[]; + issuedAtMs: number; + }> = []; + if (deviceToken) { + bootstrapDeviceTokens.push({ + deviceToken: deviceToken.token, + role: deviceToken.role, + scopes: deviceToken.scopes, + issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs, + }); + } + const approvedHandoffBootstrapProfile = handoffBootstrapProfile; + if (device && approvedHandoffBootstrapProfile) { + for (const bootstrapRole of approvedHandoffBootstrapProfile.roles) { + if (bootstrapDeviceTokens.some((entry) => entry.role === bootstrapRole)) { + continue; + } + // Extra hello-ok handoff tokens are only emitted for the approved + // setup-code profile. Operator scopes are filtered through the + // documented allowlist so QR bootstrap cannot grant admin/pairing. + const bootstrapRoleScopes = + bootstrapRole === "operator" + ? resolveBootstrapProfileScopesForRole( + bootstrapRole, + approvedHandoffBootstrapProfile.scopes, + ) + : []; + const extraToken = await ensureDeviceToken({ + deviceId: device.id, + role: bootstrapRole, + scopes: bootstrapRoleScopes, + }); + if (!extraToken) { + continue; + } + bootstrapDeviceTokens.push({ + deviceToken: extraToken.token, + role: extraToken.role, + scopes: extraToken.scopes, + issuedAtMs: extraToken.rotatedAtMs ?? extraToken.createdAtMs, + }); + } + } if (role === "node") { const reconciliation = await reconcileNodePairingOnConnect({ cfg: getRuntimeConfig(), @@ -1565,6 +1709,9 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar ? { deviceToken: deviceToken.token, issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs, + ...(bootstrapDeviceTokens.length > 1 + ? { deviceTokens: bootstrapDeviceTokens.slice(1) } + : {}), } : {}), }, @@ -1580,13 +1727,13 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar | undefined; if (authMethod === "bootstrap-token" && bootstrapTokenCandidate && device) { try { - if (issuedBootstrapProfile) { + if (handoffBootstrapProfile || issuedBootstrapProfile) { const redemption = await redeemDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate, role, scopes, }); - if (redemption.fullyRedeemed) { + if (handoffBootstrapProfile || redemption.fullyRedeemed) { const revoked = await revokeDeviceBootstrapToken({ token: bootstrapTokenCandidate, }); diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index 1b12c95fc763..92b7f00549f6 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -71,8 +71,8 @@ describe("device bootstrap tokens", () => { expect(parsed[issued.token]?.ts).toBe(Date.now()); expect(parsed[issued.token]?.issuedAtMs).toBe(Date.now()); expect(parsed[issued.token]?.profile).toEqual({ - roles: ["node"], - scopes: [], + roles: ["node", "operator"], + scopes: ["operator.approvals", "operator.read", "operator.write"], }); }); @@ -82,6 +82,12 @@ describe("device bootstrap tokens", () => { await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true }); await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true }); + await expect( + verifyBootstrapToken(baseDir, issued.token, { + deviceId: "device-456", + publicKey: "public-key-456", + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8"); const parsed = JSON.parse(raw) as Record< @@ -151,8 +157,8 @@ describe("device bootstrap tokens", () => { await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual( { - roles: ["node"], - scopes: [], + roles: ["node", "operator"], + scopes: ["operator.approvals", "operator.read", "operator.write"], }, ); await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull(); @@ -160,7 +166,13 @@ describe("device bootstrap tokens", () => { it("persists bootstrap redemption state across verification reloads", async () => { const baseDir = await createTempDir(); - const issued = await issueDeviceBootstrapToken({ baseDir }); + const issued = await issueDeviceBootstrapToken({ + baseDir, + profile: { + roles: ["node"], + scopes: [], + }, + }); await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true }); await expect( @@ -290,7 +302,7 @@ describe("device bootstrap tokens", () => { expect(raw).toContain(issued.token); }); - it("rejects bootstrap verification when role or scopes exceed the issued profile", async () => { + it("rejects bootstrap verification when scopes exceed the issued profile", async () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); @@ -387,7 +399,7 @@ describe("device bootstrap tokens", () => { await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual( { roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.write"], }, ); await expect( @@ -451,7 +463,7 @@ describe("device bootstrap tokens", () => { >; expect(parsed[issued.token]?.redeemedProfile).toEqual({ roles: ["operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.write"], }); }); @@ -532,8 +544,8 @@ describe("device bootstrap tokens", () => { baseDir, }), ).resolves.toEqual({ - roles: ["node"], - scopes: [], + roles: ["node", "operator"], + scopes: ["operator.approvals", "operator.read", "operator.write"], }); }); diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index f9c3cdc58e5f..2bff191d92f5 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -1062,15 +1062,15 @@ describe("device pairing tokens", () => { expect(paired?.tokens?.operator).toBeUndefined(); }); - test("default bootstrap pairing does not issue operator tokens", async () => { + test("baseline bootstrap pairing issues bounded operator token when requested by QR handoff", async () => { const baseDir = await makeDevicePairingDir(); const request = await requestDevicePairing( { deviceId: "bootstrap-device-operator-default", publicKey: "bootstrap-public-key-operator-default", role: "node", - roles: ["node"], - scopes: [], + roles: ["node", "operator"], + scopes: ["operator.approvals", "operator.read", "operator.write"], silent: true, }, baseDir, @@ -1084,16 +1084,40 @@ describe("device pairing tokens", () => { expectRecordFields(approved, "approved result", { status: "approved" }); const paired = await getPairedDevice("bootstrap-device-operator-default", baseDir); - const nodeToken = requireToken(paired?.tokens?.node?.token); + const operatorToken = requireToken(paired?.tokens?.operator?.token); + expect(paired?.tokens?.node?.scopes).toStrictEqual([]); + expect(paired?.tokens?.operator?.scopes).toStrictEqual([ + "operator.approvals", + "operator.read", + "operator.write", + ]); await expect( verifyDeviceToken({ deviceId: "bootstrap-device-operator-default", - token: nodeToken, + token: operatorToken, role: "operator", scopes: ["operator.approvals", "operator.read", "operator.write"], baseDir, }), - ).resolves.toEqual({ ok: false, reason: "token-missing" }); + ).resolves.toEqual({ ok: true }); + await expect( + verifyDeviceToken({ + deviceId: "bootstrap-device-operator-default", + token: operatorToken, + role: "operator", + scopes: ["operator.admin"], + baseDir, + }), + ).resolves.toEqual({ ok: false, reason: "scope-mismatch" }); + await expect( + verifyDeviceToken({ + deviceId: "bootstrap-device-operator-default", + token: operatorToken, + role: "operator", + scopes: ["operator.pairing"], + baseDir, + }), + ).resolves.toEqual({ ok: false, reason: "scope-mismatch" }); }); test("bootstrap node approval preserves existing operator token scopes", async () => { @@ -1173,13 +1197,7 @@ describe("device pairing tokens", () => { publicKey: "bootstrap-public-key-bounded-baseline", role: "node", roles: ["node", "operator"], - scopes: [ - "node.exec", - "operator.approvals", - "operator.read", - "operator.talk.secrets", - "operator.write", - ], + scopes: ["node.exec", "operator.approvals", "operator.read", "operator.write"], silent: true, }, baseDir, @@ -1207,13 +1225,11 @@ describe("device pairing tokens", () => { expect(paired?.approvedScopes).toEqual([ "operator.approvals", "operator.read", - "operator.talk.secrets", "operator.write", ]); expect(paired?.tokens?.operator?.scopes).toEqual([ "operator.approvals", "operator.read", - "operator.talk.secrets", "operator.write", ]); expect(paired?.tokens?.node?.scopes).toStrictEqual([]); @@ -1231,7 +1247,7 @@ describe("device pairing tokens", () => { const baseDir = await makeDevicePairingDir(); const bootstrapProfile = { roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.write"], }; const first = await requestDevicePairing( { diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index 3dbfbbc8c51d..bb14a145cb11 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -91,8 +91,8 @@ describe("pairing setup code", () => { expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith({ baseDir: undefined, profile: { - roles: ["node"], - scopes: [], + roles: ["node", "operator"], + scopes: ["operator.approvals", "operator.read", "operator.write"], }, }); if (params.url) { diff --git a/src/shared/device-bootstrap-profile.test.ts b/src/shared/device-bootstrap-profile.test.ts index 72462196589d..db9f12be7c8e 100644 --- a/src/shared/device-bootstrap-profile.test.ts +++ b/src/shared/device-bootstrap-profile.test.ts @@ -57,10 +57,10 @@ describe("device bootstrap profile", () => { }); }); - test("default setup profile is node-only", () => { + test("default setup profile carries node plus bounded operator handoff", () => { expect(PAIRING_SETUP_BOOTSTRAP_PROFILE).toEqual({ - roles: ["node"], - scopes: [], + roles: ["node", "operator"], + scopes: ["operator.approvals", "operator.read", "operator.write"], }); }); @@ -68,7 +68,6 @@ describe("device bootstrap profile", () => { expect([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]).toEqual([ "operator.approvals", "operator.read", - "operator.talk.secrets", "operator.write", ]); }); diff --git a/src/shared/device-bootstrap-profile.ts b/src/shared/device-bootstrap-profile.ts index 30cc673c12f6..d3eb1437a7e8 100644 --- a/src/shared/device-bootstrap-profile.ts +++ b/src/shared/device-bootstrap-profile.ts @@ -13,15 +13,17 @@ export type DeviceBootstrapProfileInput = { export const BOOTSTRAP_HANDOFF_OPERATOR_SCOPES = [ "operator.approvals", "operator.read", - "operator.talk.secrets", "operator.write", ] as const; const BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET = new Set(BOOTSTRAP_HANDOFF_OPERATOR_SCOPES); export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { - roles: ["node"], - scopes: [], + // QR/setup-code bootstrap must hand off both tokens for native onboarding: + // iOS/Android suppress the operator loop while bootstrap auth is active and + // only start it after persisting this bounded operator token. + roles: ["node", "operator"], + scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], }; export function resolveBootstrapProfileScopesForRole( From 9b97e1ef2fd2315b1ea50fbb970c274bc078390b Mon Sep 17 00:00:00 2001 From: Kevin Lin Date: Tue, 19 May 2026 11:39:50 -0700 Subject: [PATCH 09/28] feat(codex): add plugin list enable disable commands (#83293) * feat(codex): add plugin enable disable list commands * fix(codex): escape plugin management output * test(codex): narrow plugin command coverage * fix(codex): gate plugin management writes * test(codex): type command plugin context * docs(codex): document plugin management commands --- CHANGELOG.md | 1 + docs/plugins/codex-harness.md | 2 + docs/plugins/codex-native-plugins.md | 44 ++++- extensions/codex/index.ts | 56 ++++++ extensions/codex/src/command-formatters.ts | 1 + extensions/codex/src/command-handlers.ts | 15 ++ .../src/command-plugins-management.test.ts | 172 ++++++++++++++++++ .../codex/src/command-plugins-management.ts | 137 ++++++++++++++ extensions/codex/src/commands.test.ts | 66 +++++++ 9 files changed, 487 insertions(+), 7 deletions(-) create mode 100644 extensions/codex/src/command-plugins-management.test.ts create mode 100644 extensions/codex/src/command-plugins-management.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 19594e01a779..a9e22553e7f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai - Agents/skills: tighten bundled skill prompts and metadata, quote skill descriptions, refresh current CLI/API guidance, and update embedded sherpa-onnx runtime downloads. - Skills: update the Obsidian skill to target the official `obsidian` CLI and require its registered binary instead of the third-party `obsidian-cli`. - Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach. +- Codex: add `/codex plugins list`, `enable`, and `disable` for managing configured native Codex plugins from chat without editing config by hand. - Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy `interactive`/Slack directive producer APIs as deprecated. - Plugins/subagents: store channel delivery routes as canonical session metadata and deprecate ad hoc subagent hook delivery-origin fields in favor of core route projection. - Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi. diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 4db6761f0b91..4fa60a9132ef 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -202,6 +202,8 @@ Common command routing: | Attach the current chat | `/codex bind [--cwd ]` | | Resume an existing Codex thread | `/codex resume ` | | List or filter Codex threads | `/codex threads [filter]` | +| List native Codex plugins | `/codex plugins list` | +| Enable or disable a configured native Codex plugin | `/codex plugins enable `, `/codex plugins disable ` | | Attach an existing Codex CLI session on a paired node | `/codex sessions --host [filter]`, then `/codex resume --host --bind here` | | Send Codex feedback only | `/codex diagnostics [note]` | | Start an ACP/acpx task | ACP/acpx session commands, not `/codex` | diff --git a/docs/plugins/codex-native-plugins.md b/docs/plugins/codex-native-plugins.md index 4c13f4617b34..1716930e3203 100644 --- a/docs/plugins/codex-native-plugins.md +++ b/docs/plugins/codex-native-plugins.md @@ -81,8 +81,35 @@ config looks like this: } ``` -After changing `codexPlugins`, use `/new`, `/reset`, or restart the gateway so -future Codex harness sessions start with the updated app set. +After changing `codexPlugins`, new Codex conversations pick up the updated app +set automatically. Use `/new` or `/reset` to refresh the current conversation. +A gateway restart is not required for plugin enable or disable changes. + +## Manage plugins from chat + +Use `/codex plugins` when you want to inspect or change configured native Codex +plugins from the same chat where you operate the Codex harness: + +```text +/codex plugins +/codex plugins list +/codex plugins disable google-calendar +/codex plugins enable google-calendar +``` + +`/codex plugins` is an alias for `/codex plugins list`. The list output shows +the configured plugin keys, on/off state, Codex plugin name, and marketplace +from `plugins.entries.codex.config.codexPlugins.plugins`. + +`enable` and `disable` write only to OpenClaw config at +`~/.openclaw/openclaw.json`; they do not edit `~/.codex/config.toml` or install +new Codex plugins. Only the owner or a gateway client with the +`operator.admin` scope can change plugin state. + +Enabling a configured plugin also turns on the global +`codexPlugins.enabled` switch. If the plugin was written disabled because +migration returned `auth_required`, reauthorize the app in Codex before enabling +it in OpenClaw. ## How native plugin setup works @@ -110,7 +137,10 @@ check after migration. Codex harness session setup then computes a restrictive thread app config for the enabled and accessible plugin apps. Thread app config is computed when OpenClaw establishes a Codex harness session -or replaces a stale Codex thread binding. It is not recomputed on every turn. +or replaces a stale Codex thread binding. It is not recomputed on every turn, so +`/codex plugins enable` and `/codex plugins disable` affect new Codex +conversations. Use `/new` or `/reset` when the current conversation should pick +up the updated app set. ## V1 support boundary @@ -228,10 +258,10 @@ apps until ownership and readiness are known. **`app_ownership_ambiguous`:** app inventory only matched by display name, so the app is not exposed to the Codex thread. -**Config changed but the agent cannot see the plugin:** use `/new`, `/reset`, or -restart the gateway. Existing Codex thread bindings keep the app config they -started with until OpenClaw establishes a new harness session or replaces a -stale binding. +**Config changed but the agent cannot see the plugin:** use `/codex plugins +list` to confirm the configured state, then use `/new` or `/reset`. Existing +Codex thread bindings keep the app config they started with until OpenClaw +establishes a new harness session or replaces a stale binding. **Destructive action is declined:** check the global and per-plugin `allow_destructive_actions` values. Even when policy is true, unsafe elicitation diff --git a/extensions/codex/index.ts b/extensions/codex/index.ts index c83ce601b1b3..c32da221340f 100644 --- a/extensions/codex/index.ts +++ b/extensions/codex/index.ts @@ -1,9 +1,11 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation"; import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createCodexAppServerAgentHarness } from "./harness.js"; import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { buildCodexProvider } from "./provider.js"; +import type { CodexPluginsConfigBlock } from "./src/command-plugins-management.js"; import { createCodexCommand } from "./src/commands.js"; import { handleCodexConversationBindingResolved, @@ -53,6 +55,60 @@ export default definePluginEntry({ listCodexCliSessionsOnNode({ runtime: api.runtime, ...params }), resolveCodexCliSessionForBindingOnNode: (params) => resolveCodexCliSessionForBindingOnNode({ runtime: api.runtime, ...params }), + codexPluginsManagementIo: { + readConfig: () => { + const current = (api.runtime.config?.current?.() ?? {}) as OpenClawConfig; + const plugins = (current as Record).plugins; + if (!plugins || typeof plugins !== "object") { + return Promise.resolve({}); + } + const entries = (plugins as Record).entries; + if (!entries || typeof entries !== "object") { + return Promise.resolve({}); + } + const codexEntry = (entries as Record).codex; + if (!codexEntry || typeof codexEntry !== "object") { + return Promise.resolve({}); + } + const config = (codexEntry as Record).config; + if (!config || typeof config !== "object") { + return Promise.resolve({}); + } + const codexPlugins = (config as Record).codexPlugins; + if (!codexPlugins || typeof codexPlugins !== "object") { + return Promise.resolve({}); + } + const declared = (codexPlugins as Record).plugins; + if (!declared || typeof declared !== "object") { + return Promise.resolve({ + enabled: (codexPlugins as Record).enabled === true, + }); + } + return Promise.resolve({ + enabled: (codexPlugins as Record).enabled === true, + plugins: declared as Record, + }); + }, + mutate: async (update) => { + await mutateConfigFile({ + mutate: (draft) => { + const root = draft as Record; + root.plugins = (root.plugins ?? {}) as Record; + const pluginsBlock = root.plugins as Record; + pluginsBlock.entries = (pluginsBlock.entries ?? {}) as Record; + const entries = pluginsBlock.entries as Record; + entries.codex = (entries.codex ?? {}) as Record; + const codexEntry = entries.codex as Record; + codexEntry.config = (codexEntry.config ?? {}) as Record; + const config = codexEntry.config as Record; + config.codexPlugins = (config.codexPlugins ?? {}) as Record; + const codexPlugins = config.codexPlugins as Record; + codexPlugins.plugins = (codexPlugins.plugins ?? {}) as Record; + update(codexPlugins as CodexPluginsConfigBlock); + }, + }); + }, + }, }, }), ); diff --git a/extensions/codex/src/command-formatters.ts b/extensions/codex/src/command-formatters.ts index 8874001257de..828f79f7769e 100644 --- a/extensions/codex/src/command-formatters.ts +++ b/extensions/codex/src/command-formatters.ts @@ -320,6 +320,7 @@ export function buildHelp(): string { "- /codex account", "- /codex mcp", "- /codex skills", + "- /codex plugins [list|enable|disable]", ].join("\n"); } diff --git a/extensions/codex/src/command-handlers.ts b/extensions/codex/src/command-handlers.ts index 6a78cb96e204..6492b7a1ac35 100644 --- a/extensions/codex/src/command-handlers.ts +++ b/extensions/codex/src/command-handlers.ts @@ -28,6 +28,10 @@ import { formatThreads, readString, } from "./command-formatters.js"; +import { + handleCodexPluginsSubcommand, + type CodexPluginsManagementIO, +} from "./command-plugins-management.js"; import { codexControlRequest, readCodexStatusProbes, @@ -80,6 +84,7 @@ export type CodexCommandDeps = { stopCodexConversationTurn: typeof stopCodexConversationTurn; listCodexCliSessionsOnNode: ListCodexCliSessionsOnNodeFn; resolveCodexCliSessionForBindingOnNode: ResolveCodexCliSessionForBindingOnNodeFn; + codexPluginsManagementIo?: CodexPluginsManagementIO; }; type CodexControlRequestFn = ( @@ -228,6 +233,16 @@ export async function handleCodexSubcommand( if (normalized === "help") { return { text: buildHelp() }; } + if (normalized === "plugins") { + if (!deps.codexPluginsManagementIo) { + return { + text: + "Codex sub-plugin management is not wired up (codexPluginsManagementIo dep is undefined). " + + "Edit ~/.openclaw/openclaw.json or use `openclaw config patch` until the runtime exposes the IO.", + }; + } + return await handleCodexPluginsSubcommand(ctx, rest, deps.codexPluginsManagementIo); + } if (normalized === "status") { if (rest.length > 0) { return { text: "Usage: /codex status" }; diff --git a/extensions/codex/src/command-plugins-management.test.ts b/extensions/codex/src/command-plugins-management.test.ts new file mode 100644 index 000000000000..99a0fcfa55a8 --- /dev/null +++ b/extensions/codex/src/command-plugins-management.test.ts @@ -0,0 +1,172 @@ +import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; +import { describe, expect, it } from "vitest"; +import { + handleCodexPluginsSubcommand, + type CodexPluginsConfigBlock, + type CodexPluginConfigEntry, + type CodexPluginsManagementIO, +} from "./command-plugins-management.js"; + +function inMemoryIO( + initial: Record = {}, + options: { enabled?: boolean } = { enabled: true }, +): CodexPluginsManagementIO & { + current: () => Record; + currentConfig: () => CodexPluginsConfigBlock; +} { + const store: CodexPluginsConfigBlock = { + enabled: options.enabled, + plugins: JSON.parse(JSON.stringify(initial)), + }; + return { + current: () => JSON.parse(JSON.stringify(store.plugins ?? {})), + currentConfig: () => JSON.parse(JSON.stringify(store)), + readConfig: () => Promise.resolve(JSON.parse(JSON.stringify(store))), + mutate: async (update) => { + update(store); + }, + }; +} + +const fakeCtx: PluginCommandContext = { + args: "", + config: {}, + channel: "test", + isAuthorizedSender: true, + senderIsOwner: true, + commandBody: "/codex plugins", + requestConversationBinding: async () => ({ status: "error", message: "unused" }), + detachConversationBinding: async () => ({ removed: false }), + getCurrentConversationBinding: async () => null, +}; + +describe("Codex /codex plugins subcommand", () => { + it("lists a configured plugin with its enabled marker and explains the underlying file", async () => { + const io = inMemoryIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + + const result = await handleCodexPluginsSubcommand(fakeCtx, ["list"], io); + expect(result.text).toContain("ON google-calendar"); + expect(result.text).toContain("openclaw.json"); + }); + + it("lists effective disabled status when the global plugin switch is off", async () => { + const io = inMemoryIO( + { + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }, + { enabled: false }, + ); + + const result = await handleCodexPluginsSubcommand(fakeCtx, ["list"], io); + expect(result.text).toContain("OFF google-calendar"); + expect(result.text).toContain("Global codexPlugins.enabled is off"); + }); + + it("enables and disables a configured plugin and reflects the change in subsequent reads", async () => { + const io = inMemoryIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + + const disabled = await handleCodexPluginsSubcommand( + fakeCtx, + ["disable", "google-calendar"], + io, + ); + expect(disabled.text).toContain("disabled"); + expect(io.current()["google-calendar"]?.enabled).toBe(false); + + const enabled = await handleCodexPluginsSubcommand(fakeCtx, ["enable", "google-calendar"], io); + expect(enabled.text).toContain("enabled"); + expect(io.currentConfig().enabled).toBe(true); + expect(io.current()["google-calendar"]?.enabled).toBe(true); + }); + + it("rejects enable and disable from non-owner non-admin callers", async () => { + const io = inMemoryIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + const ctx = { ...fakeCtx, senderIsOwner: false, gatewayClientScopes: ["operator.write"] }; + + const result = await handleCodexPluginsSubcommand(ctx, ["disable", "google-calendar"], io); + expect(result.text).toContain("Only an owner or operator.admin"); + expect(io.current()["google-calendar"]?.enabled).toBe(true); + }); + + it("allows operator.admin gateway callers to enable and disable", async () => { + const io = inMemoryIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + const ctx = { ...fakeCtx, senderIsOwner: false, gatewayClientScopes: ["operator.admin"] }; + + const result = await handleCodexPluginsSubcommand(ctx, ["disable", "google-calendar"], io); + expect(result.text).toContain("disabled"); + expect(io.current()["google-calendar"]?.enabled).toBe(false); + }); + + it("escapes configured plugin fields before listing them in chat", async () => { + const io = inMemoryIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar_@team_*name*", + }, + }); + + const result = await handleCodexPluginsSubcommand(fakeCtx, ["list"], io); + expect(result.text).toContain("google-calendar"); + expect(result.text).toContain("google-calendar_@team_∗name∗"); + expect(result.text).not.toContain("@team"); + expect(result.text).not.toContain("*name*"); + }); + + it("reports when a target plugin is not configured rather than silently no-oping", async () => { + const io = inMemoryIO(); + const result = await handleCodexPluginsSubcommand(fakeCtx, ["disable", "chrome_@ops"], io); + expect(result.text).toContain("not configured"); + expect(result.text).toContain("chrome_@ops"); + expect(result.text).not.toContain("@ops"); + }); + + it("returns usage when list, enable, or disable receives the wrong arity", async () => { + const io = inMemoryIO(); + const listResult = await handleCodexPluginsSubcommand(fakeCtx, ["list", "chrome"], io); + expect(listResult.text).toContain("Usage: /codex plugins list"); + + const result = await handleCodexPluginsSubcommand(fakeCtx, ["disable"], io); + expect(result.text).toContain("Usage: /codex plugins disable "); + expect(result.presentation).toBeUndefined(); + + const enableResult = await handleCodexPluginsSubcommand(fakeCtx, ["enable"], io); + expect(enableResult.text).toContain("Usage: /codex plugins enable "); + expect(enableResult.presentation).toBeUndefined(); + + const extraResult = await handleCodexPluginsSubcommand( + fakeCtx, + ["enable", "google-calendar", "extra"], + io, + ); + expect(extraResult.text).toContain("Usage: /codex plugins enable "); + }); +}); diff --git a/extensions/codex/src/command-plugins-management.ts b/extensions/codex/src/command-plugins-management.ts new file mode 100644 index 000000000000..1a53942783bf --- /dev/null +++ b/extensions/codex/src/command-plugins-management.ts @@ -0,0 +1,137 @@ +import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry"; +import { formatCodexDisplayText } from "./command-formatters.js"; + +/** + * Lightweight read/write surface over the Openclaw config file. Plugged in by + * the command registration site so this module stays decoupled from the + * concrete `mutateConfigFile` import in tests. + */ +export type CodexPluginsManagementIO = { + readConfig: () => Promise<{ + enabled?: boolean; + plugins?: Record; + }>; + mutate: (update: (block: CodexPluginsConfigBlock) => void) => Promise; +}; + +export type CodexPluginConfigEntry = { + enabled?: boolean; + marketplaceName?: string; + pluginName?: string; + allow_destructive_actions?: boolean; +}; + +export type CodexPluginsConfigBlock = { + enabled?: boolean; + plugins?: Record; +}; + +// Plugin lifecycle changes (enable/disable) write to openclaw.json +// synchronously. The Codex app-server picks up the new policy when the next +// thread starts; in-flight conversations keep the old policy until /new or +// /reset. A full gateway restart is NOT needed. +const POLICY_REFRESH_HINT = + "New Codex conversations pick this up automatically. Use /new or /reset to refresh the current one."; + +export async function handleCodexPluginsSubcommand( + ctx: PluginCommandContext, + rest: string[], + io: CodexPluginsManagementIO, +): Promise { + const [verb = "list", ...args] = rest; + const normalized = verb.toLowerCase(); + + if (normalized === "list") { + if (args.length > 0) { + return { text: "Usage: /codex plugins list" }; + } + const current = await io.readConfig(); + return { + text: formatPluginList(current.plugins ?? {}, { globalEnabled: current.enabled === true }), + }; + } + + const target = args[0]; + if (normalized === "enable" || normalized === "disable") { + if (!target || args.length > 1) { + return { text: `Usage: /codex plugins ${normalized} ` }; + } + if (!canMutateCodexPlugins(ctx)) { + return { + text: `Only an owner or operator.admin gateway client can run /codex plugins ${normalized}.`, + }; + } + const wantEnabled = normalized === "enable"; + const current = (await io.readConfig()).plugins ?? {}; + if (!current[target]) { + return { + text: `Codex sub-plugin '${formatCodexDisplayText(target)}' is not configured. Run '/codex plugins list' to see configured plugins.`, + }; + } + await io.mutate((block) => { + if (wantEnabled) { + block.enabled = true; + } + block.plugins ??= {}; + block.plugins[target] = { ...block.plugins[target], enabled: wantEnabled }; + }); + return { + text: `${formatCodexDisplayText(target)}: ${wantEnabled ? "enabled" : "disabled"} in openclaw.json. ${POLICY_REFRESH_HINT}`, + }; + } + + return { + text: `Unknown /codex plugins subcommand: ${formatCodexDisplayText(verb)}\n\n${buildPluginsHelp()}`, + }; +} + +function canMutateCodexPlugins(ctx: PluginCommandContext): boolean { + if (ctx.senderIsOwner === true) { + return true; + } + return ctx.gatewayClientScopes?.includes("operator.admin") === true; +} + +export function buildPluginsHelp(): string { + return [ + "Codex sub-plugin management (writes only to ~/.openclaw/openclaw.json, never to ~/.codex/config.toml):", + "- /codex plugins (alias for list)", + "- /codex plugins list show all configured Codex sub-plugins", + "- /codex plugins enable enable a configured sub-plugin", + "- /codex plugins disable disable a configured sub-plugin", + ].join("\n"); +} + +export function formatPluginList( + plugins: Record, + options: { globalEnabled?: boolean } = {}, +): string { + const globalEnabled = options.globalEnabled === true; + const keys = Object.keys(plugins).toSorted(); + if (keys.length === 0) { + return "No Codex sub-plugins configured under plugins.entries.codex.config.codexPlugins.plugins"; + } + const rows = keys.map((key) => { + const entry = plugins[key] ?? {}; + const state = globalEnabled && entry.enabled !== false ? "ON " : "OFF"; + const displayKey = formatCodexDisplayText(key); + const pluginName = formatCodexDisplayText(entry.pluginName ?? key); + const marketplace = formatCodexDisplayText(entry.marketplaceName ?? "?"); + return { displayKey, state, pluginName, marketplace }; + }); + const keyW = Math.max(...rows.map((r) => r.displayKey.length)); + const pluginW = Math.max(...rows.map((r) => r.pluginName.length)); + return [ + "Codex sub-plugins in Openclaw config (~/.openclaw/openclaw.json):", + "", + ...rows.map( + (r) => + ` ${r.state} ${r.displayKey.padEnd(keyW)} ${r.pluginName.padEnd(pluginW)} [${r.marketplace}]`, + ), + "", + ...(globalEnabled + ? [] + : ["Global codexPlugins.enabled is off; configured sub-plugins are inactive.", ""]), + "New Codex conversations pick up policy changes automatically; /new or /reset to refresh the current one.", + ].join("\n"); +} diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index 08428d818806..d767e6b91fcc 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -21,6 +21,11 @@ import { resetCodexDiagnosticsFeedbackStateForTests, type CodexCommandDeps, } from "./command-handlers.js"; +import type { + CodexPluginsConfigBlock, + CodexPluginConfigEntry, + CodexPluginsManagementIO, +} from "./command-plugins-management.js"; import { handleCodexCommand } from "./commands.js"; let tempDir: string; @@ -73,6 +78,27 @@ function createDeps(overrides: Partial = {}): Partial = {}, + options: { enabled?: boolean } = { enabled: true }, +): CodexPluginsManagementIO & { + current: () => Record; + currentConfig: () => CodexPluginsConfigBlock; +} { + const store: CodexPluginsConfigBlock = { + enabled: options.enabled, + plugins: JSON.parse(JSON.stringify(initial)), + }; + return { + current: () => JSON.parse(JSON.stringify(store.plugins ?? {})), + currentConfig: () => JSON.parse(JSON.stringify(store)), + readConfig: () => Promise.resolve(JSON.parse(JSON.stringify(store))), + mutate: async (update) => { + update(store); + }, + }; +} + function readDiagnosticsConfirmationToken( result: PluginCommandResult, commandPrefix = "/codex diagnostics", @@ -216,6 +242,46 @@ describe("codex command", () => { expect(result.text).not.toContain("<@U123>"); }); + it("lists Codex sub-plugins through the /codex plugins command surface", async () => { + const codexPluginsManagementIo = inMemoryCodexPluginsIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + + const result = await handleCodexCommand(createContext("plugins list"), { + deps: createDeps({ codexPluginsManagementIo }), + }); + + expectResultTextContains(result, "ON google-calendar"); + expectResultTextContains(result, "openclaw.json"); + }); + + it("enables and disables Codex sub-plugins through the /codex plugins command surface", async () => { + const codexPluginsManagementIo = inMemoryCodexPluginsIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + + const disabled = await handleCodexCommand(createContext("plugins disable google-calendar"), { + deps: createDeps({ codexPluginsManagementIo }), + }); + expectResultTextContains(disabled, "google-calendar: disabled in openclaw.json"); + expect(codexPluginsManagementIo.current()["google-calendar"]?.enabled).toBe(false); + + const enabled = await handleCodexCommand(createContext("plugins enable google-calendar"), { + deps: createDeps({ codexPluginsManagementIo }), + }); + expectResultTextContains(enabled, "google-calendar: enabled in openclaw.json"); + expect(codexPluginsManagementIo.currentConfig().enabled).toBe(true); + expect(codexPluginsManagementIo.current()["google-calendar"]?.enabled).toBe(true); + }); + it("attaches the current session to an existing Codex thread", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const requests: Array<{ method: string; params: unknown }> = []; From edd7c8e4a1d84655541f8cea04ead7611edb0739 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Tue, 19 May 2026 22:04:33 +0300 Subject: [PATCH 10/28] [codex] fix iOS TestFlight release archive (#84255) Merged via squash. Prepared head SHA: c59a81a4bf9a2c4fdec7e8d994e319075e167143 Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Reviewed-by: @ngutman --- CHANGELOG.md | 1 + apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift | 1 + apps/ios/Sources/Onboarding/OnboardingWizardView.swift | 2 +- apps/ios/Sources/Settings/SettingsTab.swift | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e22553e7f1..cb349e2b095f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - WhatsApp: drain pending outbound deliveries on a 30s periodic timer in addition to the reconnect handler, so messages enqueued while the provider is already connected no longer wait for the next reconnect to send. (#79083) Thanks @Oviemudiaga. - CLI/TUI: include gateway plugin slash commands in TUI autocomplete, so connected sessions can suggest plugin-owned commands exposed by the running Gateway. (#83640) Thanks @se7en-agent. - Gateway/mobile: restore QR setup-code handoff of bounded operator tokens for iOS and Android onboarding while keeping admin and pairing scopes out of bootstrap. (#83684) Thanks @ngutman. +- iOS: repair Release archive compilation for the TestFlight build. (#84255) Thanks @ngutman. ## 2026.5.19 diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift index 0a82bbe3b81d..1db8a9345c34 100644 --- a/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift +++ b/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift @@ -2,6 +2,7 @@ import Foundation import OpenClawKit enum GatewayOnboardingReset { + @MainActor static func reset( appModel: NodeAppModel, instanceId: String, diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 856ee745fcfe..0e85c542e683 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -1017,7 +1017,7 @@ struct OnboardingWizardView: View { private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String { if problem.suggestsOnboardingReset { return "Scan QR again" } - problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection" + return problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection" } private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async { diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 8796f5f9d3ef..904108406abb 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -1058,7 +1058,7 @@ struct SettingsTab: View { private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String { if problem.suggestsOnboardingReset { return "Reset onboarding" } - problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection" + return problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection" } private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async { From 28beea9e881ce3be7497f60c221e3fe51ba34d99 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 19 May 2026 12:15:33 -0700 Subject: [PATCH 11/28] perf(plugins): thread explicit discovery to avoid redundant filesystem walks (#75451) Add optional discovery parameter to loadBundledCapabilityRuntimeRegistry, resolveBundledPluginSources, and listChannelCatalogEntries so callers that already hold a PluginDiscoveryResult can skip redundant filesystem walks. In contracts/registry.ts, the retry loop in loadScopedCapabilityRuntimeRegistryEntries computes discovery once and shares it across retry attempts (function-scoped, not module-scoped). discoverOpenClawPlugins() itself remains stateless with no hidden cache. Closes #82308 Signed-off-by: Sebastien Tardif --- CHANGELOG.md | 1 + src/plugins/bundled-capability-runtime.ts | 7 +++---- src/plugins/bundled-sources.ts | 10 +++++----- src/plugins/channel-catalog-registry.ts | 16 ++++++++++------ src/plugins/contracts/registry.ts | 3 +++ 5 files changed, 22 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb349e2b095f..e666e6e77430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/perf: thread explicit plugin discovery results through `loadBundledCapabilityRuntimeRegistry`, `resolveBundledPluginSources`, and `listChannelCatalogEntries` so callers that already hold a discovery result skip redundant filesystem walks. Thanks @SebTardif. - harden update restart script creation [AI]. (#84088) Thanks @pgondhi987. - Docker: keep the bundled Codex plugin in official release image keep lists so the default OpenAI agent harness remains available after Docker pruning. Fixes #83613. (#83626) Thanks @YuanHanzhong. - CLI/channels: preserve the first line of `openclaw channels logs` output when the rolling tail window starts exactly on a line boundary, mirroring the already-fixed `readLogSlice` behavior in `src/logging/log-tail.ts`. diff --git a/src/plugins/bundled-capability-runtime.ts b/src/plugins/bundled-capability-runtime.ts index b843e2104fe0..ba79a8c297ca 100644 --- a/src/plugins/bundled-capability-runtime.ts +++ b/src/plugins/bundled-capability-runtime.ts @@ -8,7 +8,7 @@ import { } from "./bundled-compat.js"; import { resolveBundledPluginRepoEntryPath } from "./bundled-plugin-metadata.js"; import { createCapturedPluginRegistration } from "./captured-registration.js"; -import { discoverOpenClawPlugins } from "./discovery.js"; +import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js"; import type { PluginLoadOptions } from "./loader.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; import { unwrapDefaultModuleExport } from "./module-export.js"; @@ -196,6 +196,7 @@ export function loadBundledCapabilityRuntimeRegistry(params: { pluginIds: readonly string[]; env?: PluginLoadOptions["env"]; pluginSdkResolution?: PluginSdkResolutionPreference; + discovery?: PluginDiscoveryResult; }) { const env = params.env ?? process.env; const pluginIds = new Set(params.pluginIds); @@ -232,9 +233,7 @@ export function loadBundledCapabilityRuntimeRegistry(params: { }); }; - const discovery = discoverOpenClawPlugins({ - env, - }); + const discovery = params.discovery ?? discoverOpenClawPlugins({ env }); const manifestRegistry = loadPluginManifestRegistry({ config: buildBundledCapabilityRuntimeConfig(params.pluginIds, env), env, diff --git a/src/plugins/bundled-sources.ts b/src/plugins/bundled-sources.ts index 0fb0ccbf937e..b18a89d04de2 100644 --- a/src/plugins/bundled-sources.ts +++ b/src/plugins/bundled-sources.ts @@ -1,5 +1,5 @@ import { normalizeOptionalString } from "../shared/string-coerce.js"; -import { discoverOpenClawPlugins } from "./discovery.js"; +import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js"; import { loadPluginManifest } from "./manifest.js"; export type BundledPluginSource = { @@ -38,11 +38,11 @@ export function resolveBundledPluginSources(params: { workspaceDir?: string; /** Use an explicit env when bundled roots should resolve independently from process.env. */ env?: NodeJS.ProcessEnv; + discovery?: PluginDiscoveryResult; }): Map { - const discovery = discoverOpenClawPlugins({ - workspaceDir: params.workspaceDir, - env: params.env, - }); + const discovery = + params.discovery ?? + discoverOpenClawPlugins({ workspaceDir: params.workspaceDir, env: params.env }); const bundled = new Map(); for (const candidate of discovery.candidates) { diff --git a/src/plugins/channel-catalog-registry.ts b/src/plugins/channel-catalog-registry.ts index 8b336e2ea3db..e32dacc3bd3c 100644 --- a/src/plugins/channel-catalog-registry.ts +++ b/src/plugins/channel-catalog-registry.ts @@ -1,5 +1,5 @@ import type { PluginInstallRecord } from "../config/types.plugins.js"; -import { discoverOpenClawPlugins } from "./discovery.js"; +import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js"; import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import { @@ -31,14 +31,18 @@ export function listChannelCatalogEntries( * Bundled-only callers skip the load to avoid the disk read. */ installRecords?: Record; + discovery?: PluginDiscoveryResult; } = {}, ): PluginChannelCatalogEntry[] { const installRecords = resolveInstallRecords(params); - return discoverOpenClawPlugins({ - workspaceDir: params.workspaceDir, - env: params.env, - ...(installRecords && Object.keys(installRecords).length > 0 ? { installRecords } : {}), - }).candidates.flatMap((candidate) => { + const discovery = + params.discovery ?? + discoverOpenClawPlugins({ + workspaceDir: params.workspaceDir, + env: params.env, + ...(installRecords && Object.keys(installRecords).length > 0 ? { installRecords } : {}), + }); + return discovery.candidates.flatMap((candidate) => { if (params.origin && candidate.origin !== params.origin) { return []; } diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 0a2f33a9b42c..a65715dbcf14 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -1,6 +1,7 @@ import { normalizeProviderId } from "../../agents/provider-id.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { loadBundledCapabilityRuntimeRegistry } from "../bundled-capability-runtime.js"; +import { discoverOpenClawPlugins } from "../discovery.js"; import { loadPluginManifestRegistry } from "../manifest-registry.js"; import { resolveManifestContractPluginIds } from "../plugin-registry.js"; import { resolveBundledExplicitProviderContractsFromPublicArtifacts } from "../provider-contract-public-artifacts.js"; @@ -255,12 +256,14 @@ function loadScopedCapabilityRuntimeRegistryEntries(params: { plugin: BundledCapabilityRuntimeRegistry["plugins"][number], ) => readonly string[]; }): T[] { + const discovery = discoverOpenClawPlugins({}); let lastFailure: Error | undefined; for (let attempt = 0; attempt < 2; attempt += 1) { const registry = loadBundledCapabilityRuntimeRegistry({ pluginIds: [params.pluginId], pluginSdkResolution: "dist", + discovery, }); const entries = params.loadEntries(registry); if (entries.length > 0) { From f5f0b2c7c9e072975dc6e8206b79944a5342dfc6 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 19 May 2026 12:35:01 -0700 Subject: [PATCH 12/28] perf(plugins): extend discovery threading to loader, manifest registry, installed-index, and config contracts (#84258) Follow-up to #75451. Threads optional discovery?: PluginDiscoveryResult through the remaining helpers that still call discoverOpenClawPlugins internally during startup: - loadOpenClawPlugins / loadOpenClawPluginCliRegistry (src/plugins/loader.ts): add discovery? to PluginLoadOptions and consult it before falling back to an internal scan at both call sites. - loadPluginManifestRegistry (src/plugins/manifest-registry.ts): accept discovery? as a more ergonomic alternative to the existing candidates? / diagnostics? pair; candidates? still wins when both are supplied. - resolveInstalledPluginIndexRegistry (src/plugins/installed-plugin-index-registry.ts): add discovery? to LoadInstalledPluginIndexParams and use it when candidates aren't supplied. - resolvePluginConfigContractsById (src/plugins/config-contracts.ts): add discovery? and thread it into the bundled-fallback discovery call. Add discovery-threading.test.ts asserting each entry point skips its internal discoverOpenClawPlugins call when discovery is supplied, calls it when nothing is supplied, and prefers explicit candidates over discovery when both are present (6 tests, all pass). discoverOpenClawPlugins remains stateless; sharing is function-scoped per src/plugins/CLAUDE.md guidance. Backward compatible: every change is additive (new optional param). --- src/plugins/config-contracts.ts | 19 ++++-- src/plugins/discovery-threading.test.ts | 63 +++++++++++++++++++ .../installed-plugin-index-registry.ts | 14 +++-- src/plugins/installed-plugin-index-types.ts | 9 ++- src/plugins/loader.ts | 33 +++++++--- src/plugins/manifest-registry.ts | 18 +++++- 6 files changed, 132 insertions(+), 24 deletions(-) create mode 100644 src/plugins/discovery-threading.test.ts diff --git a/src/plugins/config-contracts.ts b/src/plugins/config-contracts.ts index 21cab4903681..610f455c5735 100644 --- a/src/plugins/config-contracts.ts +++ b/src/plugins/config-contracts.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isRecord } from "../utils.js"; -import { discoverOpenClawPlugins } from "./discovery.js"; +import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; import type { PluginManifestConfigContracts } from "./manifest.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; @@ -114,6 +114,13 @@ export function resolvePluginConfigContractsById(params: { fallbackToBundledMetadataForResolvedBundled?: boolean; fallbackBundledPluginIds?: readonly string[]; pluginIds: readonly string[]; + /** + * Pre-computed discovery result. When supplied, the bundled-fallback path + * skips its internal `discoverOpenClawPlugins` call so callers sharing a + * discovery snapshot across registry helpers avoid redundant filesystem + * walks. + */ + discovery?: PluginDiscoveryResult; }): ReadonlyMap { const matches = new Map(); const pluginIds = [ @@ -132,10 +139,12 @@ export function resolvePluginConfigContractsById(params: { if (bundledContractFallbacks.has(pluginId)) { return bundledContractFallbacks.get(pluginId); } - const discovery = discoverOpenClawPlugins({ - workspaceDir: params.workspaceDir, - env: params.env, - }); + const discovery = + params.discovery ?? + discoverOpenClawPlugins({ + workspaceDir: params.workspaceDir, + env: params.env, + }); const registry = loadPluginManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, diff --git a/src/plugins/discovery-threading.test.ts b/src/plugins/discovery-threading.test.ts new file mode 100644 index 000000000000..15d06741a395 --- /dev/null +++ b/src/plugins/discovery-threading.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginDiscoveryResult } from "./discovery.js"; + +const discoverOpenClawPluginsMock = vi.fn(); + +vi.mock("./discovery.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + discoverOpenClawPlugins: (...args: unknown[]) => discoverOpenClawPluginsMock(...args), + }; +}); + +const { loadPluginManifestRegistry } = await import("./manifest-registry.js"); +const { resolveInstalledPluginIndexRegistry } = + await import("./installed-plugin-index-registry.js"); + +const emptyDiscovery: PluginDiscoveryResult = { candidates: [], diagnostics: [] }; + +describe("discovery threading", () => { + beforeEach(() => { + discoverOpenClawPluginsMock.mockReset(); + discoverOpenClawPluginsMock.mockReturnValue(emptyDiscovery); + }); + + describe("loadPluginManifestRegistry", () => { + it("skips internal discoverOpenClawPlugins when discovery is supplied", () => { + loadPluginManifestRegistry({ discovery: emptyDiscovery }); + expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + + it("calls discoverOpenClawPlugins when neither discovery nor candidates supplied", () => { + loadPluginManifestRegistry({}); + expect(discoverOpenClawPluginsMock).toHaveBeenCalledTimes(1); + }); + + it("prefers explicit candidates over discovery when both are supplied", () => { + loadPluginManifestRegistry({ candidates: [], diagnostics: [], discovery: emptyDiscovery }); + expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + }); + + describe("resolveInstalledPluginIndexRegistry", () => { + it("skips internal discoverOpenClawPlugins when discovery is supplied", () => { + resolveInstalledPluginIndexRegistry({ discovery: emptyDiscovery, installRecords: {} }); + expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + + it("calls discoverOpenClawPlugins when neither discovery nor candidates supplied", () => { + resolveInstalledPluginIndexRegistry({ installRecords: {} }); + expect(discoverOpenClawPluginsMock).toHaveBeenCalledTimes(1); + }); + + it("prefers explicit candidates over discovery when both are supplied", () => { + resolveInstalledPluginIndexRegistry({ + candidates: [], + discovery: emptyDiscovery, + installRecords: {}, + }); + expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/plugins/installed-plugin-index-registry.ts b/src/plugins/installed-plugin-index-registry.ts index bbc71e77ce61..d61053545e58 100644 --- a/src/plugins/installed-plugin-index-registry.ts +++ b/src/plugins/installed-plugin-index-registry.ts @@ -25,12 +25,14 @@ export function resolveInstalledPluginIndexRegistry(params: LoadInstalledPluginI const normalized = normalizePluginsConfig(params.config?.plugins); const installRecords = params.installRecords ?? loadInstalledPluginIndexInstallRecordsSync({ env: params.env }); - const discovery = discoverOpenClawPlugins({ - workspaceDir: params.workspaceDir, - extraPaths: normalized.loadPaths, - env: params.env, - installRecords, - }); + const discovery = + params.discovery ?? + discoverOpenClawPlugins({ + workspaceDir: params.workspaceDir, + extraPaths: normalized.loadPaths, + env: params.env, + installRecords, + }); return { candidates: discovery.candidates, registry: loadPluginManifestRegistry({ diff --git a/src/plugins/installed-plugin-index-types.ts b/src/plugins/installed-plugin-index-types.ts index 6b308ac95340..5206bfe57e1b 100644 --- a/src/plugins/installed-plugin-index-types.ts +++ b/src/plugins/installed-plugin-index-types.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "../config/types.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { PluginCompatCode } from "./compat/registry.js"; -import type { PluginCandidate } from "./discovery.js"; +import type { PluginCandidate, PluginDiscoveryResult } from "./discovery.js"; import type { PluginInstallSourceInfo } from "./install-source-info.js"; import type { InstalledPluginFileSignature } from "./installed-plugin-index-hash.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; @@ -130,6 +130,13 @@ export type LoadInstalledPluginIndexParams = { installRecords?: Record; candidates?: PluginCandidate[]; diagnostics?: PluginDiagnostic[]; + /** + * Pre-computed discovery result. When supplied (and `candidates` is not), + * the internal `discoverOpenClawPlugins` call is skipped. Callers sharing a + * discovery snapshot across registry helpers in the same flow should supply + * this to avoid redundant filesystem walks. + */ + discovery?: PluginDiscoveryResult; now?: () => Date; }; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index d26f760dc9e0..dab075b8a6f9 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -50,7 +50,11 @@ import { type NormalizedPluginsConfig, } from "./config-state.js"; import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; -import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; +import { + discoverOpenClawPlugins, + type PluginCandidate, + type PluginDiscoveryResult, +} from "./discovery.js"; import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; import { getGlobalHookRunner, initializeGlobalHookRunner } from "./hook-runner-global.js"; import { toSafeImportPath } from "./import-specifier.js"; @@ -198,6 +202,14 @@ export type PluginLoadOptions = { loadModules?: boolean; throwOnLoadError?: boolean; manifestRegistry?: PluginManifestRegistry; + /** + * Pre-computed plugin discovery result. When supplied, internal calls to + * `discoverOpenClawPlugins` are skipped. Callers in the same startup flow + * can compute one discovery result and share it across loader entry points + * to eliminate redundant filesystem walks. Ignored when `manifestRegistry` + * is also provided (the registry already implies a discovery snapshot). + */ + discovery?: PluginDiscoveryResult; }; function detailPluginStartupTrace( @@ -1690,12 +1702,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi candidates: createPluginCandidatesFromManifestRegistry(suppliedManifestRegistry), diagnostics: [] as PluginDiagnostic[], } - : discoverOpenClawPlugins({ + : (options.discovery ?? + discoverOpenClawPlugins({ workspaceDir: options.workspaceDir, extraPaths: normalized.loadPaths, env, installRecords, - }); + })); const manifestRegistry = suppliedManifestRegistry ?? loadPluginManifestRegistry({ @@ -2559,12 +2572,14 @@ export async function loadOpenClawPluginCliRegistry( activateGlobalSideEffects: false, }); - const discovery = discoverOpenClawPlugins({ - workspaceDir: options.workspaceDir, - extraPaths: normalized.loadPaths, - env, - installRecords, - }); + const discovery = + options.discovery ?? + discoverOpenClawPlugins({ + workspaceDir: options.workspaceDir, + extraPaths: normalized.loadPaths, + env, + installRecords, + }); const manifestRegistry = loadPluginManifestRegistry({ config: cfg, workspaceDir: options.workspaceDir, diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 2a7efc2bd087..e354d5fcaed1 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -10,7 +10,11 @@ import { resolveUserPath } from "../utils.js"; import { resolveCompatibilityHostVersion } from "../version.js"; import { loadBundleManifest } from "./bundle-manifest.js"; import { normalizePluginsConfigWithResolver } from "./config-policy.js"; -import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; +import { + discoverOpenClawPlugins, + type PluginCandidate, + type PluginDiscoveryResult, +} from "./discovery.js"; import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js"; @@ -916,6 +920,13 @@ export function loadPluginManifestRegistry( diagnostics?: PluginDiagnostic[]; installRecords?: Record; bundledChannelConfigCollector?: BundledChannelConfigCollector; + /** + * Pre-computed discovery result. When supplied (and `candidates` is not), + * the internal `discoverOpenClawPlugins` call is skipped. Callers sharing + * a discovery snapshot across multiple registry helpers in the same flow + * should supply this to avoid redundant filesystem walks. + */ + discovery?: PluginDiscoveryResult; } = {}, ): PluginManifestRegistry { const config = params.config ?? {}; @@ -936,12 +947,13 @@ export function loadPluginManifestRegistry( candidates: params.candidates, diagnostics: params.diagnostics ?? [], } - : discoverOpenClawPlugins({ + : (params.discovery ?? + discoverOpenClawPlugins({ workspaceDir: params.workspaceDir, extraPaths: normalized.loadPaths, env, installRecords: getInstallRecords(), - }); + })); const diagnostics: PluginDiagnostic[] = [...discovery.diagnostics]; const candidates: PluginCandidate[] = discovery.candidates; const records: PluginManifestRecord[] = []; From 3d96111a5afe377b529c9bb5a9db510d74607344 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 19 May 2026 12:35:27 -0700 Subject: [PATCH 13/28] =?UTF-8?q?Revert=20"perf(plugins):=20extend=20disco?= =?UTF-8?q?very=20threading=20to=20loader,=20manifest=20registr=E2=80=A6"?= =?UTF-8?q?=20(#84278)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit f5f0b2c7c9e072975dc6e8206b79944a5342dfc6. --- src/plugins/config-contracts.ts | 19 ++---- src/plugins/discovery-threading.test.ts | 63 ------------------- .../installed-plugin-index-registry.ts | 14 ++--- src/plugins/installed-plugin-index-types.ts | 9 +-- src/plugins/loader.ts | 33 +++------- src/plugins/manifest-registry.ts | 18 +----- 6 files changed, 24 insertions(+), 132 deletions(-) delete mode 100644 src/plugins/discovery-threading.test.ts diff --git a/src/plugins/config-contracts.ts b/src/plugins/config-contracts.ts index 610f455c5735..21cab4903681 100644 --- a/src/plugins/config-contracts.ts +++ b/src/plugins/config-contracts.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isRecord } from "../utils.js"; -import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js"; +import { discoverOpenClawPlugins } from "./discovery.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; import type { PluginManifestConfigContracts } from "./manifest.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; @@ -114,13 +114,6 @@ export function resolvePluginConfigContractsById(params: { fallbackToBundledMetadataForResolvedBundled?: boolean; fallbackBundledPluginIds?: readonly string[]; pluginIds: readonly string[]; - /** - * Pre-computed discovery result. When supplied, the bundled-fallback path - * skips its internal `discoverOpenClawPlugins` call so callers sharing a - * discovery snapshot across registry helpers avoid redundant filesystem - * walks. - */ - discovery?: PluginDiscoveryResult; }): ReadonlyMap { const matches = new Map(); const pluginIds = [ @@ -139,12 +132,10 @@ export function resolvePluginConfigContractsById(params: { if (bundledContractFallbacks.has(pluginId)) { return bundledContractFallbacks.get(pluginId); } - const discovery = - params.discovery ?? - discoverOpenClawPlugins({ - workspaceDir: params.workspaceDir, - env: params.env, - }); + const discovery = discoverOpenClawPlugins({ + workspaceDir: params.workspaceDir, + env: params.env, + }); const registry = loadPluginManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, diff --git a/src/plugins/discovery-threading.test.ts b/src/plugins/discovery-threading.test.ts deleted file mode 100644 index 15d06741a395..000000000000 --- a/src/plugins/discovery-threading.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { PluginDiscoveryResult } from "./discovery.js"; - -const discoverOpenClawPluginsMock = vi.fn(); - -vi.mock("./discovery.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - discoverOpenClawPlugins: (...args: unknown[]) => discoverOpenClawPluginsMock(...args), - }; -}); - -const { loadPluginManifestRegistry } = await import("./manifest-registry.js"); -const { resolveInstalledPluginIndexRegistry } = - await import("./installed-plugin-index-registry.js"); - -const emptyDiscovery: PluginDiscoveryResult = { candidates: [], diagnostics: [] }; - -describe("discovery threading", () => { - beforeEach(() => { - discoverOpenClawPluginsMock.mockReset(); - discoverOpenClawPluginsMock.mockReturnValue(emptyDiscovery); - }); - - describe("loadPluginManifestRegistry", () => { - it("skips internal discoverOpenClawPlugins when discovery is supplied", () => { - loadPluginManifestRegistry({ discovery: emptyDiscovery }); - expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled(); - }); - - it("calls discoverOpenClawPlugins when neither discovery nor candidates supplied", () => { - loadPluginManifestRegistry({}); - expect(discoverOpenClawPluginsMock).toHaveBeenCalledTimes(1); - }); - - it("prefers explicit candidates over discovery when both are supplied", () => { - loadPluginManifestRegistry({ candidates: [], diagnostics: [], discovery: emptyDiscovery }); - expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled(); - }); - }); - - describe("resolveInstalledPluginIndexRegistry", () => { - it("skips internal discoverOpenClawPlugins when discovery is supplied", () => { - resolveInstalledPluginIndexRegistry({ discovery: emptyDiscovery, installRecords: {} }); - expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled(); - }); - - it("calls discoverOpenClawPlugins when neither discovery nor candidates supplied", () => { - resolveInstalledPluginIndexRegistry({ installRecords: {} }); - expect(discoverOpenClawPluginsMock).toHaveBeenCalledTimes(1); - }); - - it("prefers explicit candidates over discovery when both are supplied", () => { - resolveInstalledPluginIndexRegistry({ - candidates: [], - discovery: emptyDiscovery, - installRecords: {}, - }); - expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/plugins/installed-plugin-index-registry.ts b/src/plugins/installed-plugin-index-registry.ts index d61053545e58..bbc71e77ce61 100644 --- a/src/plugins/installed-plugin-index-registry.ts +++ b/src/plugins/installed-plugin-index-registry.ts @@ -25,14 +25,12 @@ export function resolveInstalledPluginIndexRegistry(params: LoadInstalledPluginI const normalized = normalizePluginsConfig(params.config?.plugins); const installRecords = params.installRecords ?? loadInstalledPluginIndexInstallRecordsSync({ env: params.env }); - const discovery = - params.discovery ?? - discoverOpenClawPlugins({ - workspaceDir: params.workspaceDir, - extraPaths: normalized.loadPaths, - env: params.env, - installRecords, - }); + const discovery = discoverOpenClawPlugins({ + workspaceDir: params.workspaceDir, + extraPaths: normalized.loadPaths, + env: params.env, + installRecords, + }); return { candidates: discovery.candidates, registry: loadPluginManifestRegistry({ diff --git a/src/plugins/installed-plugin-index-types.ts b/src/plugins/installed-plugin-index-types.ts index 5206bfe57e1b..6b308ac95340 100644 --- a/src/plugins/installed-plugin-index-types.ts +++ b/src/plugins/installed-plugin-index-types.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "../config/types.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { PluginCompatCode } from "./compat/registry.js"; -import type { PluginCandidate, PluginDiscoveryResult } from "./discovery.js"; +import type { PluginCandidate } from "./discovery.js"; import type { PluginInstallSourceInfo } from "./install-source-info.js"; import type { InstalledPluginFileSignature } from "./installed-plugin-index-hash.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; @@ -130,13 +130,6 @@ export type LoadInstalledPluginIndexParams = { installRecords?: Record; candidates?: PluginCandidate[]; diagnostics?: PluginDiagnostic[]; - /** - * Pre-computed discovery result. When supplied (and `candidates` is not), - * the internal `discoverOpenClawPlugins` call is skipped. Callers sharing a - * discovery snapshot across registry helpers in the same flow should supply - * this to avoid redundant filesystem walks. - */ - discovery?: PluginDiscoveryResult; now?: () => Date; }; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index dab075b8a6f9..d26f760dc9e0 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -50,11 +50,7 @@ import { type NormalizedPluginsConfig, } from "./config-state.js"; import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; -import { - discoverOpenClawPlugins, - type PluginCandidate, - type PluginDiscoveryResult, -} from "./discovery.js"; +import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; import { getGlobalHookRunner, initializeGlobalHookRunner } from "./hook-runner-global.js"; import { toSafeImportPath } from "./import-specifier.js"; @@ -202,14 +198,6 @@ export type PluginLoadOptions = { loadModules?: boolean; throwOnLoadError?: boolean; manifestRegistry?: PluginManifestRegistry; - /** - * Pre-computed plugin discovery result. When supplied, internal calls to - * `discoverOpenClawPlugins` are skipped. Callers in the same startup flow - * can compute one discovery result and share it across loader entry points - * to eliminate redundant filesystem walks. Ignored when `manifestRegistry` - * is also provided (the registry already implies a discovery snapshot). - */ - discovery?: PluginDiscoveryResult; }; function detailPluginStartupTrace( @@ -1702,13 +1690,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi candidates: createPluginCandidatesFromManifestRegistry(suppliedManifestRegistry), diagnostics: [] as PluginDiagnostic[], } - : (options.discovery ?? - discoverOpenClawPlugins({ + : discoverOpenClawPlugins({ workspaceDir: options.workspaceDir, extraPaths: normalized.loadPaths, env, installRecords, - })); + }); const manifestRegistry = suppliedManifestRegistry ?? loadPluginManifestRegistry({ @@ -2572,14 +2559,12 @@ export async function loadOpenClawPluginCliRegistry( activateGlobalSideEffects: false, }); - const discovery = - options.discovery ?? - discoverOpenClawPlugins({ - workspaceDir: options.workspaceDir, - extraPaths: normalized.loadPaths, - env, - installRecords, - }); + const discovery = discoverOpenClawPlugins({ + workspaceDir: options.workspaceDir, + extraPaths: normalized.loadPaths, + env, + installRecords, + }); const manifestRegistry = loadPluginManifestRegistry({ config: cfg, workspaceDir: options.workspaceDir, diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index e354d5fcaed1..2a7efc2bd087 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -10,11 +10,7 @@ import { resolveUserPath } from "../utils.js"; import { resolveCompatibilityHostVersion } from "../version.js"; import { loadBundleManifest } from "./bundle-manifest.js"; import { normalizePluginsConfigWithResolver } from "./config-policy.js"; -import { - discoverOpenClawPlugins, - type PluginCandidate, - type PluginDiscoveryResult, -} from "./discovery.js"; +import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js"; @@ -920,13 +916,6 @@ export function loadPluginManifestRegistry( diagnostics?: PluginDiagnostic[]; installRecords?: Record; bundledChannelConfigCollector?: BundledChannelConfigCollector; - /** - * Pre-computed discovery result. When supplied (and `candidates` is not), - * the internal `discoverOpenClawPlugins` call is skipped. Callers sharing - * a discovery snapshot across multiple registry helpers in the same flow - * should supply this to avoid redundant filesystem walks. - */ - discovery?: PluginDiscoveryResult; } = {}, ): PluginManifestRegistry { const config = params.config ?? {}; @@ -947,13 +936,12 @@ export function loadPluginManifestRegistry( candidates: params.candidates, diagnostics: params.diagnostics ?? [], } - : (params.discovery ?? - discoverOpenClawPlugins({ + : discoverOpenClawPlugins({ workspaceDir: params.workspaceDir, extraPaths: normalized.loadPaths, env, installRecords: getInstallRecords(), - })); + }); const diagnostics: PluginDiagnostic[] = [...discovery.diagnostics]; const candidates: PluginCandidate[] = discovery.candidates; const records: PluginManifestRecord[] = []; From c81271ee6e3a78e961ff4db7b26ff206b61653a8 Mon Sep 17 00:00:00 2001 From: Alex Knight Date: Wed, 20 May 2026 06:44:29 +1000 Subject: [PATCH 14/28] Fix managed Gateway updates across CLI and service Node skew (#84043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - The PR pins managed Gateway package updates, runtime preflight, post-install doctor, post-core update, service refresh, and restart follow-ups to the Node binary and package root baked into the Gateway service. - Reproducibility: yes. source-level. Current main validates and follows up with the shell process Node in the ... body provides a concrete two-Node Docker reproduction, though I did not execute it in this read-only pass. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(update): detect service node mismatch even when package roots match - PR branch already contained follow-up commit before automerge: fix(update): pin package install to service root when nodes differ wi… Validation: - ClawSweeper review passed for head 5607e441f642483bf662f51b581a9bb8b1e31db7. - Required merge gates passed before the squash merge. Prepared head SHA: 5607e441f642483bf662f51b581a9bb8b1e31db7 Review: https://github.com/openclaw/openclaw/pull/84043#issuecomment-4485613931 Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com> Co-authored-by: Alex Knight Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: amknight Co-authored-by: amknight <15041791+amknight@users.noreply.github.com> --- CHANGELOG.md | 2 + docs/install/updating.md | 7 + scripts/e2e/multi-node-update-docker.sh | 404 +++++++++++++++++++++++ src/cli/update-cli.test.ts | 416 ++++++++++++++++++++++++ src/cli/update-cli/update-command.ts | 182 +++++++++-- 5 files changed, 992 insertions(+), 19 deletions(-) create mode 100755 scripts/e2e/multi-node-update-docker.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index e666e6e77430..4e9bf704743e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,9 @@ Docs: https://docs.openclaw.ai - CLI: format `openclaw acp client` failures through the shared error formatter so object-shaped errors stay readable instead of printing `[object Object]`. Fixes #83904. (#84080) - Providers/Ollama: default unknown-capabilities models to tool-capable so discovered native Ollama models can use tools when `/api/show` omits capabilities. (#84055) Thanks @dutifulbob. - Installer/Windows: launch `install.ps1` onboarding as an attached child process so fresh native Windows installs do not freeze visibly at `Starting setup...` or corrupt the wizard's terminal rendering. +- CLI/update: keep restart health checks working across one-version CLI/Gateway protocol skew and use the managed Gateway service Node for all follow-up commands even when the package root is unchanged, so `openclaw update` no longer silently switches the gateway to a different Node binary when multiple Node installations are present. Thanks @amknight. - Memory/search: close local embedding providers when active-memory searches time out so pending local model loads and embedding contexts are aborted and released. (#83858) Thanks @brokemac79. + - Agents: include bounded trajectory queued-writer diagnostics in `pi-trajectory-flush` timeout warnings so flush stalls show pending writes, queued bytes, and append state. Fixes #82961. (#82962) Thanks @galiniliev. - Agents/subagents: recover stale completion announces by retrying unsupported transcript-wait wakes without transcript waiting and forcing a message-tool handoff when the requester run is already stale. Fixes #83699. (#83700) Thanks @galiniliev. - Agents: honor explicit `models.providers..timeoutSeconds` values above the default idle watchdog for cloud and self-hosted providers, so long first-token waits no longer fall back at ~120s when the provider timeout is higher. (#83979) Thanks @yujiawei. diff --git a/docs/install/updating.md b/docs/install/updating.md index 4060c0f65bd6..3dec3f78b85a 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -67,6 +67,13 @@ from that checkout. The `stable` and `beta` channels use package installs. If th gateway is already installed, `openclaw update` refreshes the service metadata and restarts it unless you pass `--no-restart`. +For package installs with a managed Gateway service, `openclaw update` targets +the package root used by that service. If the shell `openclaw` command comes +from a different install, the updater prints both roots and the managed service +Node path. The package update uses the package manager that owns the service +root and checks the managed service Node against the target release engine +before replacing the package. + ## Alternative: re-run the installer ```bash diff --git a/scripts/e2e/multi-node-update-docker.sh b/scripts/e2e/multi-node-update-docker.sh new file mode 100755 index 000000000000..5efaa2949d92 --- /dev/null +++ b/scripts/e2e/multi-node-update-docker.sh @@ -0,0 +1,404 @@ +#!/usr/bin/env bash +# Reproduces the multi-node-install update bug. +# +# Sets up two independent Node installations inside a Docker container, installs +# OpenClaw under node-A, registers the gateway service pointing at node-A, then +# switches PATH so node-B comes first and runs `openclaw update`. Verifies that: +# +# 1. The update targets the wrong install root (node-B npm prefix) or produces +# a gateway service definition pointing at node-B while the package lives +# under node-A. +# 2. The gateway fails to start or runs a stale/missing entrypoint. +# +# Usage: +# ./scripts/e2e/multi-node-update-docker.sh +# +# Requires: Docker, a built openclaw-current.tgz (or will build one). +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" +source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh" + +IMAGE_NAME="openclaw-multi-node-update-e2e" +DOCKER_RUN_TIMEOUT="${OPENCLAW_MULTI_NODE_DOCKER_TIMEOUT:-300s}" +ARTIFACT_DIR="${OPENCLAW_MULTI_NODE_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/multi-node-update}" + +mkdir -p "$ARTIFACT_DIR" +chmod -R a+rwX "$ARTIFACT_DIR" || true + +# Build the bare e2e image and prepare the package tarball. +docker_e2e_build_or_reuse "$IMAGE_NAME" multi-node-update "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "${OPENCLAW_SKIP_DOCKER_BUILD:-0}" +PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz multi-node-update "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")" +docker_e2e_package_mount_args "$PACKAGE_TGZ" + +echo "=== Running multi-node-update Docker E2E ===" + +CONTAINER_EXIT=0 +docker_e2e_run_with_harness \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e CI=true \ + -e OPENCLAW_NO_ONBOARD=1 \ + -e OPENCLAW_NO_PROMPT=1 \ + -e OPENCLAW_SKIP_PROVIDERS=1 \ + -e OPENCLAW_SKIP_CHANNELS=1 \ + -e OPENCLAW_DISABLE_BONJOUR=1 \ + -e OPENAI_API_KEY=sk-multi-node-test \ + -v "$ARTIFACT_DIR:/tmp/artifacts" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ + --user root \ + -e HOME=/root \ + "$IMAGE_NAME" \ + timeout "$DOCKER_RUN_TIMEOUT" bash -lc ' +set -euo pipefail + +ARTIFACTS=/tmp/artifacts +exec > >(tee "$ARTIFACTS/run.log") 2>&1 + +echo "========================================" +echo " Multi-Node Update Bug Reproduction" +echo "========================================" +echo "" + +# ── Step 1: Create two separate Node installations ────────────────────── +echo "── Step 1: Setting up two Node installations ──" + +# node-A is the system node that ships with the Docker image (node:24-bookworm-slim). +NODE_A="$(command -v node)" +NODE_A_DIR="$(dirname "$NODE_A")" +NODE_A_VERSION="$("$NODE_A" --version)" +echo "node-A: $NODE_A ($NODE_A_VERSION)" + +# Set up independent npm prefixes. +NPM_PREFIX_A="/opt/npm-prefix-a" +NPM_PREFIX_B="/opt/npm-prefix-b" +mkdir -p "$NPM_PREFIX_A/bin" "$NPM_PREFIX_A/lib" "$NPM_PREFIX_B/bin" "$NPM_PREFIX_B/lib" + +# node-B is a second, full Node installation created by copying the entire +# node prefix. This simulates having two real node installs (e.g. Homebrew + +# nvm, or system node + volta). +NODE_B_ROOT="/opt/node-b" +NODE_A_PREFIX="$(dirname "$NODE_A_DIR")" +mkdir -p "$NODE_B_ROOT" +cp -a "$NODE_A_PREFIX/bin" "$NODE_B_ROOT/bin" +cp -a "$NODE_A_PREFIX/lib" "$NODE_B_ROOT/lib" +chmod -R +x "$NODE_B_ROOT/bin/"* +# Configure node-B npm to use its own global prefix (not node-A prefix). +export npm_config_prefix_orig="${npm_config_prefix:-}" +"$NODE_B_ROOT/bin/node" "$NODE_B_ROOT/bin/npm" config set prefix "$NPM_PREFIX_B" --global 2>/dev/null || true +NODE_B="$NODE_B_ROOT/bin/node" +NODE_B_VERSION="$("$NODE_B" --version)" +echo "node-B: $NODE_B ($NODE_B_VERSION)" + +echo "" +echo "── Step 2: Install OpenClaw under node-A ──" + +# Use node-A to install openclaw with npm prefix A. +export npm_config_prefix="$NPM_PREFIX_A" +export NPM_CONFIG_PREFIX="$NPM_PREFIX_A" +export npm_config_loglevel=error +export npm_config_fund=false +export npm_config_audit=false +export PATH="$NPM_PREFIX_A/bin:$NODE_A_DIR:$PATH" + +echo "Installing OpenClaw package under node-A prefix: $NPM_PREFIX_A" +npm install -g /tmp/openclaw-current.tgz --no-fund --no-audit >"$ARTIFACTS/install-a.log" 2>&1 +echo "Installed. Checking openclaw location..." + +OPENCLAW_A="$(command -v openclaw)" +echo "openclaw binary: $OPENCLAW_A" +echo "openclaw version: $(openclaw --version 2>/dev/null || echo unknown)" + +# Record the package root for node-A install. +PACKAGE_ROOT_A="$NPM_PREFIX_A/lib/node_modules/openclaw" +echo "Package root A: $PACKAGE_ROOT_A" +ls -la "$PACKAGE_ROOT_A/package.json" 2>/dev/null || echo "WARNING: package.json not found at A" + +echo "" +echo "── Step 3: Install the systemd service (gateway) using node-A ──" + +# Create a systemctl shim since we are in Docker (no real systemd). +SHIM_DIR="/usr/local/bin" +GATEWAY_UNIT_PATH="/root/.config/systemd/user/openclaw-gateway.service" +SYSTEMCTL_LOG="$ARTIFACTS/systemctl-shim.log" +GATEWAY_DAEMON_LOG="$ARTIFACTS/gateway-daemon.log" +GATEWAY_PID_FILE="$ARTIFACTS/gateway.pid" +: >"$SYSTEMCTL_LOG" + +cat >"$SHIM_DIR/systemctl" <>"$SYSTEMCTL_LOG" + +filtered=() +for arg in "\$@"; do + case "\$arg" in + --user|--quiet|--no-page|--now) ;; + *) filtered+=("\$arg") ;; + esac +done +command="\${filtered[0]:-status}" + +case "\$command" in + daemon-reload) + echo "daemon-reload (shim: no-op)" + ;; + enable) + echo "enable (shim: no-op)" + ;; + restart|start) + if [ -s "$GATEWAY_PID_FILE" ]; then + old_pid="\$(cat "$GATEWAY_PID_FILE" 2>/dev/null || true)" + if kill -0 "\$old_pid" 2>/dev/null; then + kill "\$old_pid" 2>/dev/null || true + sleep 0.5 + fi + fi + unit="$GATEWAY_UNIT_PATH" + if [ ! -f "\$unit" ]; then + echo "systemctl shim: unit not found: \$unit" >&2 + exit 1 + fi + exec_start="\$(grep "^ExecStart=" "\$unit" | head -1 | sed "s/^ExecStart=//")" + if [ -z "\$exec_start" ]; then + echo "systemctl shim: no ExecStart in \$unit" >&2 + exit 1 + fi + # Source EnvironmentFile if present + env_file="\$(grep "^EnvironmentFile=" "\$unit" | head -1 | sed "s/^EnvironmentFile=//" | sed "s/^-//")" + if [ -n "\$env_file" ] && [ -f "\$env_file" ]; then + set -a; source "\$env_file"; set +a + fi + # Inline Environment= entries + while IFS= read -r env_line; do + env_entry="\${env_line#Environment=}" + env_entry="\${env_entry#\"}" + env_entry="\${env_entry%\"}" + export "\$env_entry" + done < <(grep "^Environment=" "\$unit" || true) + echo "systemctl shim: starting: \$exec_start" + eval nohup \$exec_start >>"$GATEWAY_DAEMON_LOG" 2>&1 & + echo "\$!" >"$GATEWAY_PID_FILE" + echo "systemctl shim: started pid \$(cat "$GATEWAY_PID_FILE")" + ;; + stop) + if [ -s "$GATEWAY_PID_FILE" ]; then + pid="\$(cat "$GATEWAY_PID_FILE")" + kill "\$pid" 2>/dev/null || true + rm -f "$GATEWAY_PID_FILE" + fi + ;; + is-active) + if [ -s "$GATEWAY_PID_FILE" ] && kill -0 "\$(cat "$GATEWAY_PID_FILE" 2>/dev/null)" 2>/dev/null; then + echo "active" + else + echo "inactive" + exit 3 + fi + ;; + show) + echo "ActiveState=inactive" + ;; + *) + echo "systemctl shim: ignoring: \$*" + ;; +esac +SHIMEOF +chmod +x "$SHIM_DIR/systemctl" +echo "systemctl shim installed." + +# Now install the gateway service using node-A. +echo "Installing gateway service..." +mkdir -p "$(dirname "$GATEWAY_UNIT_PATH")" +# gateway install may exit non-zero because our systemctl shim cannot fully +# restart, but the unit file gets written before the restart step. +openclaw gateway install --json >"$ARTIFACTS/gateway-install.json" 2>"$ARTIFACTS/gateway-install.err" || true + +echo "" +echo "── Step 4: Inspect what node path was baked into the service ──" + +if [ -f "$GATEWAY_UNIT_PATH" ]; then + echo "Service unit contents:" + cat "$GATEWAY_UNIT_PATH" | tee "$ARTIFACTS/unit-before-update.txt" + echo "" + EXEC_START_BEFORE="$(grep "^ExecStart=" "$GATEWAY_UNIT_PATH" | head -1)" + BAKED_NODE_BEFORE="$(echo "$EXEC_START_BEFORE" | sed "s/^ExecStart=//" | awk "{print \$1}")" + echo "Baked node path BEFORE update: $BAKED_NODE_BEFORE" +else + echo "FAIL: Gateway unit file was not created at $GATEWAY_UNIT_PATH" + echo "gateway install output:" + cat "$ARTIFACTS/gateway-install.json" 2>/dev/null || true + cat "$ARTIFACTS/gateway-install.err" 2>/dev/null || true + exit 1 +fi + +echo "" +echo "── Step 5: Switch PATH so node-B comes first ──" + +# Simulate the user scenario: their PATH changes (e.g. they installed +# a second Node via nvm, brew, etc.) and the new node-B comes first. +# Crucially, node-B has its own working npm with its own global prefix, +# but openclaw is NOT installed there. +export PATH="$NPM_PREFIX_B/bin:$NODE_B_ROOT/bin:$NPM_PREFIX_A/bin:$NODE_A_DIR:$PATH" + +# Verify node-B npm works independently. +echo "node-B npm prefix: $($NODE_B_ROOT/bin/node $NODE_B_ROOT/bin/npm prefix -g 2>/dev/null || echo unknown)" +echo "which node: $(command -v node)" +echo "which openclaw: $(command -v openclaw)" +echo "process.execPath will be: $(node -e "console.log(process.execPath)")" + +echo "" +echo "── Step 6: Run openclaw update (this is the bug) ──" + +# Run the update WITH restart so that the update flow re-runs +# `gateway install --force` and bakes the current process.execPath +# (now node-B) into the service unit. This is where the split happens. +echo "Running openclaw update --yes --json..." +UPDATE_EXIT=0 +openclaw update --yes --json \ + --tag /tmp/openclaw-current.tgz \ + >"$ARTIFACTS/update.json" 2>"$ARTIFACTS/update.err" || UPDATE_EXIT=$? + +echo "" +echo "Update exit code: $UPDATE_EXIT" +echo "Update stderr (if any):" +cat "$ARTIFACTS/update.err" 2>/dev/null | tail -10 || true + +# The update may fail during restart (systemctl shim limitations) but it must +# have at least attempted the package install. Check that it ran past early exit. +if [ "$UPDATE_EXIT" -ne 0 ] && ! grep -q "gateway" "$ARTIFACTS/update.err" 2>/dev/null; then + echo "FAIL: openclaw update failed before reaching the package install step" + cat "$ARTIFACTS/update.err" 2>/dev/null || true + exit 1 +fi + +echo "" +echo "── Step 7: Inspect the service unit AFTER update ──" + +if [ -f "$GATEWAY_UNIT_PATH" ]; then + echo "Service unit contents after update:" + cat "$GATEWAY_UNIT_PATH" | tee "$ARTIFACTS/unit-after-update.txt" + echo "" + EXEC_START_AFTER="$(grep "^ExecStart=" "$GATEWAY_UNIT_PATH" | head -1)" + BAKED_NODE_AFTER="$(echo "$EXEC_START_AFTER" | sed "s/^ExecStart=//" | awk "{print \$1}")" + echo "Baked node path AFTER update: $BAKED_NODE_AFTER" +else + echo "No unit file after update." +fi + +echo "" +echo "── Step 8: Verify results ──" + +BAKED_NODE_BEFORE="${BAKED_NODE_BEFORE:-unknown}" +BAKED_NODE_AFTER="${BAKED_NODE_AFTER:-unknown}" + +echo "Node A: $NODE_A" +echo "Node B: $NODE_B" +echo "Baked BEFORE update: $BAKED_NODE_BEFORE" +echo "Baked AFTER update: $BAKED_NODE_AFTER" +echo "Package root A: $PACKAGE_ROOT_A" +echo "" + +# Check 1: Did the baked node path change from A to B? +if [ "$BAKED_NODE_AFTER" = "$NODE_B" ] && [ "$BAKED_NODE_BEFORE" != "$NODE_B" ]; then + echo "BUG CONFIRMED: Gateway service now points at node-B ($NODE_B)" + echo " but OpenClaw package is still under node-A prefix ($PACKAGE_ROOT_A)." + echo " The gateway will use node-B to run an entrypoint that may reference" + echo " node-A dependencies or may not exist under node-B global prefix." +elif [ "$BAKED_NODE_AFTER" = "$BAKED_NODE_BEFORE" ]; then + echo "FIXED: Gateway service still points at the original node ($BAKED_NODE_AFTER)" +else + echo "CHANGED: Node path changed from $BAKED_NODE_BEFORE to $BAKED_NODE_AFTER" +fi + +# Check 2: Is the OpenClaw package installed under node-B npm prefix? +if [ -f "$NPM_PREFIX_B/lib/node_modules/openclaw/package.json" ]; then + echo "WARNING: OpenClaw was ALSO installed under node-B prefix (split install)" +else + echo "OK: OpenClaw is NOT under node-B prefix (expected: only under node-A)" +fi + +# Check 3: Does the entrypoint in the unit file actually exist? +if [ -f "$GATEWAY_UNIT_PATH" ]; then + EXEC_START_AFTER="$(grep "^ExecStart=" "$GATEWAY_UNIT_PATH" | head -1 | sed "s/^ExecStart=//")" + ENTRYPOINT_PATH="$(echo "$EXEC_START_AFTER" | awk "{print \$2}")" + if [ -n "$ENTRYPOINT_PATH" ] && [ ! -f "$ENTRYPOINT_PATH" ]; then + echo "BUG: Entrypoint in service unit does not exist: $ENTRYPOINT_PATH" + elif [ -n "$ENTRYPOINT_PATH" ]; then + echo "OK: Entrypoint exists: $ENTRYPOINT_PATH" + fi +fi + +# Check 4: Were there any warnings about split install in the update output? +if [ -f "$ARTIFACTS/update.err" ]; then + if grep -qi "Shell OpenClaw root differs" "$ARTIFACTS/update.err" 2>/dev/null; then + echo "OK: Update warned about split root" + fi + if grep -qi "Managed gateway service Node" "$ARTIFACTS/update.err" 2>/dev/null; then + echo "OK: Update showed the managed service Node path" + fi +fi + +# Check 5: Try to start the gateway and see if it works. +echo "" +echo "── Step 9: Try starting the gateway with the post-update unit ──" + +if [ -f "$GATEWAY_UNIT_PATH" ]; then + systemctl restart 2>&1 || true + sleep 3 + if [ -s "$GATEWAY_PID_FILE" ] && kill -0 "$(cat "$GATEWAY_PID_FILE" 2>/dev/null)" 2>/dev/null; then + echo "OK: Gateway started (pid $(cat "$GATEWAY_PID_FILE"))" + # Try a health probe. + if openclaw gateway status --json >"$ARTIFACTS/status.json" 2>&1; then + echo "OK: Gateway status probe succeeded" + else + echo "WARNING: Gateway status probe failed" + fi + # Stop it. + kill "$(cat "$GATEWAY_PID_FILE")" 2>/dev/null || true + else + echo "BUG: Gateway failed to start with the post-update unit" + cat "$GATEWAY_DAEMON_LOG" 2>/dev/null | tail -20 || true + fi +fi + +echo "" +echo "========================================" +echo " Reproduction complete." +echo " Artifacts saved to /tmp/artifacts/" +echo "========================================" + +# ── Final exit code ────────────────────────────────────────────────────────── +# Exit non-zero if any BUG was found, making this usable as a CI gate. +EXIT_CODE=0 +if [ "$BAKED_NODE_AFTER" = "$NODE_B" ] && [ "$BAKED_NODE_BEFORE" != "$NODE_B" ]; then + EXIT_CODE=1 +fi +if [ -f "$NPM_PREFIX_B/lib/node_modules/openclaw/package.json" ]; then + EXIT_CODE=1 +fi +if [ -f "$GATEWAY_UNIT_PATH" ]; then + ENTRYPOINT_PATH_CHECK="$(grep "^ExecStart=" "$GATEWAY_UNIT_PATH" | head -1 | sed "s/^ExecStart=//" | awk "{print \$2}")" || true + if [ -n "$ENTRYPOINT_PATH_CHECK" ] && [ ! -f "$ENTRYPOINT_PATH_CHECK" ]; then + EXIT_CODE=1 + fi +fi +exit $EXIT_CODE +' || CONTAINER_EXIT=$? + +echo "" +echo "=== Artifacts ===" +echo "Logs saved to: $ARTIFACT_DIR/" +ls -la "$ARTIFACT_DIR/" 2>/dev/null || true + +if [ -f "$ARTIFACT_DIR/run.log" ]; then + echo "" + echo "=== Key results ===" + grep -E "^(BUG|FIXED|OK|CHANGED|WARNING)" "$ARTIFACT_DIR/run.log" || echo "(no key results found)" +fi + +if [ "$CONTAINER_EXIT" -ne 0 ]; then + echo "" + echo "FAIL: Docker container exited with code $CONTAINER_EXIT" +fi +exit "$CONTAINER_EXIT" diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 541e08eb5d27..34e961735728 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -2885,6 +2885,422 @@ describe("update-cli", () => { ); }); + it("warns when a package update targets a managed service root outside the shell root", async () => { + const shellRoot = createCaseDir("openclaw-shell-root"); + const serviceRoot = await createTrackedTempDir("openclaw-service-root-"); + const serviceNode = path.join(path.dirname(serviceRoot), "bin", "node"); + await fs.mkdir(path.join(serviceRoot, "dist"), { recursive: true }); + await fs.writeFile( + path.join(serviceRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.5.18" }), + "utf-8", + ); + mockPackageInstallStatus(shellRoot); + serviceReadCommand.mockResolvedValue({ + programArguments: [serviceNode, path.join(serviceRoot, "dist", "index.js"), "gateway"], + }); + + await updateCommand({ dryRun: true }); + + const logs = vi + .mocked(defaultRuntime.log) + .mock.calls.map((call) => String(call[0])) + .join("\n"); + expect(logs).toContain(`Targeting managed gateway service package root: ${serviceRoot}`); + expect(logs).toContain( + `Shell OpenClaw root differs from the managed gateway service root: ${shellRoot}`, + ); + expect(logs).toContain("make sure `openclaw` on PATH resolves to the managed service root"); + expect(logs).toContain(`Managed gateway service Node: ${serviceNode}`); + }); + + it("checks the managed service Node runtime before updating a redirected package root", async () => { + const shellRoot = createCaseDir("openclaw-shell-root"); + const serviceRoot = await createTrackedTempDir("openclaw-service-root-"); + const serviceNode = path.join(path.dirname(serviceRoot), "bin", "node"); + await fs.mkdir(path.join(serviceRoot, "dist"), { recursive: true }); + await fs.mkdir(path.dirname(serviceNode), { recursive: true }); + await fs.writeFile(serviceNode, "", "utf-8"); + await fs.writeFile( + path.join(serviceRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.5.18" }), + "utf-8", + ); + mockPackageInstallStatus(shellRoot); + serviceReadCommand.mockResolvedValue({ + programArguments: [serviceNode, path.join(serviceRoot, "dist", "index.js"), "gateway"], + }); + vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({ + target: "latest", + version: "2026.5.20", + nodeEngine: ">=22.19.0", + }); + vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") { + return { + stdout: "v22.18.0\n", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + }); + nodeVersionSatisfiesEngine.mockReturnValue(false); + + await updateCommand({ yes: true }); + + expect(nodeVersionSatisfiesEngine).toHaveBeenCalledWith("22.18.0", ">=22.19.0"); + expect(packageInstallCommandCall()).toBeUndefined(); + expect(serviceStop).not.toHaveBeenCalled(); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + const errors = vi.mocked(defaultRuntime.error).mock.calls.map((call) => String(call[0])); + expect(errors.join("\n")).toContain(`Node 22.18.0 at ${serviceNode} is too old`); + expect(errors.join("\n")).toContain( + "Upgrade the Node runtime that owns the managed Gateway service", + ); + }); + + it("runs managed service package follow-up commands with the service Node", async () => { + const shellRoot = createCaseDir("openclaw-shell-root"); + const servicePrefix = await createTrackedTempDir("openclaw-service-prefix-"); + const nodeModules = path.join(servicePrefix, "lib", "node_modules"); + const serviceRoot = path.join(nodeModules, "openclaw"); + const serviceNode = path.join(servicePrefix, "bin", "node"); + const serviceNpm = path.join(servicePrefix, "bin", "npm"); + const entrypoint = path.join(serviceRoot, "dist", "index.js"); + await fs.mkdir(path.dirname(entrypoint), { recursive: true }); + await fs.mkdir(path.dirname(serviceNode), { recursive: true }); + await fs.writeFile(serviceNode, "", "utf-8"); + await fs.writeFile(serviceNpm, "", "utf-8"); + const serviceNpmReal = await fs.realpath(serviceNpm); + await fs.writeFile( + path.join(serviceRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.5.18" }), + "utf-8", + ); + await fs.writeFile(entrypoint, "", "utf-8"); + await writePackageDistInventory(serviceRoot); + mockPackageInstallStatus(shellRoot); + serviceReadCommand.mockResolvedValue({ + programArguments: [serviceNode, entrypoint, "gateway"], + }); + serviceLoaded.mockResolvedValue(true); + pathExists.mockImplementation(async (candidate: string) => { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } + }); + vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") { + return { + stdout: "v22.22.0\n", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + if ( + Array.isArray(argv) && + (argv[0] === serviceNpm || argv[0] === serviceNpmReal) && + argv[1] === "root" && + argv[2] === "-g" + ) { + return { + stdout: `${nodeModules}\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + if ( + Array.isArray(argv) && + (argv[0] === serviceNpm || argv[0] === serviceNpmReal) && + argv[1] === "i" + ) { + const stagePrefix = argv.includes("--prefix") + ? argv[argv.indexOf("--prefix") + 1] + : undefined; + const stageRoot = stagePrefix + ? path.join(stagePrefix, "lib", "node_modules", "openclaw") + : serviceRoot; + const stageEntryPoint = path.join(stageRoot, "dist", "index.js"); + await fs.mkdir(path.dirname(stageEntryPoint), { recursive: true }); + await fs.writeFile( + path.join(stageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.5.20" }), + "utf-8", + ); + await fs.writeFile(stageEntryPoint, "export {};\n", "utf-8"); + await writePackageDistInventory(stageRoot); + } + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + }); + + await updateCommand({ yes: true }); + + expect(doctorCommandCall()?.[0][0]).toBe(serviceNode); + expect(spawnCall()?.[0]).toBe(serviceNode); + const serviceInstallCall = commandCalls().find( + ([argv]) => argv[2] === "gateway" && argv[3] === "install", + ); + expect(serviceInstallCall?.[0][0]).toBe(serviceNode); + }); + + it("uses the managed service Node when package roots match but node binaries differ", async () => { + const root = createCaseDir("openclaw-same-root"); + // Service is baked with a different node than the current process.execPath. + const serviceNode = "/opt/other-node/bin/node"; + const entrypoint = path.join(root, "dist", "index.js"); + mockPackageInstallStatus(root); + serviceReadCommand.mockResolvedValue({ + programArguments: [serviceNode, entrypoint, "gateway"], + }); + + await updateCommand({ dryRun: true }); + + const logs = vi + .mocked(defaultRuntime.log) + .mock.calls.map((call) => String(call[0])) + .join("\n"); + // Should NOT log root redirect messages since the package root is the same. + expect(logs).not.toContain("Targeting managed gateway service package root"); + // Should warn about the node binary mismatch. + expect(logs).toContain("differs from the managed gateway service Node"); + expect(logs).toContain(serviceNode); + expect(logs).toContain( + "Using the managed service Node for this update so the gateway can start after the upgrade", + ); + }); + + it("uses the managed service Node for follow-up commands when roots match but nodes differ", async () => { + const servicePrefix = await createTrackedTempDir("openclaw-service-prefix-"); + const nodeModules = path.join(servicePrefix, "lib", "node_modules"); + const root = path.join(nodeModules, "openclaw"); + const serviceNode = path.join(servicePrefix, "bin", "node"); + const serviceNpm = path.join(servicePrefix, "bin", "npm"); + const entrypoint = path.join(root, "dist", "index.js"); + await fs.mkdir(path.dirname(entrypoint), { recursive: true }); + await fs.mkdir(path.dirname(serviceNode), { recursive: true }); + await fs.writeFile(serviceNode, "", "utf-8"); + await fs.writeFile(serviceNpm, "", "utf-8"); + const serviceNpmReal = await fs.realpath(serviceNpm); + await fs.writeFile( + path.join(root, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.5.18" }), + "utf-8", + ); + await fs.writeFile(entrypoint, "", "utf-8"); + await writePackageDistInventory(root); + // Same package root for both shell and service. + mockPackageInstallStatus(root); + serviceReadCommand.mockResolvedValue({ + programArguments: [serviceNode, entrypoint, "gateway"], + }); + serviceLoaded.mockResolvedValue(true); + pathExists.mockImplementation(async (candidate: string) => { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } + }); + vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") { + return { + stdout: "v24.14.0\n", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + if ( + Array.isArray(argv) && + (argv[0] === serviceNpm || argv[0] === serviceNpmReal) && + argv[1] === "root" && + argv[2] === "-g" + ) { + return { + stdout: `${nodeModules}\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + if ( + Array.isArray(argv) && + (argv[0] === serviceNpm || argv[0] === serviceNpmReal) && + argv[1] === "i" + ) { + const stagePrefix = argv.includes("--prefix") + ? argv[argv.indexOf("--prefix") + 1] + : undefined; + const stageRoot = stagePrefix + ? path.join(stagePrefix, "lib", "node_modules", "openclaw") + : root; + const stageEntryPoint = path.join(stageRoot, "dist", "index.js"); + await fs.mkdir(path.dirname(stageEntryPoint), { recursive: true }); + await fs.writeFile( + path.join(stageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.5.20" }), + "utf-8", + ); + await fs.writeFile(stageEntryPoint, "export {};\n", "utf-8"); + await writePackageDistInventory(stageRoot); + } + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + }); + + await updateCommand({ yes: true }); + + // Follow-up commands should use the service Node, not process.execPath. + expect(doctorCommandCall()?.[0][0]).toBe(serviceNode); + expect(spawnCall()?.[0]).toBe(serviceNode); + const serviceInstallCall = commandCalls().find( + ([argv]) => argv[2] === "gateway" && argv[3] === "install", + ); + expect(serviceInstallCall?.[0][0]).toBe(serviceNode); + }); + + it("pins package install to the service root when nodes differ and no owning npm exists at the prefix", async () => { + const servicePrefix = await createTrackedTempDir("openclaw-no-npm-prefix-"); + const nodeModules = path.join(servicePrefix, "lib", "node_modules"); + const root = path.join(nodeModules, "openclaw"); + const serviceNode = path.join(servicePrefix, "bin", "node"); + const entrypoint = path.join(root, "dist", "index.js"); + // Create the node binary but intentionally do NOT create /bin/npm + // so resolvePreferredNpmCommand returns null and the PATH npm is used. + await fs.mkdir(path.dirname(entrypoint), { recursive: true }); + await fs.mkdir(path.dirname(serviceNode), { recursive: true }); + await fs.writeFile(serviceNode, "", "utf-8"); + // No npm binary at servicePrefix/bin/npm! + await fs.writeFile( + path.join(root, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.5.18" }), + "utf-8", + ); + await fs.writeFile(entrypoint, "", "utf-8"); + await writePackageDistInventory(root); + mockPackageInstallStatus(root); + serviceReadCommand.mockResolvedValue({ + programArguments: [serviceNode, entrypoint, "gateway"], + }); + serviceLoaded.mockResolvedValue(true); + pathExists.mockImplementation(async (candidate: string) => { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } + }); + // The PATH npm returns a DIFFERENT global root (simulates Node-B's npm). + const nodeBGlobalRoot = path.join( + await createTrackedTempDir("node-b-global-"), + "lib", + "node_modules", + ); + await fs.mkdir(nodeBGlobalRoot, { recursive: true }); + vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") { + return { + stdout: "v24.14.0\n", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { + // PATH npm returns Node-B's root, NOT the service root. + return { + stdout: `${nodeBGlobalRoot}\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "i") { + // Install step: create the expected package structure at the target. + const prefixIdx = argv.indexOf("--prefix"); + const stagePrefix = prefixIdx >= 0 ? argv[prefixIdx + 1] : undefined; + const stageRoot = stagePrefix + ? path.join(stagePrefix, "lib", "node_modules", "openclaw") + : root; + const stageEntryPoint = path.join(stageRoot, "dist", "index.js"); + await fs.mkdir(path.dirname(stageEntryPoint), { recursive: true }); + await fs.writeFile( + path.join(stageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.5.20" }), + "utf-8", + ); + await fs.writeFile(stageEntryPoint, "export {};\n", "utf-8"); + await writePackageDistInventory(stageRoot); + } + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + }); + + await updateCommand({ yes: true }); + + // The install command must use --prefix pointing to a location within + // the service root's prefix tree, NOT Node-B's global root. + const installCall = packageInstallCommandCall(); + expect(installCall).toBeDefined(); + const installArgv = installCall![0]; + const prefixIdx = installArgv.indexOf("--prefix"); + expect(prefixIdx).toBeGreaterThan(-1); + // Staging prefix should be under the service prefix, not Node-B's. + expect(installArgv[prefixIdx + 1]).toContain(servicePrefix); + expect(installArgv[prefixIdx + 1]).not.toContain(nodeBGlobalRoot); + // Follow-up commands use the service node. + expect(doctorCommandCall()?.[0][0]).toBe(serviceNode); + }); + it("repairs legacy config before persisting a requested update channel", async () => { const tempDir = createCaseDir("openclaw-update"); mockPackageInstallStatus(tempDir); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 8e73aeb943e7..c7dfc4147000 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -33,6 +33,7 @@ import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint import { disableCurrentOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js"; import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js"; import { summarizeGatewayServiceLayout } from "../../daemon/service-layout.js"; +import type { GatewayServiceCommandConfig } from "../../daemon/service-types.js"; import { readGatewayServiceState, resolveGatewayService, @@ -749,6 +750,12 @@ type PrePackageServiceStop = { serviceEnv?: NodeJS.ProcessEnv; }; +type ManagedServiceRootRedirect = { + root: string; + previousRoot: string; + nodeRunner?: string; +}; + function formatGatewayAncestryBlockMessage(pid: number): string { return `openclaw update detected it is running inside the gateway process tree. Gateway PID ${pid} is an ancestor of this process, so this updater cannot safely stop or restart the gateway that owns it. @@ -929,6 +936,7 @@ function tryResolveInvocationCwd(): string | undefined { async function resolvePackageRuntimePreflightError(params: { tag: string; timeoutMs?: number; + nodeRunner?: string; }): Promise { if (!canResolveRegistryVersionForPackageTarget(params.tag)) { return null; @@ -944,20 +952,45 @@ async function resolvePackageRuntimePreflightError(params: { if (status.error) { return null; } - const satisfies = nodeVersionSatisfiesEngine(process.versions.node ?? null, status.nodeEngine); + const runtime = await resolvePackageRuntimeForPreflight({ + nodeRunner: params.nodeRunner, + timeoutMs: params.timeoutMs, + }); + const satisfies = nodeVersionSatisfiesEngine(runtime.version, status.nodeEngine); if (satisfies !== false) { return null; } const targetLabel = status.version ?? target; + const runtimeLabel = runtime.nodeRunner + ? `Node ${runtime.version ?? "unknown"} at ${runtime.nodeRunner}` + : `Node ${runtime.version ?? "unknown"}`; return [ - `Node ${process.versions.node ?? "unknown"} is too old for openclaw@${targetLabel}.`, + `${runtimeLabel} is too old for openclaw@${targetLabel}.`, `The requested package requires ${status.nodeEngine}.`, - "Upgrade Node to 22.19+ or Node 24, then rerun `openclaw update`.", + runtime.nodeRunner + ? "Upgrade the Node runtime that owns the managed Gateway service, then rerun `openclaw update`." + : "Upgrade Node to 22.19+ or Node 24, then rerun `openclaw update`.", "Bare `npm i -g openclaw` can silently install an older compatible release.", "After upgrading Node, use `npm i -g openclaw@latest`.", ].join("\n"); } +async function resolvePackageRuntimeForPreflight(params: { + nodeRunner?: string; + timeoutMs?: number; +}): Promise<{ version: string | null; nodeRunner?: string }> { + const nodeRunner = normalizeOptionalString(params.nodeRunner); + if (!nodeRunner) { + return { version: process.versions.node ?? null }; + } + const res = await runCommandWithTimeout([nodeRunner, "--version"], { + timeoutMs: Math.min(params.timeoutMs ?? 10_000, 10_000), + }).catch(() => null); + const rawVersion = res?.code === 0 ? res.stdout.trim() : ""; + const version = rawVersion.replace(/^v/u, "") || null; + return { version, nodeRunner }; +} + function resolveServiceRefreshEnv( env: NodeJS.ProcessEnv, invocationCwd?: string, @@ -1094,6 +1127,7 @@ async function refreshGatewayServiceEnv(params: { jsonMode: boolean; invocationCwd?: string; env?: NodeJS.ProcessEnv; + nodeRunner?: string; }): Promise { const args = ["gateway", "install", "--force"]; if (params.jsonMode) { @@ -1102,11 +1136,14 @@ async function refreshGatewayServiceEnv(params: { const entrypoint = await resolveGatewayInstallEntrypoint(params.result.root); if (entrypoint) { - const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], { - cwd: params.result.root, - env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd), - timeoutMs: SERVICE_REFRESH_TIMEOUT_MS, - }); + const res = await runCommandWithTimeout( + [params.nodeRunner ?? resolveNodeRunner(), entrypoint, ...args], + { + cwd: params.result.root, + env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd), + timeoutMs: SERVICE_REFRESH_TIMEOUT_MS, + }, + ); if (res.code === 0) { return; } @@ -1129,6 +1166,7 @@ async function runUpdatedInstallGatewayRestart(params: { jsonMode: boolean; invocationCwd?: string; env?: NodeJS.ProcessEnv; + nodeRunner?: string; }): Promise { const entrypoint = await resolveGatewayInstallEntrypoint(params.result.root); if (!entrypoint) { @@ -1141,11 +1179,14 @@ async function runUpdatedInstallGatewayRestart(params: { if (params.jsonMode) { args.push("--json"); } - const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], { - cwd: params.result.root, - env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd), - timeoutMs: SERVICE_REFRESH_TIMEOUT_MS, - }); + const res = await runCommandWithTimeout( + [params.nodeRunner ?? resolveNodeRunner(), entrypoint, ...args], + { + cwd: params.result.root, + env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd), + timeoutMs: SERVICE_REFRESH_TIMEOUT_MS, + }, + ); if (res.code === 0) { return true; } @@ -1217,9 +1258,54 @@ async function tryRealpathOrResolve(value: string): Promise { } } +function isNodeExecutable(value: string | undefined): boolean { + const base = normalizeOptionalString(value ? path.basename(value) : undefined)?.toLowerCase(); + return base === "node" || base === "node.exe"; +} + +function resolveManagedServiceNodeRunner( + command: GatewayServiceCommandConfig | null, +): string | undefined { + const args = command?.programArguments; + if (!args?.length) { + return undefined; + } + const gatewayIndex = args.indexOf("gateway"); + if (gatewayIndex <= 1) { + return undefined; + } + const runner = args[gatewayIndex - 2]; + return isNodeExecutable(runner) ? runner : undefined; +} + +/** + * Resolve the node binary baked into the managed gateway service unit, + * independent of any package root redirect. This detects when the user's + * current PATH-resolved node differs from the service's baked node even + * when the package root is the same. + */ +async function resolveManagedServiceNodeRunnerOverride(): Promise { + const command = await resolveGatewayService() + .readCommand(process.env) + .catch(() => null); + const serviceNode = resolveManagedServiceNodeRunner(command); + if (!serviceNode) { + return undefined; + } + const currentNode = resolveNodeRunner(); + const [serviceNodeReal, currentNodeReal] = await Promise.all([ + tryRealpathOrResolve(serviceNode), + tryRealpathOrResolve(currentNode), + ]); + if (serviceNodeReal === currentNodeReal) { + return undefined; + } + return serviceNode; +} + async function resolveManagedServicePackageUpdateRoot(params: { root: string; -}): Promise<{ root: string; previousRoot: string } | null> { +}): Promise { const command = await resolveGatewayService() .readCommand(process.env) .catch(() => null); @@ -1235,7 +1321,12 @@ async function resolveManagedServicePackageUpdateRoot(params: { if (currentRootReal === serviceRootReal) { return null; } - return { root: serviceRoot, previousRoot: params.root }; + const nodeRunner = resolveManagedServiceNodeRunner(command); + return { + root: serviceRoot, + previousRoot: params.root, + ...(nodeRunner ? { nodeRunner } : {}), + }; } async function runPackageInstallUpdate(params: { @@ -1249,6 +1340,7 @@ async function runPackageInstallUpdate(params: { managedServiceEnv?: NodeJS.ProcessEnv; invocationCwd?: string; honorPackageRoot?: boolean; + nodeRunner?: string; }): Promise { const manager = await resolveGlobalManager({ root: params.root, @@ -1313,7 +1405,13 @@ async function runPackageInstallUpdate(params: { await createUpdateConfigSnapshot(); return await runUpdateStep({ name: `${CLI_NAME} doctor`, - argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive", "--fix"], + argv: [ + params.nodeRunner ?? resolveNodeRunner(), + entryPath, + "doctor", + "--non-interactive", + "--fix", + ], cwd: verifiedPackageRoot, env: { ...resolvePostInstallDoctorEnv({ @@ -1785,6 +1883,7 @@ async function maybeRestartService(params: { gatewayPort: number; restartScriptPath?: string | null; invocationCwd?: string; + nodeRunner?: string; }): Promise { const verifyRestartedGateway = async (expectedGatewayVersion: string | undefined) => { const restartAfterStaleCleanup = async () => { @@ -1794,6 +1893,7 @@ async function maybeRestartService(params: { jsonMode: Boolean(params.opts.json), invocationCwd: params.invocationCwd, env: params.serviceEnv, + nodeRunner: params.nodeRunner, }); return; } @@ -1907,6 +2007,7 @@ async function maybeRestartService(params: { jsonMode: Boolean(params.opts.json), invocationCwd: params.invocationCwd, env: params.serviceEnv, + nodeRunner: params.nodeRunner, }); } catch (err) { // Always log the refresh failure so callers can detect it (issue #56772). @@ -1955,6 +2056,7 @@ async function maybeRestartService(params: { jsonMode: Boolean(params.opts.json), invocationCwd: params.invocationCwd, env: params.serviceEnv, + nodeRunner: params.nodeRunner, }); } else if ( !refreshedGatewayAlreadyHealthy && @@ -2548,6 +2650,7 @@ async function continuePostCoreUpdateInFreshProcess(params: { pluginInstallRecords: Record; preUpdateConfig?: PreUpdateConfigRestoreInput; updateStartedAtMs: number; + nodeRunner?: string; }): Promise<{ resumed: boolean; pluginUpdate?: PostCorePluginUpdateResult }> { const entryPath = await resolveGatewayInstallEntrypoint(params.root); if (!entryPath) { @@ -2576,7 +2679,7 @@ async function continuePostCoreUpdateInFreshProcess(params: { await writePostCorePluginInstallRecordsFile(installRecordsPath, params.pluginInstallRecords); await writePostCoreSourceConfigFile(sourceConfigPath, params.preUpdateConfig); const childStdio = resolvePostCoreUpdateChildStdio(); - const child = spawn(resolveNodeRunner(), argv, { + const child = spawn(params.nodeRunner ?? resolveNodeRunner(), argv, { stdio: childStdio, env: { ...stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)), @@ -2884,18 +2987,54 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { let fallbackToLatest = false; let packageInstallSpec: string | null = null; let packageAlreadyCurrent = false; - let managedServiceRootRedirect: { root: string; previousRoot: string } | null = null; + let managedServiceRootRedirect: ManagedServiceRootRedirect | null = null; + // Resolved independently of the root redirect so it covers the common case + // where the package root is the same but the user's PATH-resolved node + // differs from the node baked into the managed gateway service unit. + let managedServiceNodeRunner: string | undefined; if (updateInstallKind === "package") { managedServiceRootRedirect = await resolveManagedServicePackageUpdateRoot({ root }); if (managedServiceRootRedirect) { root = managedServiceRootRedirect.root; + managedServiceNodeRunner = managedServiceRootRedirect.nodeRunner; if (!opts.json) { defaultRuntime.log( theme.muted( `Targeting managed gateway service package root: ${managedServiceRootRedirect.root}`, ), ); + defaultRuntime.log( + theme.warn( + `Shell OpenClaw root differs from the managed gateway service root: ${managedServiceRootRedirect.previousRoot}`, + ), + ); + defaultRuntime.log( + theme.muted( + `After the update, make sure \`${CLI_NAME}\` on PATH resolves to the managed service root or reinstall the gateway service from the shell install you want to use.`, + ), + ); + if (managedServiceNodeRunner) { + defaultRuntime.log( + theme.muted(`Managed gateway service Node: ${managedServiceNodeRunner}`), + ); + } + } + } else { + // Roots match but the node binary may still differ (e.g. user switched + // nvm/fnm/brew node after gateway install). + managedServiceNodeRunner = await resolveManagedServiceNodeRunnerOverride(); + if (managedServiceNodeRunner && !opts.json) { + defaultRuntime.log( + theme.warn( + `Current Node (${resolveNodeRunner()}) differs from the managed gateway service Node (${managedServiceNodeRunner}).`, + ), + ); + defaultRuntime.log( + theme.muted( + `Using the managed service Node for this update so the gateway can start after the upgrade.`, + ), + ); } } } @@ -3047,6 +3186,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { const runtimePreflightError = await resolvePackageRuntimePreflightError({ tag, timeoutMs, + nodeRunner: managedServiceNodeRunner, }); if (runtimePreflightError) { defaultRuntime.error(runtimePreflightError); @@ -3114,7 +3254,9 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { jsonMode: Boolean(opts.json), managedServiceEnv: prePackageServiceStop?.serviceEnv, invocationCwd, - honorPackageRoot: managedServiceRootRedirect !== null, + honorPackageRoot: + managedServiceRootRedirect !== null || managedServiceNodeRunner !== undefined, + nodeRunner: managedServiceNodeRunner, }) : await runGitUpdate({ root, @@ -3240,6 +3382,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { opts, pluginInstallRecords: preUpdatePluginInstallRecords, updateStartedAtMs: startedAt, + nodeRunner: managedServiceNodeRunner, preUpdateConfig: configSnapshot.valid ? { sourceConfig: configSnapshot.sourceConfig, @@ -3365,6 +3508,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { gatewayPort, restartScriptPath, invocationCwd, + nodeRunner: managedServiceNodeRunner, }); if (!restartOk) { await markControlPlaneUpdateRestartSentinelFailureBestEffort({ From 3bc728eaa993655698d7b731aad72e03139efcd4 Mon Sep 17 00:00:00 2001 From: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Date: Tue, 19 May 2026 14:20:25 -0700 Subject: [PATCH 15/28] fix(twitch): register chat intent for refreshing auth (#83750) Summary: - The PR registers Twitch refreshing-token users with Twurple's chat intent and adds regression coverage for that contract. - Reproducibility: yes. by source and dependency contract. Current main does not register the chat intent, and ... RefreshingAuthProvider only resolves getAccessTokenForIntent('chat') when that intent is mapped to a user. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(twitch): register chat intent for refreshing auth Validation: - ClawSweeper review passed for head 1fdadcff049fbb49aade0a6b3a89ca2220e58310. - Required merge gates passed before the squash merge. Prepared head SHA: 1fdadcff049fbb49aade0a6b3a89ca2220e58310 Review: https://github.com/openclaw/openclaw/pull/83750#issuecomment-4481748086 Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/twitch/src/twitch-client.test.ts | 27 +++++++++++++++++++++ extensions/twitch/src/twitch-client.ts | 17 ++++++++----- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e9bf704743e..fd92be8c4d43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,7 @@ Docs: https://docs.openclaw.ai - Telegram: keep queued forum-topic follow-up messages from inheriting superseded source abort signals, so later same-topic user turns can still run and reply after an active turn is replaced. (#83827) Thanks @VACInc. - CLI/update: bypass npm freshness filters consistently during managed package and plugin installs so freshly published release plugins remain installable. Thanks @jalehman. - CLI/update: guide root-owned npm install EACCES recovery by stopping the managed Gateway before manual package replacement, then reinstalling and restarting the service. Fixes #83747. (#83757) Thanks @brokemac79. +- Twitch: register refreshing chat tokens with Twurple's chat intent so automatic token refresh keeps chat access available. (#83750) Thanks @TurboTheTurtle. - Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577. - Skills: refresh existing session skill snapshots when watched skill roots change, so changed extra skill directories take effect without starting a new session. Fixes #83782. (#83800) Thanks @hclsys. - Providers/Anthropic: preserve native image input for current Claude model rows when stale local catalog data marks them text-only. (#83756) Thanks @TurboTheTurtle. diff --git a/extensions/twitch/src/twitch-client.test.ts b/extensions/twitch/src/twitch-client.test.ts index a44b18fc6dd0..f7ad25e8d5cb 100644 --- a/extensions/twitch/src/twitch-client.test.ts +++ b/extensions/twitch/src/twitch-client.test.ts @@ -201,6 +201,33 @@ describe("TwitchClientManager", () => { ); }); + it("should register refreshing tokens for Twurple chat intent", async () => { + const refreshingAccount: TwitchAccountConfig = { + ...testAccount, + clientSecret: "test-client-secret", + refreshToken: "test-refresh-token", + expiresIn: 3600, + obtainmentTimestamp: 1_700_000_000_000, + }; + + await manager.getClient(refreshingAccount); + + expect(mockAddUserForToken).toHaveBeenCalledTimes(1); + expect(mockAddUserForToken).toHaveBeenCalledWith( + { + accessToken: "mock-token-from-tests", + refreshToken: "test-refresh-token", + expiresIn: 3600, + obtainmentTimestamp: 1_700_000_000_000, + }, + ["chat"], + ); + expect(mockAuthProvider.constructor).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + "Using RefreshingAuthProvider for testbot (automatic token refresh enabled)", + ); + }); + it("should throw error when clientId is missing", async () => { const accountWithoutClientId: TwitchAccountConfig = { ...testAccount, diff --git a/extensions/twitch/src/twitch-client.ts b/extensions/twitch/src/twitch-client.ts index 38f31bb4e753..1e96c3826c84 100644 --- a/extensions/twitch/src/twitch-client.ts +++ b/extensions/twitch/src/twitch-client.ts @@ -6,6 +6,8 @@ import { resolveTwitchToken } from "./token.js"; import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js"; import { normalizeToken } from "./utils/twitch.js"; +const TWITCH_CHAT_AUTH_INTENTS = ["chat"]; + /** * Manages Twitch chat client connections */ @@ -33,12 +35,15 @@ export class TwitchClientManager { }); await authProvider - .addUserForToken({ - accessToken: normalizedToken, - refreshToken: account.refreshToken ?? null, - expiresIn: account.expiresIn ?? null, - obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now(), - }) + .addUserForToken( + { + accessToken: normalizedToken, + refreshToken: account.refreshToken ?? null, + expiresIn: account.expiresIn ?? null, + obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now(), + }, + TWITCH_CHAT_AUTH_INTENTS, + ) .then((userId) => { this.logger.info( `Added user ${userId} to RefreshingAuthProvider for ${account.username}`, From a059309a9f9a7aeab9f476b6debe68674f60d631 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 20 May 2026 04:49:00 +0700 Subject: [PATCH 16/28] fix(agents): bound plugin-owned context-engine compaction with a safety timeout (#84083) Merged via squash. Prepared head SHA: 9121a1a5ea3a782da1a0346265e25c907a76519c Co-authored-by: 100yenadmin <239388517+100yenadmin@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- .../codex/src/app-server/compact.test.ts | 119 ++++++++++++++-- extensions/codex/src/app-server/compact.ts | 34 +++-- .../run-attempt.context-engine.test.ts | 93 +++++++++++++ .../codex/src/app-server/run-attempt.ts | 42 ++++-- src/agents/command/cli-compaction.test.ts | 85 ++++++++++++ src/agents/command/cli-compaction.ts | 36 +++-- ...d-runner.compaction-safety-timeout.test.ts | 128 ++++++++++++++++++ .../compact.hooks.harness.ts | 28 +++- .../pi-embedded-runner/compact.hooks.test.ts | 27 ++++ .../pi-embedded-runner/compact.queued.ts | 50 +++++-- .../compaction-safety-timeout.ts | 87 +++++++++++- .../run.overflow-compaction.test.ts | 20 +++ src/agents/pi-embedded-runner/run.ts | 66 ++++++--- src/context-engine/types.ts | 12 ++ src/plugin-sdk/agent-harness-runtime.ts | 9 ++ 17 files changed, 752 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd92be8c4d43..4c8fd41f25b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - CLI/TUI: include gateway plugin slash commands in TUI autocomplete, so connected sessions can suggest plugin-owned commands exposed by the running Gateway. (#83640) Thanks @se7en-agent. - Gateway/mobile: restore QR setup-code handoff of bounded operator tokens for iOS and Android onboarding while keeping admin and pairing scopes out of bootstrap. (#83684) Thanks @ngutman. - iOS: repair Release archive compilation for the TestFlight build. (#84255) Thanks @ngutman. +- Agents/compaction: bound plugin-owned CLI transcript compaction with the host safety timeout so a hung context engine can no longer stall post-turn cleanup. (#84083) Thanks @100yenadmin. ## 2026.5.19 diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index b1f9a0309cae..c9976fb40219 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -c3d3f4331b8e49a5f54aa4e322a0b03ab057715ed4f50b2b3e20fbbcbaf332db plugin-sdk-api-baseline.json -7b925ff856294bc8afc54aea9bf12a038d73821b4df297c60908032e1a4d85d9 plugin-sdk-api-baseline.jsonl +81675fa8adf4a3a7cc696ba77760e69224dadd15255daab2bdc83dfd8d290fed plugin-sdk-api-baseline.json +78d3e47f075a6645b771071aaa27832b25b97c797cdb4777a697614157d944ca plugin-sdk-api-baseline.jsonl diff --git a/extensions/codex/src/app-server/compact.test.ts b/extensions/codex/src/app-server/compact.test.ts index 9cfeb5a683a5..d5b3bab86fd9 100644 --- a/extensions/codex/src/app-server/compact.test.ts +++ b/extensions/codex/src/app-server/compact.test.ts @@ -461,17 +461,20 @@ describe("maybeCompactCodexAppServerSession", () => { expect(details.codexThreadBindingInvalidated).toBe(true); expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined(); expect(compact).toHaveBeenCalledTimes(1); - expect(compact).toHaveBeenCalledWith({ - sessionId: "session-1", - sessionKey: "agent:main:session-1", - sessionFile, - tokenBudget: 777, - currentTokenCount: 123, - compactionTarget: "threshold", - customInstructions: undefined, - force: true, - runtimeContext: { workspaceDir: tempDir, provider: "codex" }, - }); + expect(compact).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + tokenBudget: 777, + currentTokenCount: 123, + compactionTarget: "threshold", + customInstructions: undefined, + force: true, + runtimeContext: { workspaceDir: tempDir, provider: "codex" }, + abortSignal: expect.any(AbortSignal), + }), + ); expect(maintain).toHaveBeenCalledTimes(1); const [maintainCall] = maintain.mock.calls[0] ?? []; const maintainParams = maintainCall as @@ -683,6 +686,100 @@ describe("maybeCompactCodexAppServerSession", () => { expect(compactResult.reason).toBe("below threshold"); expect(maintain).not.toHaveBeenCalled(); }); + + describe("owning context-engine compaction safety timeout", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("bounds a hung owning context-engine compact() and reports a clean ok:false", async () => { + const sessionFile = await writeTestBinding(); + const compact = vi.fn(() => new Promise(() => {})); + const contextEngine: ContextEngine = { + info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true }, + assemble: vi.fn() as never, + ingest: vi.fn() as never, + compact, + }; + + vi.useFakeTimers(); + const pendingResult = maybeCompactCodexAppServerSession({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + workspaceDir: tempDir, + contextEngine, + // 1 s host-resolved compaction timeout. + config: { agents: { defaults: { compaction: { timeoutSeconds: 1 } } } }, + }); + + await vi.advanceTimersByTimeAsync(1_000); + const result = requireCompactResult(await pendingResult); + + expect(result.ok).toBe(false); + expect(result.compacted).toBe(false); + expect(result.reason).toContain("timed out"); + expect(compact).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it("threads a composed caller abort signal into the owning context-engine compact()", async () => { + const sessionFile = await writeTestBinding(); + const controller = new AbortController(); + const compact = vi.fn(async () => ({ + ok: true, + compacted: false, + reason: "below threshold", + })); + const contextEngine: ContextEngine = { + info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true }, + assemble: vi.fn() as never, + ingest: vi.fn() as never, + compact, + }; + + await maybeCompactCodexAppServerSession({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + workspaceDir: tempDir, + contextEngine, + abortSignal: controller.signal, + }); + + expect(compact).toHaveBeenCalledTimes(1); + expect(compact.mock.calls[0]?.[0]?.abortSignal).toBeInstanceOf(AbortSignal); + }); + + it("aborts a hung owning context-engine compact() when the caller signal fires", async () => { + const sessionFile = await writeTestBinding(); + const controller = new AbortController(); + const compact = vi.fn(() => new Promise(() => {})); + const contextEngine: ContextEngine = { + info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true }, + assemble: vi.fn() as never, + ingest: vi.fn() as never, + compact, + }; + + const pendingResult = maybeCompactCodexAppServerSession({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + workspaceDir: tempDir, + contextEngine, + abortSignal: controller.signal, + }); + + controller.abort(new Error("run aborted")); + const result = requireCompactResult(await pendingResult); + + expect(result.ok).toBe(false); + expect(result.compacted).toBe(false); + expect(result.reason).toContain("run aborted"); + expect(compact).toHaveBeenCalledTimes(1); + }); + }); }); function createFakeCodexClient(): { diff --git a/extensions/codex/src/app-server/compact.ts b/extensions/codex/src/app-server/compact.ts index e2532dd2da97..321806e4b0b4 100644 --- a/extensions/codex/src/app-server/compact.ts +++ b/extensions/codex/src/app-server/compact.ts @@ -1,7 +1,9 @@ import { + compactContextEngineWithSafetyTimeout, embeddedAgentLog, formatErrorMessage, isActiveHarnessContextEngine, + resolveCompactionTimeoutMs, resolveContextEngineOwnerPluginId, runHarnessContextEngineMaintenance, type CompactEmbeddedPiSessionParams, @@ -79,17 +81,27 @@ async function compactOwningContextEngine( }); let result: Awaited>; try { - result = await contextEngine.compact({ - sessionId: params.sessionId, - sessionKey: params.sessionKey, - sessionFile: params.sessionFile, - tokenBudget: params.contextTokenBudget, - currentTokenCount: params.currentTokenCount, - compactionTarget: params.trigger === "manual" ? "threshold" : "budget", - customInstructions: params.customInstructions, - force: params.trigger === "manual", - runtimeContext: params.contextEngineRuntimeContext, - }); + // Bound the plugin-owned compaction with the same finite safety timeout + // that protects native runtime compaction, and thread the caller's abort + // signal through, so a slow/hung plugin compact() cannot hang the Codex + // compaction lane indefinitely. A timeout/abort (or any thrown error) is + // converted to a clean { ok: false } result by the catch below. + result = await compactContextEngineWithSafetyTimeout( + contextEngine, + { + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + tokenBudget: params.contextTokenBudget, + currentTokenCount: params.currentTokenCount, + compactionTarget: params.trigger === "manual" ? "threshold" : "budget", + customInstructions: params.customInstructions, + force: params.trigger === "manual", + runtimeContext: params.contextEngineRuntimeContext, + }, + resolveCompactionTimeoutMs(params.config), + params.abortSignal, + ); } catch (error) { embeddedAgentLog.warn("context-engine-owned Codex app-server compaction failed", { sessionId: params.sessionId, diff --git a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts index cf16ff12a33a..31ef5b1bedf9 100644 --- a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts +++ b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts @@ -842,6 +842,99 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { expect(savedBinding?.contextEngine?.projection?.epoch).toBe("epoch-after"); }); + it("bounds a hung owning context-engine compaction during Codex overflow recovery", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + SessionManager.open(sessionFile).appendMessage( + assistantMessage("pre-compaction context", Date.now()) as never, + ); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-old", + cwd: workspaceDir, + dynamicToolsFingerprint: "[]", + contextEngine: { + schemaVersion: 1, + engineId: "lossless-claw", + policyFingerprint: + '{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}', + projection: { + schemaVersion: 1, + mode: "thread_bootstrap", + epoch: "epoch-before", + }, + }, + }); + // Owning-engine compaction that never settles. Without the safety timeout + // the awaited compact() would hang the whole Codex overflow-recovery turn; + // with it the call is bounded and forced compaction reports failure so the + // run still proceeds on a fresh thread. + const compact = vi.fn(() => new Promise(() => {})); + const assemble = vi.fn( + async ({ messages, prompt }: Parameters[0]) => ({ + messages: [...messages, userMessage(prompt ?? "", 11)], + estimatedTokens: 42, + systemPromptAddition: "context-engine system", + contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-before" }, + }), + ); + const contextEngine = createContextEngine({ assemble, compact }); + const harness = createStartedThreadHarness(async (method, requestParams) => { + const request = requireRecord(requestParams, `${method} params`); + if (method === "thread/resume") { + return threadStartResult("thread-old"); + } + if (method === "turn/start" && request.threadId === "thread-old") { + throw new Error("Codex ran out of room in the model's context window"); + } + if (method === "thread/start") { + return threadStartResult("thread-fresh"); + } + if (method === "turn/start" && request.threadId === "thread-fresh") { + return turnStartResult("turn-fresh"); + } + return undefined; + }); + const params = createParams(sessionFile, workspaceDir); + params.contextEngine = contextEngine; + params.contextTokenBudget = 400_000; + // 1 s host-resolved compaction timeout so the hung compact() is bounded + // well within the 5 s run timeout used by this harness. + params.config = { + agents: { defaults: { compaction: { timeoutSeconds: 1 } } }, + } as EmbeddedRunAttemptParams["config"]; + + const run = runCodexAppServerAttempt(params); + await vi.waitFor( + () => + expect(harness.requests.map((request) => request.method)).toEqual([ + "thread/resume", + "turn/start", + "thread/start", + "turn/start", + ]), + { timeout: 4_000 }, + ); + await harness.notify({ + method: "turn/completed", + params: { + threadId: "thread-fresh", + turnId: "turn-fresh", + turn: { + id: "turn-fresh", + status: "completed", + items: [{ type: "agentMessage", id: "msg-1", text: "fresh answer" }], + }, + }, + }); + const result = await run; + + expect(result.assistantTexts).toContain("fresh answer"); + expect(compact).toHaveBeenCalledTimes(1); + // The run-level abort signal is threaded into the owning-engine compact() + // so a cooperating engine can cancel its own in-flight work. + expect(compact.mock.calls[0]?.[0]?.abortSignal).toBeInstanceOf(AbortSignal); + }); + it("keeps current inbound context at the front of the Codex context-engine prompt", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 9a5b9a1c2b88..0fda9248dd9f 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -10,6 +10,7 @@ import { buildHarnessContextEngineRuntimeContextFromUsage, buildEmbeddedAttemptToolRunContext, clearActiveEmbeddedRun, + compactContextEngineWithSafetyTimeout, embeddedAgentLog, emitAgentEvent as emitGlobalAgentEvent, finalizeHarnessContextEngineTurn, @@ -21,6 +22,7 @@ import { normalizeAgentRuntimeTools, resolveAttemptSpawnWorkspaceDir, resolveAgentHarnessBeforePromptBuildResult, + resolveCompactionTimeoutMs, resolveModelAuthMode, resolveContextEngineOwnerPluginId, resolveSandboxContext, @@ -2224,21 +2226,31 @@ export async function runCodexAppServerAttempt( try { const runtimeContext = buildActiveContextEngineRuntimeContext(); const overflowTokenCount = params.contextTokenBudget ?? params.contextWindowInfo?.tokens; - const compactResult = await activeContextEngine.compact({ - sessionId: activeSessionId, - sessionKey: sandboxSessionKey, - sessionFile: activeSessionFile, - tokenBudget: params.contextTokenBudget, - force: true, - ...(overflowTokenCount ? { currentTokenCount: overflowTokenCount } : {}), - compactionTarget: "threshold", - runtimeContext: overflowTokenCount - ? { - ...runtimeContext, - currentTokenCount: overflowTokenCount, - } - : runtimeContext, - }); + // Bound the plugin-owned compaction with the same finite safety timeout + // that protects native runtime compaction, and thread the run-level + // abort signal through, so a slow/hung plugin compact() cannot stall + // Codex overflow recovery indefinitely. A timeout/abort surfaces as a + // thrown error handled by the catch below. + const compactResult = await compactContextEngineWithSafetyTimeout( + activeContextEngine, + { + sessionId: activeSessionId, + sessionKey: sandboxSessionKey, + sessionFile: activeSessionFile, + tokenBudget: params.contextTokenBudget, + force: true, + ...(overflowTokenCount ? { currentTokenCount: overflowTokenCount } : {}), + compactionTarget: "threshold", + runtimeContext: overflowTokenCount + ? { + ...runtimeContext, + currentTokenCount: overflowTokenCount, + } + : runtimeContext, + }, + resolveCompactionTimeoutMs(params.config), + runAbortController.signal, + ); embeddedAgentLog.info("codex app-server context-engine forced compaction result", { sessionId: activeSessionId, sessionKey: sandboxSessionKey, diff --git a/src/agents/command/cli-compaction.test.ts b/src/agents/command/cli-compaction.test.ts index 00b029971f70..a236ea312519 100644 --- a/src/agents/command/cli-compaction.test.ts +++ b/src/agents/command/cli-compaction.test.ts @@ -80,6 +80,8 @@ describe("runCliTurnCompactionLifecycle", () => { afterEach(async () => { resetCliCompactionTestDeps(); + vi.clearAllTimers(); + vi.useRealTimers(); await fs.rm(tmpDir, { recursive: true, force: true }); }); @@ -229,4 +231,87 @@ describe("runCliTurnCompactionLifecycle", () => { expect(calls).toEqual(["ensure", "resolve"]); }); + + it("bounds a hung CLI context-engine compaction and leaves resume state intact", async () => { + const sessionKey = "agent:main:cli"; + const sessionId = "session-cli-timeout"; + const sessionFile = path.join(tmpDir, "session-timeout.jsonl"); + const storePath = path.join(tmpDir, "sessions-timeout.json"); + await writeSessionFile({ sessionFile, sessionId }); + + const sessionEntry: SessionEntry = { + sessionId, + updatedAt: Date.now(), + sessionFile, + contextTokens: 1_000, + totalTokens: 950, + totalTokensFresh: true, + cliSessionBindings: { + "claude-cli": { sessionId: "claude-session" }, + }, + cliSessionIds: { + "claude-cli": "claude-session", + }, + claudeCliSessionId: "claude-session", + }; + const sessionStore: Record = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + const compactCalls: Array[0]> = []; + const maintenance = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 })); + const recordCliCompactionInStore = vi.fn(); + setCliCompactionTestDeps({ + resolveContextEngine: async () => ({ + ...buildContextEngine({ compactCalls }), + async compact(compactParams) { + compactCalls.push(compactParams); + return await new Promise(() => {}); + }, + }), + createPreparedEmbeddedPiSettingsManager: async () => ({ + getCompactionReserveTokens: () => 200, + getCompactionKeepRecentTokens: () => 0, + applyOverrides: () => {}, + }), + shouldPreemptivelyCompactBeforePrompt: () => ({ + route: "fits", + shouldCompact: false, + estimatedPromptTokens: 600, + promptBudgetBeforeReserve: 800, + overflowTokens: 0, + toolResultReducibleChars: 0, + effectiveReserveTokens: 200, + }), + resolveLiveToolResultMaxChars: () => 20_000, + runContextEngineMaintenance: maintenance, + recordCliCompactionInStore, + }); + + vi.useFakeTimers(); + const pending = runCliTurnCompactionLifecycle({ + cfg: { agents: { defaults: { compaction: { timeoutSeconds: 1 } } } } as OpenClawConfig, + sessionId, + sessionKey, + sessionEntry, + sessionStore, + storePath, + sessionAgentId: "main", + workspaceDir: tmpDir, + agentDir: tmpDir, + provider: "claude-cli", + model: "opus", + }); + + await vi.advanceTimersByTimeAsync(1_000); + const updatedEntry = await pending; + vi.useRealTimers(); + + expect(compactCalls).toHaveLength(1); + expect(compactCalls[0]?.abortSignal).toBeInstanceOf(AbortSignal); + expect(compactCalls[0]?.abortSignal?.aborted).toBe(true); + expect(maintenance).not.toHaveBeenCalled(); + expect(recordCliCompactionInStore).not.toHaveBeenCalled(); + expect(updatedEntry).toBe(sessionEntry); + expect(updatedEntry?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe("claude-session"); + }); }); diff --git a/src/agents/command/cli-compaction.ts b/src/agents/command/cli-compaction.ts index 25d90ba7b365..153c86f0a40e 100644 --- a/src/agents/command/cli-compaction.ts +++ b/src/agents/command/cli-compaction.ts @@ -8,6 +8,10 @@ import { resolveContextEngine as resolveContextEngineImpl } from "../../context- import type { ContextEngine } from "../../context-engine/types.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { buildEmbeddedCompactionRuntimeContext } from "../pi-embedded-runner/compaction-runtime-context.js"; +import { + compactContextEngineWithSafetyTimeout, + resolveCompactionTimeoutMs, +} from "../pi-embedded-runner/compaction-safety-timeout.js"; import { runContextEngineMaintenance as runContextEngineMaintenanceImpl } from "../pi-embedded-runner/context-engine-maintenance.js"; import { shouldPreemptivelyCompactBeforePrompt as shouldPreemptivelyCompactBeforePromptImpl } from "../pi-embedded-runner/run/preemptive-compaction.js"; import { resolveLiveToolResultMaxChars as resolveLiveToolResultMaxCharsImpl } from "../pi-embedded-runner/tool-result-truncation.js"; @@ -149,16 +153,28 @@ async function compactCliTranscript(params: { trigger: "cli_budget", }; - const compactResult = await params.contextEngine.compact({ - sessionId: params.sessionId, - sessionKey: params.sessionKey, - sessionFile: params.sessionFile, - tokenBudget: params.contextTokenBudget, - currentTokenCount: params.currentTokenCount, - force: true, - compactionTarget: "budget", - runtimeContext, - }); + let compactResult: Awaited>; + try { + compactResult = await compactContextEngineWithSafetyTimeout( + params.contextEngine, + { + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + tokenBudget: params.contextTokenBudget, + currentTokenCount: params.currentTokenCount, + force: true, + compactionTarget: "budget", + runtimeContext, + }, + resolveCompactionTimeoutMs(params.cfg), + ); + } catch (error) { + log.warn( + `CLI transcript compaction failed for ${params.provider}/${params.model}: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } if (!compactResult.compacted) { log.warn( diff --git a/src/agents/pi-embedded-runner.compaction-safety-timeout.test.ts b/src/agents/pi-embedded-runner.compaction-safety-timeout.test.ts index de1447a68b12..7938ba424fcb 100644 --- a/src/agents/pi-embedded-runner.compaction-safety-timeout.test.ts +++ b/src/agents/pi-embedded-runner.compaction-safety-timeout.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CompactResult, ContextEngine } from "../context-engine/types.js"; import { + compactContextEngineWithSafetyTimeout, compactWithSafetyTimeout, EMBEDDED_COMPACTION_TIMEOUT_MS, resolveCompactionTimeoutMs, @@ -159,3 +161,129 @@ describe("resolveCompactionTimeoutMs", () => { ).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS); }); }); + +describe("compactContextEngineWithSafetyTimeout", () => { + type CompactFn = ContextEngine["compact"]; + const baseParams: Parameters[0] = { + sessionId: "session-1", + sessionFile: "/tmp/session-1.jsonl", + tokenBudget: 100_000, + force: true, + }; + + beforeEach(() => { + vi.useRealTimers(); + vi.clearAllTimers(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("bounds a hung plugin compact() and rejects with a timeout error", async () => { + vi.useFakeTimers(); + const compact = vi.fn(() => new Promise(() => {})); + + const pending = compactContextEngineWithSafetyTimeout({ compact }, baseParams, 30); + const assertion = expect(pending).rejects.toThrow("Compaction timed out"); + + await vi.advanceTimersByTimeAsync(30); + await assertion; + expect(vi.getTimerCount()).toBe(0); + }); + + it("returns the plugin compact() result when it settles in time", async () => { + const result: CompactResult = { + ok: true, + compacted: true, + result: { tokensBefore: 1000, tokensAfter: 200 }, + }; + const compact = vi.fn(async () => result); + + await expect(compactContextEngineWithSafetyTimeout({ compact }, baseParams, 30)).resolves.toBe( + result, + ); + }); + + it("threads a signal that follows the run abort signal into the plugin compact() params", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const reason = new Error("run aborted"); + let compactAbortSignal: AbortSignal | undefined; + const compact = vi.fn((params) => { + compactAbortSignal = params.abortSignal; + return new Promise(() => {}); + }); + + const pending = compactContextEngineWithSafetyTimeout( + { compact }, + baseParams, + 30, + controller.signal, + ); + const assertion = expect(pending).rejects.toBe(reason); + + expect(compact).toHaveBeenCalledTimes(1); + expect(compactAbortSignal).toBeInstanceOf(AbortSignal); + expect(compactAbortSignal?.aborted).toBe(false); + + controller.abort(reason); + await assertion; + expect(compactAbortSignal?.aborted).toBe(true); + expect(compactAbortSignal?.reason).toBe(reason); + expect(vi.getTimerCount()).toBe(0); + }); + + it("threads the host timeout abort signal into the plugin compact() params", async () => { + vi.useFakeTimers(); + let compactAbortSignal: AbortSignal | undefined; + const compact = vi.fn((params) => { + compactAbortSignal = params.abortSignal; + return new Promise(() => {}); + }); + + const pending = compactContextEngineWithSafetyTimeout({ compact }, baseParams, 30); + const assertion = expect(pending).rejects.toThrow("Compaction timed out"); + + expect(compactAbortSignal).toBeInstanceOf(AbortSignal); + expect(compactAbortSignal?.aborted).toBe(false); + + await vi.advanceTimersByTimeAsync(30); + await assertion; + expect(compactAbortSignal?.aborted).toBe(true); + expect(compactAbortSignal?.reason).toBeInstanceOf(Error); + expect((compactAbortSignal?.reason as Error | undefined)?.message).toBe("Compaction timed out"); + expect(vi.getTimerCount()).toBe(0); + }); + + it("rejects promptly when the run abort signal fires before the timeout", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const abortError = new Error("run aborted"); + const compact = vi.fn(() => new Promise(() => {})); + + const pending = compactContextEngineWithSafetyTimeout( + { compact }, + baseParams, + EMBEDDED_COMPACTION_TIMEOUT_MS, + controller.signal, + ); + const assertion = expect(pending).rejects.toBe(abortError); + + controller.abort(abortError); + await assertion; + expect(vi.getTimerCount()).toBe(0); + }); + + it("preserves a thrown plugin compaction error", async () => { + const error = new Error("engine compaction failed"); + const compact = vi.fn(async () => { + throw error; + }); + + await expect(compactContextEngineWithSafetyTimeout({ compact }, baseParams, 30)).rejects.toBe( + error, + ); + }); +}); diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index 0a669d1dfe9f..a48aa72fa5b3 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -615,8 +615,8 @@ export async function loadCompactHooksHarness(): Promise<{ splitSdkTools: vi.fn(() => ({ customTools: [] })), })); - vi.doMock("./compaction-safety-timeout.js", () => ({ - compactWithSafetyTimeout: vi.fn( + vi.doMock("./compaction-safety-timeout.js", () => { + const compactWithSafetyTimeout = vi.fn( async ( compact: () => Promise, _timeoutMs?: number, @@ -652,9 +652,27 @@ export async function loadCompactHooksHarness(): Promise<{ }), ]); }, - ), - resolveCompactionTimeoutMs: vi.fn(() => 30_000), - })); + ); + return { + compactWithSafetyTimeout, + resolveCompactionTimeoutMs: vi.fn(() => 30_000), + // Mirror the real wrapper: bound the engine's compact() with the + // (mocked) safety timeout and thread the abort signal into its params. + compactContextEngineWithSafetyTimeout: vi.fn( + ( + contextEngine: { compact: (params: Record) => Promise }, + params: Record, + timeoutMs?: number, + abortSignal?: AbortSignal, + ) => + compactWithSafetyTimeout( + () => contextEngine.compact(abortSignal ? { ...params, abortSignal } : params), + timeoutMs, + abortSignal ? { abortSignal } : undefined, + ), + ), + }; + }); vi.doMock("./compaction-successor-transcript.js", async () => { const actual = await vi.importActual( diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index da3305e20063..e78c430a1c08 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -1386,6 +1386,33 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { expect(sync).not.toHaveBeenCalled(); }); + it("surfaces a hung/throwing engine compact() as a clean ok:false result", async () => { + hookRunner.hasHooks.mockReturnValue(true); + // The safety-timeout wrapper rejects on timeout; a thrown rejection here + // simulates that path. The queued lane must convert it to a result object + // instead of throwing a raw rejection at callers that only read result.ok. + contextEngineCompactMock.mockRejectedValue(new Error("Compaction timed out after 900000ms")); + + const result = await compactEmbeddedPiSession(wrappedCompactionArgs()); + + expect(result.ok).toBe(false); + expect(result.compacted).toBe(false); + expect(result.reason).toContain("timed out"); + expect(hookRunner.runAfterCompaction).not.toHaveBeenCalled(); + }); + + it("threads the caller abort signal into the engine compact() call", async () => { + const controller = new AbortController(); + + const result = await compactEmbeddedPiSession( + wrappedCompactionArgs({ abortSignal: controller.signal }), + ); + + expect(result.ok).toBe(true); + const compactArg = mockCallArg(contextEngineCompactMock) as { abortSignal?: AbortSignal }; + expect(compactArg.abortSignal).toBe(controller.signal); + }); + it("does not duplicate transcript updates or sync in the wrapper when the engine delegates compaction", async () => { const listener = vi.fn(); const cleanup = onSessionTranscriptUpdate(listener); diff --git a/src/agents/pi-embedded-runner/compact.queued.ts b/src/agents/pi-embedded-runner/compact.queued.ts index 6723d539b24a..617fe33a0219 100644 --- a/src/agents/pi-embedded-runner/compact.queued.ts +++ b/src/agents/pi-embedded-runner/compact.queued.ts @@ -32,6 +32,10 @@ import { buildEmbeddedCompactionRuntimeContext, resolveEmbeddedCompactionTarget, } from "./compaction-runtime-context.js"; +import { + compactContextEngineWithSafetyTimeout, + resolveCompactionTimeoutMs, +} from "./compaction-safety-timeout.js"; import { rotateTranscriptFileAfterCompaction, shouldRotateCompactionTranscript, @@ -176,17 +180,41 @@ export async function compactEmbeddedPiSession( }); } } - const result = await contextEngine.compact({ - sessionId: params.sessionId, - sessionKey: params.sessionKey, - sessionFile: params.sessionFile, - tokenBudget: contextTokenBudget, - currentTokenCount: params.currentTokenCount, - compactionTarget: params.trigger === "manual" ? "threshold" : "budget", - customInstructions: params.customInstructions, - force: params.trigger === "manual", - runtimeContext, - }); + // Bound the plugin-owned compaction with the same finite safety + // timeout that protects native runtime compaction, and thread the + // caller's abort signal through, so a slow/hung plugin compact() + // cannot hang the queued /compact lane indefinitely. A timeout/abort + // (or any thrown error) is surfaced as a clean { ok: false } result — + // matching how the run-loop overflow/timeout lanes handle it — instead + // of throwing a raw rejection at callers that only inspect result.ok. + let result: Awaited>; + try { + result = await compactContextEngineWithSafetyTimeout( + contextEngine, + { + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + tokenBudget: contextTokenBudget, + currentTokenCount: params.currentTokenCount, + compactionTarget: params.trigger === "manual" ? "threshold" : "budget", + customInstructions: params.customInstructions, + force: params.trigger === "manual", + runtimeContext, + }, + resolveCompactionTimeoutMs(params.config), + params.abortSignal, + ); + } catch (compactErr) { + log.warn("context-engine compaction failed", { + errorMessage: formatErrorMessage(compactErr), + }); + result = { + ok: false, + compacted: false, + reason: formatErrorMessage(compactErr), + }; + } const delegatedSessionId = result.result?.sessionId; const delegatedSessionFile = result.result?.sessionFile; const delegatedRotatedTranscript = diff --git a/src/agents/pi-embedded-runner/compaction-safety-timeout.ts b/src/agents/pi-embedded-runner/compaction-safety-timeout.ts index cbdfc49658f3..003ec97e6e23 100644 --- a/src/agents/pi-embedded-runner/compaction-safety-timeout.ts +++ b/src/agents/pi-embedded-runner/compaction-safety-timeout.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { CompactResult, ContextEngine } from "../../context-engine/types.js"; import { withTimeout } from "../../node-host/with-timeout.js"; export const EMBEDDED_COMPACTION_TIMEOUT_MS = 900_000; @@ -15,6 +16,44 @@ function createAbortError(signal: AbortSignal): Error { return err; } +function composeAbortSignals(...signals: Array): { + signal?: AbortSignal; + cleanup: () => void; +} { + const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal)); + if (activeSignals.length <= 1) { + return { signal: activeSignals[0], cleanup: () => {} }; + } + + const controller = new AbortController(); + const removers: Array<() => void> = []; + + const abortFrom = (signal: AbortSignal) => { + if (!controller.signal.aborted) { + controller.abort("reason" in signal ? signal.reason : undefined); + } + }; + + for (const signal of activeSignals) { + if (signal.aborted) { + abortFrom(signal); + break; + } + const onAbort = () => abortFrom(signal); + signal.addEventListener("abort", onAbort, { once: true }); + removers.push(() => signal.removeEventListener("abort", onAbort)); + } + + return { + signal: controller.signal, + cleanup: () => { + for (const remove of removers) { + remove(); + } + }, + }; +} + export function resolveCompactionTimeoutMs(cfg?: OpenClawConfig): number { const raw = cfg?.agents?.defaults?.compaction?.timeoutSeconds; if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { @@ -24,7 +63,7 @@ export function resolveCompactionTimeoutMs(cfg?: OpenClawConfig): number { } export async function compactWithSafetyTimeout( - compact: () => Promise, + compact: (abortSignal?: AbortSignal) => Promise, timeoutMs: number = EMBEDDED_COMPACTION_TIMEOUT_MS, opts?: { abortSignal?: AbortSignal; @@ -51,6 +90,7 @@ export async function compactWithSafetyTimeout( let externalAbortListener: (() => void) | undefined; let externalAbortPromise: Promise | undefined; const abortSignal = opts?.abortSignal; + const composedAbortSignal = composeAbortSignals(timeoutSignal, abortSignal); if (timeoutSignal) { timeoutListener = () => { @@ -74,11 +114,13 @@ export async function compactWithSafetyTimeout( } try { + const compactPromise = compact(composedAbortSignal.signal); if (externalAbortPromise) { - return await Promise.race([compact(), externalAbortPromise]); + return await Promise.race([compactPromise, externalAbortPromise]); } - return await compact(); + return await compactPromise; } finally { + composedAbortSignal.cleanup(); if (timeoutListener) { timeoutSignal?.removeEventListener("abort", timeoutListener); } @@ -91,3 +133,42 @@ export async function compactWithSafetyTimeout( "Compaction", ); } + +/** Parameters for a single {@link ContextEngine.compact} invocation. */ +export type ContextEngineCompactParams = Parameters[0]; + +/** + * Invoke a plugin-owned {@link ContextEngine.compact} bounded by the same + * finite safety timeout that protects native runtime compaction. + * + * Plugin context engines that advertise `ownsCompaction` previously had their + * `compact()` awaited with no timeout, no watchdog, and no abort signal — a + * slow or hung plugin compaction would hang the agent turn indefinitely. This + * wrapper closes that gap: + * - the call is bounded by `timeoutMs` (host-resolved, default + * {@link EMBEDDED_COMPACTION_TIMEOUT_MS}); on timeout it rejects with a + * "Compaction timed out" error so the caller's existing failure handling + * runs instead of hanging; + * - the timeout signal and caller `abortSignal` are both raced against the + * call (so a non-cooperating engine is still bounded) and threaded into the + * `compact()` params (so cooperating engines can cancel their own in-flight + * work). + * + * Callers keep their existing try/catch — a timeout or abort surfaces as a + * thrown error, never a silent hang. + */ +export function compactContextEngineWithSafetyTimeout( + contextEngine: Pick, + params: ContextEngineCompactParams, + timeoutMs: number = EMBEDDED_COMPACTION_TIMEOUT_MS, + abortSignal?: AbortSignal, +): Promise { + return compactWithSafetyTimeout( + (compactAbortSignal) => + contextEngine.compact( + compactAbortSignal ? { ...params, abortSignal: compactAbortSignal } : params, + ), + timeoutMs, + abortSignal ? { abortSignal } : undefined, + ); +} diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index 932410d62af9..71934a00654c 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -1678,6 +1678,26 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { expect(result.payloads?.[0]?.isError).toBe(true); }); + it("threads a composed run abort signal into engine-owned overflow compaction", async () => { + mockedContextEngine.info.ownsCompaction = true; + const abortController = new AbortController(); + mockedRunEmbeddedAttempt + .mockResolvedValueOnce(makeAttemptResult({ promptError: makeOverflowError() })) + .mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + mockedCompactDirect.mockResolvedValueOnce( + makeCompactionSuccess({ summary: "engine-owned compaction", tokensAfter: 50 }), + ); + + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + abortSignal: abortController.signal, + }); + + expect(mockedCompactDirect).toHaveBeenCalledTimes(1); + const compactArg = mockCallArg(mockedCompactDirect) as { abortSignal?: AbortSignal }; + expect(compactArg.abortSignal).toBeInstanceOf(AbortSignal); + }); + it("returns retry_limit when repeated retries never converge", async () => { mockedRunEmbeddedAttempt.mockClear(); mockedCompactDirect.mockClear(); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 36157fd7c6a8..602d3ef2b4cf 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -100,6 +100,10 @@ import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js" import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; import { runPostCompactionSideEffects } from "./compaction-hooks.js"; import { buildEmbeddedCompactionRuntimeContext } from "./compaction-runtime-context.js"; +import { + compactContextEngineWithSafetyTimeout, + resolveCompactionTimeoutMs, +} from "./compaction-safety-timeout.js"; import { resolveContextEngineCapabilities } from "./context-engine-capabilities.js"; import { runContextEngineMaintenance } from "./context-engine-maintenance.js"; import { hasMessagingToolDeliveryEvidence } from "./delivery-evidence.js"; @@ -1763,15 +1767,25 @@ export async function runEmbeddedPiAgent( attempt: timeoutCompactionAttempts, maxAttempts: MAX_TIMEOUT_COMPACTION_ATTEMPTS, }; - timeoutCompactResult = await contextEngine.compact({ - sessionId: activeSessionId, - sessionKey: params.sessionKey, - sessionFile: activeSessionFile, - tokenBudget: ctxInfo.tokens, - force: true, - compactionTarget: "budget", - runtimeContext: timeoutCompactionRuntimeContext, - }); + // Bound plugin-owned compaction with the same finite safety + // timeout that protects native compaction, and thread the + // run-level abort signal through, so a hung plugin compact() + // cannot stall timeout recovery indefinitely. A timeout/abort + // surfaces as a thrown error handled by the catch below. + timeoutCompactResult = await compactContextEngineWithSafetyTimeout( + contextEngine, + { + sessionId: activeSessionId, + sessionKey: params.sessionKey, + sessionFile: activeSessionFile, + tokenBudget: ctxInfo.tokens, + force: true, + compactionTarget: "budget", + runtimeContext: timeoutCompactionRuntimeContext, + }, + resolveCompactionTimeoutMs(params.config), + params.abortSignal, + ); } catch (compactErr) { log.warn( `[timeout-compaction] contextEngine.compact() threw during timeout recovery for ${provider}/${modelId}: ${String(compactErr)}`, @@ -1938,18 +1952,28 @@ export async function runEmbeddedPiAgent( attempt: overflowCompactionAttempts, maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS, }; - compactResult = await contextEngine.compact({ - sessionId: activeSessionId, - sessionKey: params.sessionKey, - sessionFile: activeSessionFile, - tokenBudget: ctxInfo.tokens, - ...(observedOverflowTokens !== undefined - ? { currentTokenCount: observedOverflowTokens } - : {}), - force: true, - compactionTarget: "budget", - runtimeContext: overflowCompactionRuntimeContext, - }); + // Bound plugin-owned compaction with the same finite safety + // timeout that protects native compaction, and thread the + // run-level abort signal through, so a hung plugin compact() + // cannot stall overflow recovery indefinitely. A timeout/abort + // surfaces as a thrown error handled by the catch below. + compactResult = await compactContextEngineWithSafetyTimeout( + contextEngine, + { + sessionId: activeSessionId, + sessionKey: params.sessionKey, + sessionFile: activeSessionFile, + tokenBudget: ctxInfo.tokens, + ...(observedOverflowTokens !== undefined + ? { currentTokenCount: observedOverflowTokens } + : {}), + force: true, + compactionTarget: "budget", + runtimeContext: overflowCompactionRuntimeContext, + }, + resolveCompactionTimeoutMs(params.config), + params.abortSignal, + ); if (compactResult.ok && compactResult.compacted) { adoptCompactionTranscript(compactResult); await runContextEngineMaintenance({ diff --git a/src/context-engine/types.ts b/src/context-engine/types.ts index a08626926bf4..5d9a07ea7272 100644 --- a/src/context-engine/types.ts +++ b/src/context-engine/types.ts @@ -298,6 +298,12 @@ export interface ContextEngine { /** * Compact context to reduce token usage. * May create summaries, prune old turns, etc. + * + * The host always bounds this call with a finite safety timeout (the same + * one that protects native runtime compaction). Engines that run long + * operations SHOULD additionally honor `abortSignal` so an in-flight + * compaction can be canceled promptly on run abort or host timeout instead + * of running to completion in the background. */ compact(params: { sessionId: string; @@ -313,6 +319,12 @@ export interface ContextEngine { customInstructions?: string; /** Optional runtime-owned context for engines that need caller state. */ runtimeContext?: ContextEngineRuntimeContext; + /** + * Optional abort signal honored before and during compaction. The host + * aborts it on run-level abort or when its compaction safety timeout + * fires; engines should stop work and reject promptly when it aborts. + */ + abortSignal?: AbortSignal; }): Promise; /** diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index 40ae18974763..4acd71ce53a0 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -195,6 +195,15 @@ export { isActiveHarnessContextEngine, runHarnessContextEngineMaintenance, } from "../agents/harness/context-engine-lifecycle.js"; +// Plugin-owned (`ownsCompaction`) compaction safety timeout. Exposed on the +// agent-harness-runtime surface so plugin harnesses such as Codex bound their +// own `ContextEngine.compact()` calls with the exact same finite, host-resolved +// timeout the built-in pi-embedded runner uses — one shared implementation, no +// copy-pasted watchdog. +export { + compactContextEngineWithSafetyTimeout, + resolveCompactionTimeoutMs, +} from "../agents/pi-embedded-runner/compaction-safety-timeout.js"; export { resolveContextEngineOwnerPluginId } from "../context-engine/registry.js"; export { runAgentHarnessAfterToolCallHook, From d7b23d5bcab9ac85d082d5bcb6a60b16f89570d0 Mon Sep 17 00:00:00 2001 From: Extra Small Date: Tue, 19 May 2026 15:10:17 -0700 Subject: [PATCH 17/28] fix(cli): honor --no-prefix-cwd in acp Fixes #83901. Honors Commander negated option handling for ACP prompt-prefix forwarding and adds focused CLI regression coverage. Verified with Crabbox AWS cbx_1689d0ad78e9 run run_a406418db6fe and Real behavior proof run 26127392365. --- src/cli/acp-cli.option-collisions.test.ts | 21 +++++++++++++++++++++ src/cli/acp-cli.ts | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/cli/acp-cli.option-collisions.test.ts b/src/cli/acp-cli.option-collisions.test.ts index a0cc1c3edfa2..17503f494517 100644 --- a/src/cli/acp-cli.option-collisions.test.ts +++ b/src/cli/acp-cli.option-collisions.test.ts @@ -11,6 +11,7 @@ type AcpClientOptions = { type AcpGatewayOptions = { gatewayPassword?: string; gatewayToken?: string; + prefixCwd?: boolean; }; const mocks = vi.hoisted(() => ({ @@ -89,6 +90,26 @@ describe("acp cli option collisions", () => { expect(clientOptions?.verbose).toBe(true); }); + it("forwards --no-prefix-cwd to the ACP bridge", async () => { + await parseAcp(["--no-prefix-cwd"]); + + expect(serveAcpGateway).toHaveBeenCalledTimes(1); + const gatewayOptions = requireFirstMockArg(serveAcpGateway) as { + prefixCwd?: boolean; + }; + expect(gatewayOptions?.prefixCwd).toBe(false); + }); + + it("defaults to prefixing the working directory", async () => { + await parseAcp([]); + + expect(serveAcpGateway).toHaveBeenCalledTimes(1); + const gatewayOptions = requireFirstMockArg(serveAcpGateway) as { + prefixCwd?: boolean; + }; + expect(gatewayOptions?.prefixCwd).toBe(true); + }); + it("loads gateway token/password from files", async () => { await withTempSecretFiles( "openclaw-acp-cli-", diff --git a/src/cli/acp-cli.ts b/src/cli/acp-cli.ts index 8578769ea308..c0727d69339b 100644 --- a/src/cli/acp-cli.ts +++ b/src/cli/acp-cli.ts @@ -22,7 +22,7 @@ export function registerAcpCli(program: Command) { .option("--session-label