From 7b8315d18e3ccda620e748289a455f0798dc9bf8 Mon Sep 17 00:00:00 2001 From: Michael Appel Date: Mon, 4 May 2026 13:39:00 -0400 Subject: [PATCH 001/107] fix: block SystemRoot/WINDIR in workspace .env and harden reg.exe path resolution [AI-assisted] (#74454) * fix: address issue * fix: address PR review feedback * Add changelog entry for PR #74454 --------- Co-authored-by: Devin Robison --- CHANGELOG.md | 1 + src/infra/dotenv.test.ts | 6 +++++ src/infra/windows-install-roots.test.ts | 36 ++++++++++++------------- src/infra/windows-install-roots.ts | 27 ++++--------------- 4 files changed, 30 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c328827d6a..1fa7c3b34af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -203,6 +203,7 @@ Docs: https://docs.openclaw.ai - Agents/subagents: detect prefix-only completion announce replies and fall back to the captured child result so requester chats no longer lose most of long sub-agent reports silently. Fixes #76412. Thanks @inxaos and @davemorin. - TUI: replace the stale-response watchdog notice with plain user-facing copy so stalled replies no longer surface backend or streaming internals. (#77120) Thanks @davemorin. - Security/Windows: validate `SystemRoot`/`WINDIR` env values through the Windows install-root validator and add them to the dangerous-host-env policy when resolving `icacls.exe`/`whoami.exe` for `openclaw security audit`, so workspace `.env` overrides and bare command names cannot redirect Windows ACL helpers to attacker-controlled binaries. (#74458) Thanks @mmaps. +- Security/Windows: pin Windows registry-probe `reg.exe` resolution to the canonical Windows install root in install-root probing, so `SystemRoot`/`WINDIR` env overrides cannot redirect registry queries during Windows host detection. (#74454) Thanks @mmaps. ## 2026.5.3-1 diff --git a/src/infra/dotenv.test.ts b/src/infra/dotenv.test.ts index a2f364bfd41..1beb8e9e19c 100644 --- a/src/infra/dotenv.test.ts +++ b/src/infra/dotenv.test.ts @@ -228,8 +228,10 @@ describe("loadDotEnv", () => { "HTTP_PROXY=http://evil-proxy:8080", "HOMEBREW_BREW_FILE=./evil-brew/bin/brew", "HOMEBREW_PREFIX=./evil-brew", + "SystemRoot=.\\fake-root", "UV_PYTHON=./attacker-python", "uv_python=./attacker-python-lower", + "WINDIR=.\\fake-windir", ].join("\n"), ); await writeEnvFile(path.join(stateDir, ".env"), "BAR=from-global\n"); @@ -245,8 +247,10 @@ describe("loadDotEnv", () => { delete process.env.HTTP_PROXY; delete process.env.HOMEBREW_BREW_FILE; delete process.env.HOMEBREW_PREFIX; + delete process.env.SystemRoot; delete process.env.UV_PYTHON; delete process.env.uv_python; + delete process.env.WINDIR; loadDotEnv({ quiet: true }); @@ -262,8 +266,10 @@ describe("loadDotEnv", () => { expect(process.env.HTTP_PROXY).toBeUndefined(); expect(process.env.HOMEBREW_BREW_FILE).toBeUndefined(); expect(process.env.HOMEBREW_PREFIX).toBeUndefined(); + expect(process.env.SystemRoot).toBeUndefined(); expect(process.env.UV_PYTHON).toBeUndefined(); expect(process.env.uv_python).toBeUndefined(); + expect(process.env.WINDIR).toBeUndefined(); }); }); }); diff --git a/src/infra/windows-install-roots.test.ts b/src/infra/windows-install-roots.test.ts index 55466bb0469..112cf1baca4 100644 --- a/src/infra/windows-install-roots.test.ts +++ b/src/infra/windows-install-roots.test.ts @@ -171,25 +171,25 @@ describe("getWindowsProgramFilesRoots", () => { }); describe("locateWindowsRegExe", () => { - it("prefers SystemRoot and WINDIR candidates over arbitrary drive scans", () => { - expect( - _private.getWindowsRegExeCandidates({ - SystemRoot: "D:\\Windows", - WINDIR: "E:\\Windows", - }), - ).toEqual([ - "D:\\Windows\\System32\\reg.exe", - "E:\\Windows\\System32\\reg.exe", - "C:\\Windows\\System32\\reg.exe", - ]); + it("uses the fixed Windows system reg.exe candidate", () => { + expect(_private.getWindowsRegExeCandidates()).toEqual(["C:\\Windows\\System32\\reg.exe"]); }); - it("dedupes equivalent roots case-insensitively", () => { - expect( - _private.getWindowsRegExeCandidates({ - SystemRoot: "D:\\Windows\\", - windir: "d:\\windows", - }), - ).toEqual(["D:\\Windows\\System32\\reg.exe", "C:\\Windows\\System32\\reg.exe"]); + it("does not resolve readable reg.exe files from env-derived roots", () => { + _resetWindowsInstallRootsForTests({ + isReadableFile: (filePath) => filePath === "D:\\Windows\\System32\\reg.exe", + }); + + const originalEnv = process.env; + try { + process.env = { + ...originalEnv, + SystemRoot: "D:\\Windows", + WINDIR: "E:\\Windows", + }; + expect(_private.locateWindowsRegExe()).toBeNull(); + } finally { + process.env = originalEnv; + } }); }); diff --git a/src/infra/windows-install-roots.ts b/src/infra/windows-install-roots.ts index 2c580d87028..ea3ba520c7a 100644 --- a/src/infra/windows-install-roots.ts +++ b/src/infra/windows-install-roots.ts @@ -92,29 +92,12 @@ function getEnvValueCaseInsensitive( return actualKey ? env[actualKey] : undefined; } -function getWindowsRegExeCandidates(env: Record): readonly string[] { - const seen = new Set(); - const candidates: string[] = []; - for (const root of [ - normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "SystemRoot")), - normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "WINDIR")), - DEFAULT_WINDOWS_SYSTEM_ROOT, - ]) { - if (!root) { - continue; - } - const key = normalizeLowercaseStringOrEmpty(root); - if (seen.has(key)) { - continue; - } - seen.add(key); - candidates.push(path.win32.join(root, "System32", "reg.exe")); - } - return candidates; +function getWindowsRegExeCandidates(): readonly string[] { + return [path.win32.join(DEFAULT_WINDOWS_SYSTEM_ROOT, "System32", "reg.exe")]; } -function locateWindowsRegExe(env: Record = process.env): string | null { - for (const candidate of getWindowsRegExeCandidates(env)) { +function locateWindowsRegExe(): string | null { + for (const candidate of getWindowsRegExeCandidates()) { if (isReadableFileFn(candidate)) { return candidate; } @@ -151,7 +134,7 @@ function runRegQuery( } function defaultQueryRegistryValue(key: string, valueName: string): string | null { - const regExe = locateWindowsRegExe(process.env); + const regExe = locateWindowsRegExe(); if (!regExe) { return null; } From c240e718e91e8ddb2590c25cb6cf9b815cfaf6fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Ba=C5=9Far?= <100199793+MertBasar0@users.noreply.github.com> Date: Mon, 4 May 2026 20:44:11 +0300 Subject: [PATCH 002/107] Feat/main session durable delivery pr (#75280) * feat: generalize pending-final-delivery for subagents and main session (cherry picked from commit 677fcbfaf87c8cd6de8b5bd02099b29b7d49e916) * feat(agents): implement Phase 2 durable final delivery for main sessions (cherry picked from commit b4e39f0ddf6dbd3f0d3b9226df8e714ad722f751) * fix(agents): narrow heartbeat deferral to pending final delivery * fix(agents): clear final delivery after dispatch * fix(agents): gate durable delivery retry capture --------- Co-authored-by: Mert Basar --- CHANGELOG.md | 1 + src/agents/agent-command.ts | 70 ++++++++++++- src/agents/command/delivery.test.ts | 46 +++++++++ src/agents/command/delivery.ts | 11 ++- .../main-session-restart-recovery.test.ts | 37 +++++++ src/agents/main-session-restart-recovery.ts | 37 +++++-- src/agents/subagent-registry-lifecycle.ts | 92 ++++++++++++++--- src/agents/subagent-registry.types.ts | 26 +++++ .../agent-runner.runreplyagent.e2e.test.ts | 99 +++++++++++++++++++ src/auto-reply/reply/agent-runner.ts | 52 +++++++++- ...ispatch-from-config.reply-dispatch.test.ts | 64 ++++++++++++ .../reply/dispatch-from-config.runtime.ts | 6 +- ...ispatch-from-config.shared.test-harness.ts | 16 +++ .../reply/dispatch-from-config.test.ts | 16 +++ src/auto-reply/reply/dispatch-from-config.ts | 42 ++++++++ src/auto-reply/reply/get-reply.ts | 34 +++++++ src/config/sessions/types.ts | 10 ++ src/infra/heartbeat-runner.ts | 18 ++++ 18 files changed, 647 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa7c3b34af..3b517c21ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -463,6 +463,7 @@ Docs: https://docs.openclaw.ai - Plugins/ClawHub: fall back to version metadata when the artifact resolver route is missing and keep the Docker ClawHub fixture aligned with npm-pack artifact resolution, avoiding false version-not-found failures during plugin install validation. Thanks @vincentkoc. - Providers/openai-codex: honor `providerConfig.baseUrl` in the dynamic-model synthesis fallback so codex providers configured with a custom upstream (for example a forwarding proxy) no longer silently bypass the configured URL when the registry has no template row to clone for the requested model id. (#76428) Thanks @arniesaha. - Status/channels: show configured channels in `openclaw status` and config-only `openclaw channels status` output even when the Gateway is unreachable, avoiding empty Channels tables on WSL and other no-Gateway paths. Thanks @vincentkoc. +- Agents/main-session: keep pending final delivery markers until the final reply is actually routed or queued, so restart and heartbeat recovery can retry failed delivery. Refs #65037. - Plugins/ClawHub: explain unavailable explicit ClawHub ClawPack artifact downloads with a temporary npm install hint while ClawHub artifact routing rolls out. Thanks @vincentkoc. - Media: accept home-relative `MEDIA:~/...` attachment paths while preserving existing file-read policy, traversal checks, and media type validation. Fixes #73796. Thanks @fabkury. - Onboarding/search: install official external web-search plugins such as Brave before saving provider config, and make doctor repair reconcile selected external search providers whose npm payload is missing. Thanks @vincentkoc. diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index dfde018cd13..e47193dedc6 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -17,8 +17,11 @@ import { import { formatErrorMessage } from "../infra/errors.js"; import { buildOutboundSessionContext } from "../infra/outbound/session-context.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { normalizeAgentId } from "../routing/session-key.js"; -import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +import { + isSubagentSessionKey, + normalizeAgentId, + resolveAgentIdFromSessionKey, +} from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { applyVerboseOverride } from "../sessions/level-overrides.js"; import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; @@ -1246,8 +1249,43 @@ async function agentCommandInternal( } const payloads = result.payloads ?? []; + + // Phase 2: Persist pending final delivery for main sessions before attempting delivery. + // This ensures that if the process restarts during delivery, the payload is durable. + if ( + opts.deliver === true && + sessionStore && + sessionKey && + payloads.length > 0 && + !isSubagentSessionKey(sessionKey) + ) { + const now = Date.now(); + const combinedPayload = payloads + .map((p) => (typeof p.text === "string" ? p.text : "")) + .filter(Boolean) + .join("\n\n"); + + if (combinedPayload) { + const entry = sessionStore[sessionKey] ?? sessionEntry; + const next: SessionEntry = { + ...entry, + pendingFinalDelivery: true, + pendingFinalDeliveryText: combinedPayload, + pendingFinalDeliveryCreatedAt: now, + updatedAt: now, + }; + await persistSessionEntry({ + sessionStore, + sessionKey, + storePath, + entry: next, + }); + sessionEntry = next; + } + } + const { deliverAgentCommandResult } = await loadDeliveryRuntime(); - return await deliverAgentCommandResult({ + const deliveryResult = await deliverAgentCommandResult({ cfg, deps: resolvedDeps, runtime, @@ -1257,6 +1295,32 @@ async function agentCommandInternal( result, payloads, }); + + // Phase 2: Clear pending delivery payload after successful delivery. + if ( + deliveryResult?.deliverySucceeded === true && + sessionStore && + sessionKey && + !isSubagentSessionKey(sessionKey) + ) { + const entry = sessionStore[sessionKey] ?? sessionEntry; + const next: SessionEntry = { + ...entry, + pendingFinalDelivery: undefined, + pendingFinalDeliveryText: undefined, + pendingFinalDeliveryCreatedAt: undefined, + updatedAt: Date.now(), + }; + await persistSessionEntry({ + sessionStore, + sessionKey, + storePath, + entry: next, + }); + sessionEntry = next; + } + + return deliveryResult; } finally { clearAgentRunContext(runId); } diff --git a/src/agents/command/delivery.test.ts b/src/agents/command/delivery.test.ts index 97eeaf7415b..2e82a135ead 100644 --- a/src/agents/command/delivery.test.ts +++ b/src/agents/command/delivery.test.ts @@ -215,6 +215,52 @@ describe("normalizeAgentCommandReplyPayloads", () => { }); }); + it("reports successful requested delivery", async () => { + deliverOutboundPayloadsMock.mockResolvedValue([]); + + const delivered = await deliverMediaReplyForTest({ + key: "agent:tester:slack:direct:alice", + agentId: "tester", + } as never); + + expect(delivered.deliverySucceeded).toBe(true); + }); + + it("does not report success when best-effort delivery records an error", async () => { + deliverOutboundPayloadsMock.mockImplementationOnce(async (params: unknown) => { + (params as { onError?: (err: unknown) => void }).onError?.(new Error("send failed")); + return []; + }); + + const runtime = { log: vi.fn(), error: vi.fn() }; + const delivered = await deliverAgentCommandResult({ + cfg: { + agents: { + list: [{ id: "tester", workspace: "/tmp/agent-workspace" }], + }, + } as OpenClawConfig, + deps: {} as CliDeps, + runtime: runtime as never, + opts: { + message: "go", + deliver: true, + bestEffortDeliver: true, + replyChannel: "slack", + replyTo: "#general", + } as AgentCommandOpts, + outboundSession: { + key: "agent:tester:slack:direct:alice", + agentId: "tester", + } as never, + sessionEntry: undefined, + payloads: [{ text: "here you go" }], + result: createResult(), + }); + + expect(delivered.deliverySucceeded).toBe(false); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("send failed")); + }); + it("threads agentId into the normalizer when sessionKey is unresolved", async () => { createReplyMediaPathNormalizerMock.mockReturnValue(async (payload: ReplyPayload) => payload); deliverOutboundPayloadsMock.mockResolvedValue([]); diff --git a/src/agents/command/delivery.ts b/src/agents/command/delivery.ts index 9a1f3c06524..2cad2a18179 100644 --- a/src/agents/command/delivery.ts +++ b/src/agents/command/delivery.ts @@ -354,6 +354,8 @@ export async function deliverAgentCommandResult(params: { } const deliveryPayloads = projectOutboundPayloadPlanForOutbound(outboundPayloadPlan); + let deliverySucceeded = false; + let deliveryHadError = false; const logPayload = (payload: NormalizedOutboundPayload) => { if (opts.json) { return; @@ -368,6 +370,10 @@ export async function deliverAgentCommandResult(params: { } runtime.log(output); }; + const markDeliveryError = (err: unknown) => { + deliveryHadError = true; + logDeliveryError(err); + }; if (!deliver) { for (const payload of deliveryPayloads) { logPayload(payload); @@ -385,12 +391,13 @@ export async function deliverAgentCommandResult(params: { replyToId: resolvedReplyToId ?? null, threadId: resolvedThreadTarget ?? null, bestEffort: bestEffortDeliver, - onError: (err) => logDeliveryError(err), + onError: markDeliveryError, onPayload: logPayload, deps: createOutboundSendDeps(deps), }); + deliverySucceeded = !deliveryHadError; } } - return { payloads: normalizedPayloads, meta: resultMeta }; + return { payloads: normalizedPayloads, meta: resultMeta, deliverySucceeded }; } diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index 0bf84c978ba..2aaba3004cd 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -278,6 +278,43 @@ describe("main-session-restart-recovery", () => { expect(store["agent:main:main"]?.abortedLastRun).toBe(true); }); + it("resumes marked sessions with a durable pending final delivery payload (Phase 2)", async () => { + const sessionsDir = await makeSessionsDir(); + const pendingPayload = "The final answer is 42."; + await writeStore(sessionsDir, { + "agent:main:main": { + sessionId: "main-session", + updatedAt: Date.now() - 10_000, + status: "running", + abortedLastRun: true, + pendingFinalDelivery: true, + pendingFinalDeliveryText: pendingPayload, + pendingFinalDeliveryCreatedAt: Date.now() - 5_000, + }, + }); + await writeTranscript(sessionsDir, "main-session", [ + { role: "user", content: "calculate the answer" }, + { role: "assistant", content: [{ type: "toolCall", id: "call-1", name: "calc" }] }, + { role: "toolResult", content: "42" }, + ]); + + const result = await recoverRestartAbortedMainSessions({ stateDir: tmpDir }); + + expect(result).toEqual({ recovered: 1, failed: 0, skipped: 0 }); + expect(callGateway).toHaveBeenCalledOnce(); + const callParams = vi.mocked(callGateway).mock.calls[0]?.[0].params as { message?: string }; + expect(callParams.message).toContain(pendingPayload); + + const store = loadSessionStore(path.join(sessionsDir, "sessions.json")); + expect(store["agent:main:main"]?.abortedLastRun).toBe(false); + expect(store["agent:main:main"]?.pendingFinalDelivery).toBe(true); + expect(store["agent:main:main"]?.pendingFinalDeliveryText).toBe(pendingPayload); + expect(store["agent:main:main"]?.pendingFinalDeliveryCreatedAt).toBeDefined(); + expect(store["agent:main:main"]?.pendingFinalDeliveryAttemptCount).toBe(1); + expect(store["agent:main:main"]?.pendingFinalDeliveryLastAttemptAt).toBeDefined(); + expect(store["agent:main:main"]?.pendingFinalDeliveryLastError).toBeNull(); + }); + it("does not scan ordinary running sessions without the restart-aborted marker", async () => { const sessionsDir = await makeSessionsDir(); await writeStore(sessionsDir, { diff --git a/src/agents/main-session-restart-recovery.ts b/src/agents/main-session-restart-recovery.ts index 585a931a286..68d0607511a 100644 --- a/src/agents/main-session-restart-recovery.ts +++ b/src/agents/main-session-restart-recovery.ts @@ -116,12 +116,15 @@ function resolveMainSessionResumeBlockReason(messages: unknown[]): string | null return null; } -function buildResumeMessage(): string { - return ( +function buildResumeMessage(pendingFinalDeliveryText?: string | null): string { + const base = "[System] Your previous turn was interrupted by a gateway restart while " + "OpenClaw was waiting on tool/model work. Continue from the existing " + - "transcript and finish the interrupted response." - ); + "transcript and finish the interrupted response."; + if (pendingFinalDeliveryText) { + return `${base}\n\nNote: The interrupted final reply was captured: "${pendingFinalDeliveryText}"`; + } + return base; } async function markSessionFailed(params: { @@ -140,6 +143,13 @@ async function markSessionFailed(params: { entry.abortedLastRun = true; entry.endedAt = Date.now(); entry.updatedAt = entry.endedAt; + entry.pendingFinalDelivery = undefined; + entry.pendingFinalDeliveryText = undefined; + entry.pendingFinalDeliveryCreatedAt = undefined; + entry.pendingFinalDeliveryLastAttemptAt = undefined; + entry.pendingFinalDeliveryAttemptCount = undefined; + entry.pendingFinalDeliveryLastError = undefined; + entry.pendingFinalDeliveryContext = undefined; store[params.sessionKey] = entry; }, { skipMaintenance: true }, @@ -150,12 +160,13 @@ async function markSessionFailed(params: { async function resumeMainSession(params: { storePath: string; sessionKey: string; + pendingFinalDeliveryText?: string | null; }): Promise { try { await callGateway<{ runId: string }>({ method: "agent", params: { - message: buildResumeMessage(), + message: buildResumeMessage(params.pendingFinalDeliveryText), sessionKey: params.sessionKey, idempotencyKey: crypto.randomUUID(), deliver: false, @@ -170,13 +181,24 @@ async function resumeMainSession(params: { if (!entry) { return; } + const now = Date.now(); entry.abortedLastRun = false; - entry.updatedAt = Date.now(); + entry.updatedAt = now; + if (entry.pendingFinalDelivery || entry.pendingFinalDeliveryText) { + entry.pendingFinalDeliveryLastAttemptAt = now; + entry.pendingFinalDeliveryAttemptCount = + (entry.pendingFinalDeliveryAttemptCount ?? 0) + 1; + entry.pendingFinalDeliveryLastError = null; + } store[params.sessionKey] = entry; }, { skipMaintenance: true }, ); - log.info(`resumed interrupted main session: ${params.sessionKey}`); + log.info( + `resumed interrupted main session: ${params.sessionKey}${ + params.pendingFinalDeliveryText ? " (with pending payload)" : "" + }`, + ); return true; } catch (err) { log.warn(`failed to resume interrupted main session ${params.sessionKey}: ${String(err)}`); @@ -290,6 +312,7 @@ async function recoverStore(params: { const resumed = await resumeMainSession({ storePath: params.storePath, sessionKey, + pendingFinalDeliveryText: entry.pendingFinalDeliveryText, }); if (resumed) { params.resumedSessionKeys.add(sessionKey); diff --git a/src/agents/subagent-registry-lifecycle.ts b/src/agents/subagent-registry-lifecycle.ts index 30753bdcc5e..e629366fa83 100644 --- a/src/agents/subagent-registry-lifecycle.ts +++ b/src/agents/subagent-registry-lifecycle.ts @@ -34,7 +34,7 @@ import { resolveAnnounceRetryDelayMs, safeRemoveAttachmentsDir, } from "./subagent-registry-helpers.js"; -import type { SubagentRunRecord } from "./subagent-registry.types.js"; +import type { PendingFinalDeliveryPayload, SubagentRunRecord } from "./subagent-registry.types.js"; import { deleteSubagentSessionForCleanup } from "./subagent-session-cleanup.js"; type CaptureSubagentCompletionReply = @@ -315,11 +315,64 @@ export function createSubagentRegistryLifecycleController(params: { } }; + const clearPendingFinalDelivery = (entry: SubagentRunRecord) => { + entry.pendingFinalDelivery = undefined; + entry.pendingFinalDeliveryCreatedAt = undefined; + entry.pendingFinalDeliveryLastAttemptAt = undefined; + entry.pendingFinalDeliveryAttemptCount = undefined; + entry.pendingFinalDeliveryLastError = undefined; + entry.pendingFinalDeliveryPayload = undefined; + }; + + const loadPendingFinalDeliveryPayload = ( + entry: SubagentRunRecord, + ): PendingFinalDeliveryPayload => { + return { + requesterSessionKey: + entry.pendingFinalDeliveryPayload?.requesterSessionKey ?? entry.requesterSessionKey, + requesterOrigin: entry.pendingFinalDeliveryPayload?.requesterOrigin ?? entry.requesterOrigin, + requesterDisplayKey: + entry.pendingFinalDeliveryPayload?.requesterDisplayKey ?? entry.requesterDisplayKey, + childSessionKey: entry.pendingFinalDeliveryPayload?.childSessionKey ?? entry.childSessionKey, + childRunId: entry.pendingFinalDeliveryPayload?.childRunId ?? entry.runId, + task: entry.pendingFinalDeliveryPayload?.task ?? entry.task, + label: entry.pendingFinalDeliveryPayload?.label ?? entry.label, + startedAt: entry.pendingFinalDeliveryPayload?.startedAt ?? entry.startedAt, + endedAt: entry.pendingFinalDeliveryPayload?.endedAt ?? entry.endedAt, + outcome: entry.pendingFinalDeliveryPayload?.outcome ?? entry.outcome, + expectsCompletionMessage: + entry.pendingFinalDeliveryPayload?.expectsCompletionMessage ?? + entry.expectsCompletionMessage, + spawnMode: entry.pendingFinalDeliveryPayload?.spawnMode ?? entry.spawnMode, + frozenResultText: + entry.pendingFinalDeliveryPayload?.frozenResultText ?? entry.frozenResultText, + fallbackFrozenResultText: + entry.pendingFinalDeliveryPayload?.fallbackFrozenResultText ?? + entry.fallbackFrozenResultText, + wakeOnDescendantSettle: + entry.pendingFinalDeliveryPayload?.wakeOnDescendantSettle ?? entry.wakeOnDescendantSettle, + }; + }; + + const markPendingFinalDelivery = (args: { entry: SubagentRunRecord; error?: string }) => { + const now = Date.now(); + const payload: PendingFinalDeliveryPayload = loadPendingFinalDeliveryPayload(args.entry); + + args.entry.pendingFinalDelivery = true; + args.entry.pendingFinalDeliveryCreatedAt ??= now; + args.entry.pendingFinalDeliveryLastAttemptAt = now; + args.entry.pendingFinalDeliveryAttemptCount = + (args.entry.pendingFinalDeliveryAttemptCount ?? 0) + 1; + args.entry.pendingFinalDeliveryLastError = args.error ?? null; + args.entry.pendingFinalDeliveryPayload = payload; + }; + const finalizeResumedAnnounceGiveUp = async (giveUpParams: { runId: string; entry: SubagentRunRecord; reason: "retry-limit" | "expiry"; }) => { + clearPendingFinalDelivery(giveUpParams.entry); safeSetSubagentTaskDeliveryStatus({ runId: giveUpParams.runId, childSessionKey: giveUpParams.entry.childSessionKey, @@ -486,6 +539,7 @@ export function createSubagentRegistryLifecycleController(params: { entry.completionAnnouncedAt = Date.now(); params.persist(); } + clearPendingFinalDelivery(entry); if (!options?.skipDeliveryStatus) { safeSetSubagentTaskDeliveryStatus({ runId, @@ -544,6 +598,7 @@ export function createSubagentRegistryLifecycleController(params: { } if (deferredDecision.kind === "give-up") { + clearPendingFinalDelivery(entry); safeSetSubagentTaskDeliveryStatus({ runId, childSessionKey: entry.childSessionKey, @@ -571,6 +626,10 @@ export function createSubagentRegistryLifecycleController(params: { return; } + markPendingFinalDelivery({ + entry, + error: didAnnounce ? undefined : "announce deferred or direct delivery failed", + }); entry.cleanupHandled = false; params.resumedRuns.delete(runId); params.persist(); @@ -631,7 +690,8 @@ export function createSubagentRegistryLifecycleController(params: { }); return true; } - const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin); + const pendingPayload = loadPendingFinalDeliveryPayload(entry); + const requesterOrigin = normalizeDeliveryContext(pendingPayload.requesterOrigin); let latestDeliveryError = entry.lastAnnounceDeliveryError; const finalizeAnnounceCleanup = (didAnnounce: boolean) => { if (!didAnnounce && latestDeliveryError) { @@ -650,24 +710,24 @@ export function createSubagentRegistryLifecycleController(params: { void params .runSubagentAnnounceFlow({ - childSessionKey: entry.childSessionKey, - childRunId: entry.runId, - requesterSessionKey: entry.requesterSessionKey, + childSessionKey: pendingPayload.childSessionKey, + childRunId: pendingPayload.childRunId, + requesterSessionKey: pendingPayload.requesterSessionKey, requesterOrigin, - requesterDisplayKey: entry.requesterDisplayKey, - task: entry.task, + requesterDisplayKey: pendingPayload.requesterDisplayKey, + task: pendingPayload.task, timeoutMs: params.subagentAnnounceTimeoutMs, cleanup: entry.cleanup, - roundOneReply: entry.frozenResultText ?? undefined, - fallbackReply: entry.fallbackFrozenResultText ?? undefined, + roundOneReply: pendingPayload.frozenResultText ?? undefined, + fallbackReply: pendingPayload.fallbackFrozenResultText ?? undefined, waitForCompletion: false, - startedAt: entry.startedAt, - endedAt: entry.endedAt, - label: entry.label, - outcome: entry.outcome, - spawnMode: entry.spawnMode, - expectsCompletionMessage: entry.expectsCompletionMessage, - wakeOnDescendantSettle: entry.wakeOnDescendantSettle === true, + startedAt: pendingPayload.startedAt, + endedAt: pendingPayload.endedAt, + label: pendingPayload.label, + outcome: pendingPayload.outcome, + spawnMode: pendingPayload.spawnMode, + expectsCompletionMessage: pendingPayload.expectsCompletionMessage, + wakeOnDescendantSettle: pendingPayload.wakeOnDescendantSettle === true, onDeliveryResult: (delivery) => { if (delivery.delivered) { if (entry.lastAnnounceDeliveryError !== undefined) { diff --git a/src/agents/subagent-registry.types.ts b/src/agents/subagent-registry.types.ts index 19577062ec1..f8640a9db22 100644 --- a/src/agents/subagent-registry.types.ts +++ b/src/agents/subagent-registry.types.ts @@ -3,6 +3,24 @@ import type { SubagentRunOutcome } from "./subagent-announce-output.js"; import type { SubagentLifecycleEndedReason } from "./subagent-lifecycle-events.js"; import type { SpawnSubagentMode } from "./subagent-spawn.types.js"; +export type PendingFinalDeliveryPayload = { + requesterSessionKey: string; + requesterOrigin?: DeliveryContext; + requesterDisplayKey: string; + childSessionKey: string; + childRunId: string; + task: string; + label?: string; + startedAt?: number; + endedAt?: number; + outcome?: SubagentRunOutcome; + expectsCompletionMessage?: boolean; + spawnMode?: SpawnSubagentMode; + frozenResultText?: string | null; + fallbackFrozenResultText?: string | null; + wakeOnDescendantSettle?: boolean; +}; + export type SubagentRunRecord = { runId: string; childSessionKey: string; @@ -39,7 +57,15 @@ export type SubagentRunRecord = { frozenResultCapturedAt?: number; fallbackFrozenResultText?: string | null; fallbackFrozenResultCapturedAt?: number; + /** Set after the subagent_ended hook has been emitted successfully once. */ endedHookEmittedAt?: number; + /** Durable marker that final user delivery still needs a retry/resume pass. */ + pendingFinalDelivery?: boolean; + pendingFinalDeliveryCreatedAt?: number; + pendingFinalDeliveryLastAttemptAt?: number; + pendingFinalDeliveryAttemptCount?: number; + pendingFinalDeliveryLastError?: string | null; + pendingFinalDeliveryPayload?: PendingFinalDeliveryPayload; completionAnnouncedAt?: number; attachmentsDir?: string; attachmentsRootDir?: string; diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 9aa9d6a56c8..78ee122be98 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -1,3 +1,6 @@ +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions.js"; import type { TypingMode } from "../../config/types.js"; @@ -112,6 +115,7 @@ function createMinimalRun(params?: { isRunActive?: () => boolean; shouldFollowup?: boolean; resolvedQueueMode?: string; + sessionCtx?: Partial; runOverrides?: Partial; }) { const typing = createMockTypingController(); @@ -119,6 +123,7 @@ function createMinimalRun(params?: { const sessionCtx = { Provider: "whatsapp", MessageSid: "msg", + ...params?.sessionCtx, } as unknown as TemplateContext; const resolvedQueue = { mode: params?.resolvedQueueMode ?? "interrupt", @@ -277,6 +282,100 @@ describe("runReplyAgent heartbeat followup guard", () => { }); }); +describe("runReplyAgent pending final delivery capture", () => { + async function createSessionStoreFile(entry: SessionEntry) { + const dir = await mkdtemp(join(tmpdir(), "openclaw-agent-runner-pending-")); + const storePath = join(dir, "sessions.json"); + await writeFile(storePath, JSON.stringify({ main: entry }), "utf8"); + return storePath; + } + + async function readStoredMainSession(storePath: string): Promise { + const raw = await readFile(storePath, "utf8"); + return JSON.parse(raw).main as SessionEntry; + } + + it("does not persist message-tool-only final replies for heartbeat replay", async () => { + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + }; + const sessionStore = { main: sessionEntry }; + const storePath = await createSessionStoreFile(sessionEntry); + state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "private final" }], + meta: {}, + }); + + const { run } = createMinimalRun({ + opts: { sourceReplyDeliveryMode: "message_tool_only" }, + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + + await run(); + + const stored = await readStoredMainSession(storePath); + expect(stored.pendingFinalDelivery).toBeUndefined(); + expect(stored.pendingFinalDeliveryText).toBeUndefined(); + }); + + it("does not persist sendPolicy-denied final replies for heartbeat replay", async () => { + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + sendPolicy: "deny", + }; + const sessionStore = { main: sessionEntry }; + const storePath = await createSessionStoreFile(sessionEntry); + state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "denied final" }], + meta: {}, + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + + await run(); + + const stored = await readStoredMainSession(storePath); + expect(stored.pendingFinalDelivery).toBeUndefined(); + expect(stored.pendingFinalDeliveryText).toBeUndefined(); + }); + + it("persists only visible non-reasoning final reply text", async () => { + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + }; + const sessionStore = { main: sessionEntry }; + const storePath = await createSessionStoreFile(sessionEntry); + state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hidden reasoning", isReasoning: true }, { text: "visible final" }], + meta: {}, + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + + await run(); + + const stored = await readStoredMainSession(storePath); + expect(stored.pendingFinalDelivery).toBe(true); + expect(stored.pendingFinalDeliveryText).toBe("visible final"); + }); +}); + describe("runReplyAgent typing (heartbeat)", () => { it("signals typing for normal runs", async () => { const onPartialReply = vi.fn(); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 5581a00e79a..2485061d593 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -26,6 +26,7 @@ import { } from "../../infra/diagnostic-trace-context.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js"; +import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { estimateUsageCost, @@ -82,6 +83,7 @@ import { } from "./reply-run-registry.js"; import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js"; import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js"; +import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js"; import { createTypingSignaler } from "./typing-mode.js"; import type { TypingController } from "./typing.js"; @@ -804,6 +806,14 @@ function joinCommitmentAssistantText(payloads: ReplyPayload[]): string { .trim(); } +function buildPendingFinalDeliveryText(payloads: ReplyPayload[]): string { + return payloads + .filter((payload) => payload.isReasoning !== true) + .map((payload) => payload.text) + .filter((text): text is string => Boolean(text)) + .join("\n\n"); +} + function enqueueCommitmentExtractionForTurn(params: { cfg: OpenClawConfig; commandBody: string; @@ -1817,11 +1827,51 @@ export async function runReplyAgent(params: { finalPayloads = appendUsageLine(finalPayloads, responseUsageLine); } - return finalizeWithFollowup( + // Capture only policy-visible final payloads in session store to support + // durable delivery retries. Hidden reasoning, message-tool-only replies, + // and sendPolicy-denied replies must not become heartbeat-replayable text. + if (sessionKey && storePath && finalPayloads.length > 0) { + const sendPolicy = resolveSendPolicy({ + cfg, + entry: activeSessionEntry, + sessionKey: params.runtimePolicySessionKey ?? sessionKey, + channel: + sessionCtx.OriginatingChannel ?? + sessionCtx.Surface ?? + sessionCtx.Provider ?? + activeSessionEntry?.channel, + chatType: activeSessionEntry?.chatType, + }); + const sourceReplyPolicy = resolveSourceReplyVisibilityPolicy({ + cfg, + ctx: sessionCtx, + requested: opts?.sourceReplyDeliveryMode, + sendPolicy, + }); + const pendingText = sourceReplyPolicy.suppressDelivery + ? "" + : buildPendingFinalDeliveryText(finalPayloads); + if (pendingText) { + await updateSessionStoreEntry({ + storePath, + sessionKey, + update: async () => ({ + pendingFinalDelivery: true, + pendingFinalDeliveryText: pendingText, + pendingFinalDeliveryCreatedAt: Date.now(), + updatedAt: Date.now(), + }), + }); + } + } + + const result = finalizeWithFollowup( finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads, queueKey, runFollowupTurn, ); + + return result; } catch (error) { if ( replyOperation.result?.kind === "aborted" && diff --git a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts index 51b7b32503d..4e36f370733 100644 --- a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts @@ -62,6 +62,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { sessionStoreMocks.loadSessionStore.mockReset().mockReturnValue({}); sessionStoreMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/mock-sessions.json"); sessionStoreMocks.resolveSessionStoreEntry.mockReset().mockReturnValue({ existing: undefined }); + sessionStoreMocks.updateSessionStoreEntry.mockClear(); acpManagerRuntimeMocks.getAcpSessionManager.mockReset(); acpManagerRuntimeMocks.getAcpSessionManager.mockImplementation(() => ({ resolveSession: () => ({ kind: "none" as const }), @@ -149,4 +150,67 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { counts: { tool: 0, block: 0, final: 0 }, }); }); + + it("clears pending final delivery after final dispatch succeeds", async () => { + hookMocks.runner.hasHooks.mockReturnValue(false); + sessionStoreMocks.currentEntry = { + sessionKey: "agent:test:session", + pendingFinalDelivery: true, + pendingFinalDeliveryText: "durable reply", + pendingFinalDeliveryCreatedAt: 1, + pendingFinalDeliveryLastAttemptAt: 2, + pendingFinalDeliveryAttemptCount: 3, + pendingFinalDeliveryLastError: "previous failure", + pendingFinalDeliveryContext: { source: "heartbeat" }, + }; + sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({ + existing: sessionStoreMocks.currentEntry, + }); + mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" }); + + const result = await dispatchReplyFromConfig({ + ctx: createHookCtx(), + cfg: emptyConfig, + dispatcher: createDispatcher(), + replyResolver: async () => ({ text: "durable reply" }), + }); + + expect(result.queuedFinal).toBe(true); + expect(sessionStoreMocks.updateSessionStoreEntry).toHaveBeenCalledOnce(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBeUndefined(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBeUndefined(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryCreatedAt).toBeUndefined(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryLastAttemptAt).toBeUndefined(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryAttemptCount).toBeUndefined(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryLastError).toBeUndefined(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryContext).toBeUndefined(); + }); + + it("preserves pending final delivery when final dispatch fails", async () => { + hookMocks.runner.hasHooks.mockReturnValue(false); + sessionStoreMocks.currentEntry = { + sessionKey: "agent:test:session", + pendingFinalDelivery: true, + pendingFinalDeliveryText: "durable reply", + pendingFinalDeliveryCreatedAt: 1, + }; + sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({ + existing: sessionStoreMocks.currentEntry, + }); + const dispatcher = createDispatcher(); + vi.mocked(dispatcher.sendFinalReply).mockReturnValue(false); + + const result = await dispatchReplyFromConfig({ + ctx: createHookCtx(), + cfg: emptyConfig, + dispatcher, + replyResolver: async () => ({ text: "durable reply" }), + }); + + expect(result.queuedFinal).toBe(false); + expect(sessionStoreMocks.updateSessionStoreEntry).not.toHaveBeenCalled(); + expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBe(true); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBe("durable reply"); + expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryCreatedAt).toBe(1); + }); }); diff --git a/src/auto-reply/reply/dispatch-from-config.runtime.ts b/src/auto-reply/reply/dispatch-from-config.runtime.ts index 7e14375d12e..3ce5f38ff46 100644 --- a/src/auto-reply/reply/dispatch-from-config.runtime.ts +++ b/src/auto-reply/reply/dispatch-from-config.runtime.ts @@ -1,3 +1,7 @@ export { resolveStorePath } from "../../config/sessions/paths.js"; -export { loadSessionStore, resolveSessionStoreEntry } from "../../config/sessions/store.js"; +export { + loadSessionStore, + resolveSessionStoreEntry, + updateSessionStoreEntry, +} from "../../config/sessions/store.js"; export { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; diff --git a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts index 2e1bc8c2ac6..0db92384e75 100644 --- a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts +++ b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts @@ -92,6 +92,21 @@ const sessionStoreMocks = vi.hoisted(() => ({ loadSessionStore: vi.fn(() => ({})), resolveStorePath: vi.fn(() => "/tmp/mock-sessions.json"), resolveSessionStoreEntry: vi.fn(() => ({ existing: sessionStoreMocks.currentEntry })), + updateSessionStoreEntry: vi.fn( + async (params: { + update: (entry: Record) => Promise | null>; + }) => { + if (!sessionStoreMocks.currentEntry) { + return null; + } + const patch = await params.update(sessionStoreMocks.currentEntry); + if (!patch) { + return sessionStoreMocks.currentEntry; + } + sessionStoreMocks.currentEntry = { ...sessionStoreMocks.currentEntry, ...patch }; + return sessionStoreMocks.currentEntry; + }, + ), })); const acpManagerRuntimeMocks = vi.hoisted(() => ({ getAcpSessionManager: vi.fn(), @@ -192,6 +207,7 @@ vi.mock("./dispatch-from-config.runtime.js", () => ({ resolveSessionStoreEntry: sessionStoreMocks.resolveSessionStoreEntry, resolveStorePath: sessionStoreMocks.resolveStorePath, triggerInternalHook: internalHookMocks.triggerInternalHook, + updateSessionStoreEntry: sessionStoreMocks.updateSessionStoreEntry, })); vi.mock("../../plugins/hook-runner-global.js", () => ({ initializeGlobalHookRunner: vi.fn(), diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index edb37f73ad6..40f5d4427cd 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -109,6 +109,21 @@ const sessionStoreMocks = vi.hoisted(() => ({ loadSessionStore: vi.fn(() => ({})), resolveStorePath: vi.fn(() => "/tmp/mock-sessions.json"), resolveSessionStoreEntry: vi.fn(() => ({ existing: sessionStoreMocks.currentEntry })), + updateSessionStoreEntry: vi.fn( + async (params: { + update: (entry: Record) => Promise | null>; + }) => { + if (!sessionStoreMocks.currentEntry) { + return null; + } + const patch = await params.update(sessionStoreMocks.currentEntry); + if (!patch) { + return sessionStoreMocks.currentEntry; + } + sessionStoreMocks.currentEntry = { ...sessionStoreMocks.currentEntry, ...patch }; + return sessionStoreMocks.currentEntry; + }, + ), })); const acpManagerRuntimeMocks = vi.hoisted(() => ({ getAcpSessionManager: vi.fn(), @@ -358,6 +373,7 @@ vi.mock("./dispatch-from-config.runtime.js", () => ({ resolveSessionStoreEntry: sessionStoreMocks.resolveSessionStoreEntry, resolveStorePath: sessionStoreMocks.resolveStorePath, triggerInternalHook: internalHookMocks.triggerInternalHook, + updateSessionStoreEntry: sessionStoreMocks.updateSessionStoreEntry, })); vi.mock("../../plugins/hook-runner-global.js", () => ({ diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index fccb38f84b6..7ca3c5cea9a 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -84,6 +84,7 @@ import { resolveSessionStoreEntry, resolveStorePath, triggerInternalHook, + updateSessionStoreEntry, } from "./dispatch-from-config.runtime.js"; import type { DispatchFromConfigParams, @@ -326,6 +327,34 @@ const resolveHarnessSourceVisibleRepliesDefault = (params: { } }; +async function clearPendingFinalDeliveryAfterSuccess(params: { + storePath?: string; + sessionKey?: string; +}): Promise { + if (!params.storePath || !params.sessionKey) { + return; + } + await updateSessionStoreEntry({ + storePath: params.storePath, + sessionKey: params.sessionKey, + update: async (entry) => { + if (!entry.pendingFinalDelivery && !entry.pendingFinalDeliveryText) { + return null; + } + return { + pendingFinalDelivery: undefined, + pendingFinalDeliveryText: undefined, + pendingFinalDeliveryCreatedAt: undefined, + pendingFinalDeliveryLastAttemptAt: undefined, + pendingFinalDeliveryAttemptCount: undefined, + pendingFinalDeliveryLastError: undefined, + pendingFinalDeliveryContext: undefined, + updatedAt: Date.now(), + }; + }, + }); +} + export type { DispatchFromConfigParams, DispatchFromConfigResult, @@ -1470,6 +1499,8 @@ export async function dispatchReplyFromConfig( let queuedFinal = false; let routedFinalCount = 0; + let attemptedFinalDelivery = false; + let finalDeliveryFailed = false; if (!suppressDelivery) { for (const reply of replies) { // Suppress reasoning payloads from channel delivery — channels using this @@ -1477,9 +1508,20 @@ export async function dispatchReplyFromConfig( if (reply.isReasoning === true) { continue; } + attemptedFinalDelivery = true; const finalReply = await sendFinalPayload(reply); queuedFinal = finalReply.queuedFinal || queuedFinal; routedFinalCount += finalReply.routedFinalCount; + if (!finalReply.queuedFinal && finalReply.routedFinalCount === 0) { + finalDeliveryFailed = true; + } + } + + if (attemptedFinalDelivery && !finalDeliveryFailed) { + await clearPendingFinalDeliveryAfterSuccess({ + storePath: sessionStoreEntry.storePath, + sessionKey: sessionStoreEntry.sessionKey ?? sessionKey, + }); } const ttsMode = resolveConfiguredTtsMode(cfg, { diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 1bbaf5e93ca..d81b02f4646 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -310,6 +310,40 @@ export async function getReplyFromConfig( triggerBodyNormalized, bodyStripped, } = sessionState; + + if (sessionEntry?.pendingFinalDelivery && sessionEntry.pendingFinalDeliveryText) { + const text = sessionEntry.pendingFinalDeliveryText; + + // If it's a heartbeat, we definitely want to try delivering the lost reply now. + // If it's a user message, we deliver the lost reply first, then continue. + // For now, let's just return the lost reply if it's a heartbeat. + if (opts?.isHeartbeat) { + const updatedAt = Date.now(); + const attemptCount = (sessionEntry.pendingFinalDeliveryAttemptCount ?? 0) + 1; + sessionEntry.pendingFinalDeliveryLastAttemptAt = updatedAt; + sessionEntry.pendingFinalDeliveryAttemptCount = attemptCount; + sessionEntry.pendingFinalDeliveryLastError = null; + sessionEntry.updatedAt = updatedAt; + if (sessionKey && sessionStore) { + sessionStore[sessionKey] = sessionEntry; + } + if (sessionKey && storePath) { + const { updateSessionStoreEntry } = await import("../../config/sessions.js"); + await updateSessionStoreEntry({ + storePath, + sessionKey, + update: async () => ({ + pendingFinalDeliveryLastAttemptAt: updatedAt, + pendingFinalDeliveryAttemptCount: attemptCount, + pendingFinalDeliveryLastError: null, + updatedAt, + }), + }); + } + return { text }; + } + } + if (resetTriggered && normalizeOptionalString(bodyStripped)) { const { applyResetModelOverride } = await loadSessionResetModelRuntime(); await applyResetModelOverride({ diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index fcffc810ab6..6e8062a5a86 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -267,6 +267,16 @@ export type SessionEntry = { inputTokens?: number; outputTokens?: number; totalTokens?: number; + /** Durable marker that final user reply delivery still needs a retry/resume pass. */ + pendingFinalDelivery?: boolean; + pendingFinalDeliveryCreatedAt?: number; + pendingFinalDeliveryLastAttemptAt?: number; + pendingFinalDeliveryAttemptCount?: number; + pendingFinalDeliveryLastError?: string | null; + /** Frozen reply text that needs delivery. */ + pendingFinalDeliveryText?: string | null; + /** Original delivery context (channel, recipient, etc). */ + pendingFinalDeliveryContext?: DeliveryContext; /** * Whether totalTokens reflects a fresh context snapshot for the latest run. * Undefined means legacy/unknown freshness; false forces consumers to treat diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index c7ef602e922..1e0dd20c75e 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -1208,6 +1208,24 @@ export async function runHeartbeatOnce(opts: { return { status: "skipped", reason: HEARTBEAT_SKIP_LANES_BUSY }; } + // Phase 2: Stronger heartbeat deferral while a final delivery replay is pending. + // Plain `updatedAt` changes are normal for heartbeat sessions and should not + // suppress heartbeat runs; only defer when final delivery recovery is active. + const { entry: recentSessionEntry } = resolveHeartbeatSession( + cfg, + agentId, + heartbeat, + opts.sessionKey, + ); + const HEARTBEAT_DEFER_WINDOW_MS = 30_000; + if ( + recentSessionEntry?.pendingFinalDelivery === true && + recentSessionEntry?.updatedAt && + startedAt - recentSessionEntry.updatedAt < HEARTBEAT_DEFER_WINDOW_MS + ) { + return { status: "skipped", reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT }; + } + // Preflight centralizes trigger classification, event inspection, and HEARTBEAT.md gating. const preflight = await resolveHeartbeatPreflight({ cfg, From 0e702f106313c1c63a32a6e7b3dbb5e96e620656 Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Mon, 4 May 2026 23:16:07 +0530 Subject: [PATCH 003/107] fix(gateway): clamp unbound websocket auth scopes [AI] (#77413) * fix: clamp unapproved trusted proxy websocket scopes * addressing claude review * addressing claude review * addressing ci * addressing ci * docs: add changelog entry for PR merge --- CHANGELOG.md | 1 + .../server-methods/config-write-flow.ts | 46 ++++++- .../server-methods/config.shared-auth.test.ts | 117 ++++++++++++++++++ .../server.auth.compat-baseline.test.ts | 88 ++++++++++++- src/gateway/server.auth.control-ui.suite.ts | 62 ++++++++++ src/gateway/server.impl.ts | 7 +- src/gateway/server/ws-connection.ts | 6 +- .../server/ws-connection/message-handler.ts | 30 +++-- .../server/ws-shared-generation.test.ts | 57 +++++++++ src/gateway/server/ws-shared-generation.ts | 38 +++++- 10 files changed, 435 insertions(+), 17 deletions(-) create mode 100644 src/gateway/server/ws-shared-generation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b517c21ba4..3eaf4e39e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987. - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. - fix(device-pair): require pairing scope for pair command [AI]. (#76377) Thanks @pgondhi987. - fix(qqbot): keep private commands off framework surface [AI]. (#77212) Thanks @pgondhi987. diff --git a/src/gateway/server-methods/config-write-flow.ts b/src/gateway/server-methods/config-write-flow.ts index 522a8988335..deffd26af02 100644 --- a/src/gateway/server-methods/config-write-flow.ts +++ b/src/gateway/server-methods/config-write-flow.ts @@ -13,7 +13,7 @@ import { } from "../../infra/restart-sentinel.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { getActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js"; -import { resolveEffectiveSharedGatewayAuth } from "../auth.js"; +import { resolveEffectiveSharedGatewayAuth, resolveGatewayAuth } from "../auth.js"; import { buildGatewayReloadPlan } from "../config-reload-plan.js"; import { resolveGatewayReloadSettings } from "../config-reload-settings.js"; import { formatControlPlaneActor, type ControlPlaneActor } from "../control-plane-audit.js"; @@ -31,7 +31,51 @@ export function resolveGatewayConfigPath(snapshot?: Pick): { + userHeader: string | undefined; + requiredHeaders: string[]; + allowUsers: string[]; + allowLoopback: boolean | undefined; +} { + return { + userHeader: auth.trustedProxy?.userHeader, + requiredHeaders: normalizeStringListForAuthCompare(auth.trustedProxy?.requiredHeaders), + allowUsers: normalizeStringListForAuthCompare(auth.trustedProxy?.allowUsers), + allowLoopback: auth.trustedProxy?.allowLoopback, + }; +} + export function didSharedGatewayAuthChange(prev: OpenClawConfig, next: OpenClawConfig): boolean { + const prevResolvedAuth = resolveGatewayAuth({ + authConfig: prev.gateway?.auth, + env: process.env, + tailscaleMode: prev.gateway?.tailscale?.mode, + }); + const nextResolvedAuth = resolveGatewayAuth({ + authConfig: next.gateway?.auth, + env: process.env, + tailscaleMode: next.gateway?.tailscale?.mode, + }); + if (prevResolvedAuth.mode === "trusted-proxy" || nextResolvedAuth.mode === "trusted-proxy") { + if (prevResolvedAuth.mode !== nextResolvedAuth.mode) { + return true; + } + return ( + !isDeepStrictEqual( + normalizeTrustedProxyAuthForCompare(prevResolvedAuth), + normalizeTrustedProxyAuthForCompare(nextResolvedAuth), + ) || + !isDeepStrictEqual( + normalizeStringListForAuthCompare(prev.gateway?.trustedProxies), + normalizeStringListForAuthCompare(next.gateway?.trustedProxies), + ) + ); + } + const prevAuth = resolveEffectiveSharedGatewayAuth({ authConfig: prev.gateway?.auth, env: process.env, diff --git a/src/gateway/server-methods/config.shared-auth.test.ts b/src/gateway/server-methods/config.shared-auth.test.ts index cffa90c73ce..78f5e6437a5 100644 --- a/src/gateway/server-methods/config.shared-auth.test.ts +++ b/src/gateway/server-methods/config.shared-auth.test.ts @@ -168,6 +168,123 @@ describe("config shared auth disconnects", () => { expect(disconnectClientsUsingSharedGatewayAuth).not.toHaveBeenCalled(); }); + it("disconnects gateway-auth clients when active trusted-proxy policy changes", async () => { + const prevConfig: OpenClawConfig = { + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + allowUsers: ["alice@example.com"], + }, + }, + trustedProxies: ["127.0.0.1"], + }, + }; + readConfigFileSnapshotForWriteMock.mockResolvedValue(createConfigWriteSnapshot(prevConfig)); + + const { options, disconnectClientsUsingSharedGatewayAuth } = createConfigHandlerHarness({ + method: "config.patch", + params: { + baseHash: "base-hash", + raw: JSON.stringify({ + gateway: { + auth: { + trustedProxy: { + userHeader: "x-forwarded-user", + allowUsers: ["bob@example.com"], + }, + }, + }, + }), + restartDelayMs: 1_000, + }, + }); + + await configHandlers["config.patch"](options); + await flushConfigHandlerMicrotasks(); + + expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); + expect(disconnectClientsUsingSharedGatewayAuth).toHaveBeenCalledTimes(1); + }); + + it("disconnects gateway-auth clients when trusted-proxy source list changes", async () => { + const prevConfig: OpenClawConfig = { + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + trustedProxies: ["127.0.0.1"], + }, + }; + readConfigFileSnapshotForWriteMock.mockResolvedValue(createConfigWriteSnapshot(prevConfig)); + + const { options, disconnectClientsUsingSharedGatewayAuth } = createConfigHandlerHarness({ + method: "config.patch", + params: { + baseHash: "base-hash", + raw: JSON.stringify({ + gateway: { + trustedProxies: ["10.0.0.10"], + }, + }), + restartDelayMs: 1_000, + }, + }); + + await configHandlers["config.patch"](options); + await flushConfigHandlerMicrotasks(); + + expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); + expect(disconnectClientsUsingSharedGatewayAuth).toHaveBeenCalledTimes(1); + }); + + it("does not disconnect gateway-auth clients when trusted-proxy lists are reordered", async () => { + const prevConfig: OpenClawConfig = { + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"], + allowUsers: ["alice@example.com", "bob@example.com"], + }, + }, + trustedProxies: ["127.0.0.1", "10.0.0.10"], + }, + }; + readConfigFileSnapshotForWriteMock.mockResolvedValue(createConfigWriteSnapshot(prevConfig)); + + const { options, disconnectClientsUsingSharedGatewayAuth } = createConfigHandlerHarness({ + method: "config.patch", + params: { + baseHash: "base-hash", + raw: JSON.stringify({ + gateway: { + auth: { + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-host", "x-forwarded-proto"], + allowUsers: ["bob@example.com", "alice@example.com"], + }, + }, + trustedProxies: ["10.0.0.10", "127.0.0.1"], + }, + }), + restartDelayMs: 1_000, + }, + }); + + await configHandlers["config.patch"](options); + await flushConfigHandlerMicrotasks(); + + expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); + expect(disconnectClientsUsingSharedGatewayAuth).not.toHaveBeenCalled(); + }); + it("still schedules a direct restart for hot mode when the reloader cannot apply the change", async () => { const prevConfig: OpenClawConfig = { gateway: { diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index 7542b03c2f2..285f021a855 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -6,7 +6,9 @@ import { connectReq, CONTROL_UI_CLIENT, ConnectErrorDetailCodes, + createSignedDevice, getFreePort, + readConnectChallengeNonce, openWs, originForPort, rpcReq, @@ -312,7 +314,7 @@ describe("gateway auth compatibility baseline", () => { testState.gatewayAuth = { mode: "none" }; delete process.env.OPENCLAW_GATEWAY_TOKEN; port = await getFreePort(); - server = await startGatewayServer(port); + server = await startGatewayServer(port, { controlUiEnabled: true }); }); afterAll(async () => { @@ -329,5 +331,89 @@ describe("gateway auth compatibility baseline", () => { ws.close(); } }); + + test("keeps auth-none control ui first-connect token absence unchanged", async () => { + const ws = await openWs(port, { origin: originForPort(port) }); + try { + const deviceIdentityPath = path.join( + os.tmpdir(), + `openclaw-auth-none-control-ui-first-${process.pid}-${port}.json`, + ); + const res = await connectReq(ws, { + skipDefaultAuth: true, + client: { ...CONTROL_UI_CLIENT }, + scopes: ["operator.read"], + deviceIdentityPath, + }); + expect(res.ok).toBe(true); + const helloOk = res.payload as + | { + auth?: { + deviceToken?: unknown; + }; + } + | undefined; + expect(helloOk?.auth?.deviceToken).toBeUndefined(); + } finally { + ws.close(); + } + }); + + test("keeps auth-none control ui stale-key token handoff unchanged", async () => { + const ws = await openWs(port, { origin: originForPort(port) }); + try { + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } = + await import("../infra/device-identity.js"); + const { approveDevicePairing, requestDevicePairing } = + await import("../infra/device-pairing.js"); + const nonce = await readConnectChallengeNonce(ws); + const identityPath = path.join( + os.tmpdir(), + `openclaw-auth-none-control-ui-${process.pid}-${port}.json`, + ); + const staleIdentityPath = path.join( + os.tmpdir(), + `openclaw-auth-none-control-ui-stale-${process.pid}-${port}.json`, + ); + const { identity, device } = await createSignedDevice({ + token: null, + scopes: ["operator.read"], + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + identityPath, + nonce, + }); + const staleIdentity = loadOrCreateDeviceIdentity(staleIdentityPath); + const pending = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(staleIdentity.publicKeyPem), + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + role: "operator", + scopes: ["operator.read"], + }); + await approveDevicePairing(pending.request.requestId, { + callerScopes: ["operator.admin"], + }); + + const res = await connectReq(ws, { + skipDefaultAuth: true, + client: { ...CONTROL_UI_CLIENT }, + scopes: ["operator.read"], + device, + }); + expect(res.ok).toBe(true); + const helloOk = res.payload as + | { + auth?: { + deviceToken?: unknown; + }; + } + | undefined; + expect(typeof helloOk?.auth?.deviceToken).toBe("string"); + } finally { + ws.close(); + } + }); }); }); diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index bc2048bb856..a8589113cd5 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -314,6 +314,68 @@ export function registerControlUiAndPairingSuite(): void { }); }); + test("clamps trusted-proxy control ui scopes for unpaired device identity", async () => { + const { replaceConfigFile } = await import("../config/config.js"); + testState.gatewayAuth = undefined; + testState.gatewayControlUi = { + ...testState.gatewayControlUi, + allowedOrigins: ["https://localhost"], + }; + await replaceConfigFile({ + nextConfig: { + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto"], + allowLoopback: true, + }, + }, + trustedProxies: ["127.0.0.1"], + controlUi: { + allowedOrigins: ["https://localhost"], + }, + }, + }, + afterWrite: { mode: "auto" }, + }); + await withControlUiGatewayServer(async ({ port }) => { + const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS); + try { + const challengeNonce = await readConnectChallengeNonce(ws); + const { device } = await createSignedDevice({ + token: null, + role: "operator", + scopes: ["operator.admin"], + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + nonce: challengeNonce, + }); + const res = await connectReq(ws, { + skipDefaultAuth: true, + scopes: ["operator.admin"], + device, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(true); + const payload = res.payload as + | { + auth?: { scopes?: string[]; deviceToken?: string }; + } + | undefined; + expect(payload?.auth?.scopes).toEqual([]); + expect(payload?.auth?.deviceToken).toBeUndefined(); + + const admin = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(admin.ok).toBe(false); + expect(admin.error?.message ?? "").toContain("missing scope"); + } finally { + ws.close(); + } + }); + }); + test("allows localhost ui clients without device identity when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret", { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 8c92fa07008..ce3ba130d75 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -735,9 +735,13 @@ export async function startGatewayServer( env: process.env, tailscaleMode, }), + config.gateway?.trustedProxies, ); const resolveCurrentSharedGatewaySessionGeneration = () => - resolveSharedGatewaySessionGeneration(getResolvedAuth()); + resolveSharedGatewaySessionGeneration( + getResolvedAuth(), + getRuntimeConfig().gateway?.trustedProxies, + ); const resolveSharedGatewaySessionGenerationForRuntimeSnapshot = () => resolveSharedGatewaySessionGeneration( resolveGatewayAuth({ @@ -746,6 +750,7 @@ export async function startGatewayServer( env: process.env, tailscaleMode, }), + getRuntimeConfig().gateway?.trustedProxies, ); const sharedGatewaySessionGenerationState: SharedGatewaySessionGenerationState = { current: resolveCurrentSharedGatewaySessionGeneration(), diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index c36aa488906..7ea32a99e42 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import type { Socket } from "node:net"; import type { RawData, WebSocket, WebSocketServer } from "ws"; +import { getRuntimeConfig } from "../../config/io.js"; import { resolveCanvasHostUrl } from "../../infra/canvas-host-url.js"; import { removeRemoteNodeInfo } from "../../infra/skills-remote.js"; import { upsertPresence } from "../../infra/system-presence.js"; @@ -205,7 +206,10 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti resolvedAuth, getResolvedAuth = () => resolvedAuth, getRequiredSharedGatewaySessionGeneration = () => - resolveSharedGatewaySessionGeneration(getResolvedAuth()), + resolveSharedGatewaySessionGeneration( + getResolvedAuth(), + getRuntimeConfig().gateway?.trustedProxies, + ), rateLimiter, browserRateLimiter, isStartupPending, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 9cd048aa56d..84b35be9118 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -837,9 +837,11 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar rejectUnauthorized(authResult); return; } - if (authMethod === "token" || authMethod === "password") { - const sharedGatewaySessionGeneration = - resolveSharedGatewaySessionGeneration(resolvedAuth); + if (authMethod === "token" || authMethod === "password" || authMethod === "trusted-proxy") { + const sharedGatewaySessionGeneration = resolveSharedGatewaySessionGeneration( + resolvedAuth, + trustedProxies, + ); const requiredSharedGatewaySessionGeneration = getRequiredSharedGatewaySessionGeneration?.(); if ( @@ -874,6 +876,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar resolvedAuth.mode, authMethod, ); + let hasServerApprovedDeviceTokenBaseline = false; if (device && devicePublicKey) { const formatAuditList = (items: string[] | undefined): string => { if (!items || items.length === 0) { @@ -1133,8 +1136,17 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar if (!ok) { return; } + hasServerApprovedDeviceTokenBaseline = true; + } else if (trustedProxyAuthOk) { + clearUnboundScopes(); + } else if ( + skipControlUiPairingForDevice || + (skipLocalBackendSelfPairing && authMethod !== "device-token") + ) { + hasServerApprovedDeviceTokenBaseline = true; } } else { + hasServerApprovedDeviceTokenBaseline = true; const claimedPlatform = connectParams.client.platform; const pairedPlatform = paired.platform; const claimedDeviceFamily = connectParams.client.deviceFamily; @@ -1222,9 +1234,10 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar } } - const deviceToken = device - ? await ensureDeviceToken({ deviceId: device.id, role, scopes }) - : null; + const deviceToken = + device && hasServerApprovedDeviceTokenBaseline + ? await ensureDeviceToken({ deviceId: device.id, role, scopes }) + : null; const bootstrapDeviceTokens: Array<{ deviceToken: string; role: string; @@ -1303,9 +1316,10 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar const canvasCapabilityExpiresAtMs = canvasCapability ? Date.now() + CANVAS_CAPABILITY_TTL_MS : undefined; - const usesSharedGatewayAuth = authMethod === "token" || authMethod === "password"; + const usesSharedGatewayAuth = + authMethod === "token" || authMethod === "password" || authMethod === "trusted-proxy"; const sharedGatewaySessionGeneration = usesSharedGatewayAuth - ? resolveSharedGatewaySessionGeneration(resolvedAuth) + ? resolveSharedGatewaySessionGeneration(resolvedAuth, trustedProxies) : undefined; const scopedCanvasHostUrl = canvasHostUrl && canvasCapability diff --git a/src/gateway/server/ws-shared-generation.test.ts b/src/gateway/server/ws-shared-generation.test.ts new file mode 100644 index 00000000000..9bc2f32b4f2 --- /dev/null +++ b/src/gateway/server/ws-shared-generation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { resolveSharedGatewaySessionGeneration } from "./ws-shared-generation.js"; + +describe("resolveSharedGatewaySessionGeneration", () => { + it("tracks trusted-proxy policy inputs", () => { + const baseAuth = { + mode: "trusted-proxy" as const, + allowTailscale: false, + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"], + allowUsers: ["alice@example.com", "bob@example.com"], + }, + }; + + const base = resolveSharedGatewaySessionGeneration(baseAuth, ["127.0.0.1", "10.0.0.10"]); + expect(base).toBeDefined(); + expect( + resolveSharedGatewaySessionGeneration( + { + ...baseAuth, + trustedProxy: { + ...baseAuth.trustedProxy, + requiredHeaders: ["x-forwarded-host", "x-forwarded-proto"], + allowUsers: ["bob@example.com", "alice@example.com"], + }, + }, + ["10.0.0.10", "127.0.0.1"], + ), + ).toBe(base); + expect( + resolveSharedGatewaySessionGeneration( + { + ...baseAuth, + trustedProxy: { + ...baseAuth.trustedProxy, + allowUsers: ["carol@example.com"], + }, + }, + ["127.0.0.1", "10.0.0.10"], + ), + ).not.toBe(base); + expect(resolveSharedGatewaySessionGeneration(baseAuth, ["10.0.0.11"])).not.toBe(base); + }); + + it("keeps shared-secret generations independent from proxy allowlists", () => { + const auth = { + mode: "token" as const, + allowTailscale: false, + token: "shared-token", + }; + + expect(resolveSharedGatewaySessionGeneration(auth, ["127.0.0.1"])).toBe( + resolveSharedGatewaySessionGeneration(auth, ["10.0.0.10"]), + ); + }); +}); diff --git a/src/gateway/server/ws-shared-generation.ts b/src/gateway/server/ws-shared-generation.ts index 27890458594..1003a25f612 100644 --- a/src/gateway/server/ws-shared-generation.ts +++ b/src/gateway/server/ws-shared-generation.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import type { GatewayTrustedProxyConfig } from "../../config/types.gateway.js"; import type { ResolvedGatewayAuth } from "../auth.js"; function resolveSharedSecret( @@ -18,14 +19,41 @@ function resolveSharedSecret( return null; } +function normalizeTrustedProxyConfig(trustedProxy: GatewayTrustedProxyConfig | undefined): { + userHeader: string | undefined; + requiredHeaders: string[]; + allowUsers: string[]; + allowLoopback: boolean | undefined; +} { + return { + userHeader: trustedProxy?.userHeader, + requiredHeaders: [...(trustedProxy?.requiredHeaders ?? [])].toSorted(), + allowUsers: [...(trustedProxy?.allowUsers ?? [])].toSorted(), + allowLoopback: trustedProxy?.allowLoopback, + }; +} + export function resolveSharedGatewaySessionGeneration( auth: ResolvedGatewayAuth, + trustedProxies?: readonly string[], ): string | undefined { const shared = resolveSharedSecret(auth); - if (!shared) { - return undefined; + if (shared) { + return createHash("sha256") + .update(`${shared.mode}\u0000${shared.secret}`, "utf8") + .digest("base64url"); } - return createHash("sha256") - .update(`${shared.mode}\u0000${shared.secret}`, "utf8") - .digest("base64url"); + if (auth.mode === "trusted-proxy") { + return createHash("sha256") + .update( + JSON.stringify({ + mode: auth.mode, + trustedProxy: normalizeTrustedProxyConfig(auth.trustedProxy), + trustedProxies: [...(trustedProxies ?? [])].toSorted(), + }), + "utf8", + ) + .digest("base64url"); + } + return undefined; } From 103cdd9d96f8f916742a6420b71583abd2f47732 Mon Sep 17 00:00:00 2001 From: "Satoshi F." <54186359+NikolaFC@users.noreply.github.com> Date: Mon, 4 May 2026 18:58:36 +0100 Subject: [PATCH 004/107] fix(gateway): add safe restart coordinator (#76923) Add a safe restart coordinator that preflights active Gateway work before restart. - expose gateway.restart.preflight and gateway.restart.request RPC methods - add explicit openclaw gateway restart --safe / openclaw daemon restart --safe path - narrow restart blockers to running non-ended tasks so queued records no longer block indefinitely - keep existing restart behavior unchanged; --force remains the immediate override Co-authored-by: NikolaFC <54186359+NikolaFC@users.noreply.github.com> Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/cli/daemon.md | 3 +- docs/cli/gateway.md | 10 ++ src/cli/daemon-cli/lifecycle.test.ts | 55 +++++- src/cli/daemon-cli/lifecycle.ts | 49 ++++++ .../register-service-commands.test.ts | 11 ++ .../daemon-cli/register-service-commands.ts | 2 + src/cli/daemon-cli/types.ts | 1 + src/cli/gateway-cli/run-loop.test.ts | 4 +- src/cli/gateway-cli/run-loop.ts | 2 +- src/gateway/method-scopes.ts | 2 + src/gateway/server-methods-list.ts | 2 + src/gateway/server-methods.ts | 9 +- src/gateway/server-methods/restart.ts | 22 +++ src/gateway/server-reload-handlers.test.ts | 4 +- src/gateway/server-reload-handlers.ts | 7 +- src/gateway/server-startup-early.ts | 3 +- src/gateway/server.reload.test.ts | 8 +- src/infra/restart-coordinator.test.ts | 119 +++++++++++++ src/infra/restart-coordinator.ts | 166 ++++++++++++++++++ ...k-registry.maintenance.issue-60299.test.ts | 39 ++++ src/tasks/task-registry.maintenance.ts | 16 +- 22 files changed, 519 insertions(+), 16 deletions(-) create mode 100644 src/gateway/server-methods/restart.ts create mode 100644 src/infra/restart-coordinator.test.ts create mode 100644 src/infra/restart-coordinator.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eaf4e39e7c..c34c89e193f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -457,6 +457,7 @@ Docs: https://docs.openclaw.ai - Status/update: resolve beta update-channel checks from the installed version when config still says `stable`, and let `status --deep` reuse live gateway channel credential state instead of warning on command-path-only token misses. - Doctor/plugins: preserve unmanaged third-party plugin `node_modules` during `doctor --fix`, while still pruning OpenClaw-managed runtime dependency caches. - Gateway/restart: add `openclaw gateway restart --force` and `--wait `, log active task run IDs before restart deferral timers, and report timeout restarts as explicit forced restarts. +- Gateway/restart: align `gateway.restart.safe` preflight with scheduled restart deferral by counting only active restart blockers (running non-ended tasks), so queued task records no longer keep "safe" restarts deferred indefinitely. - Discord: persist slash-command deploy hashes across process restarts so unchanged command sets skip redeploy and avoid restart-loop 429s. - Providers/LM Studio: normalize binary `off`/`on` reasoning metadata from Gemma 4 and other local models to LM Studio's accepted OpenAI-compatible `reasoning_effort` values. - Plugins/externalization: keep official external install docs, update examples, and live Codex npm checks on default npm tags instead of `@beta`. Thanks @vincentkoc. diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md index 4581398c4bc..69c5fa9f3fd 100644 --- a/docs/cli/daemon.md +++ b/docs/cli/daemon.md @@ -36,7 +36,7 @@ openclaw daemon uninstall - `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json` - `install`: `--port`, `--runtime `, `--token`, `--force`, `--json` -- `restart`: `--force`, `--wait `, `--json` +- `restart`: `--safe`, `--force`, `--wait `, `--json` - lifecycle (`uninstall|start|stop`): `--json` Notes: @@ -53,6 +53,7 @@ Notes: - If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, install is blocked until mode is set explicitly. - On macOS, `install` keeps LaunchAgent plists owner-only and loads managed service environment values through an owner-only file and wrapper instead of serializing API keys or auth-profile env refs into `EnvironmentVariables`. - If you intentionally run multiple gateways on one host, isolate ports, config/state, and workspaces; see [/gateway#multiple-gateways-same-host](/gateway#multiple-gateways-same-host). +- `restart --safe` asks the running Gateway to preflight active work and schedule one coalesced restart after active work drains. Plain `restart` keeps the existing service-manager behavior; `--force` remains the immediate override path. ## Prefer diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index af8a34cf7a8..7f159238833 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -105,6 +105,16 @@ openclaw gateway run Raw stream jsonl path. +## Restart the Gateway + +```bash +openclaw gateway restart +openclaw gateway restart --safe +openclaw gateway restart --force +``` + +`openclaw gateway restart --safe` asks the running Gateway to preflight active OpenClaw work before restarting. If queued operations, reply delivery, embedded runs, or task runs are active, the Gateway reports the blockers, coalesces duplicate safe restart requests, and restarts once the active work drains. Plain `restart` keeps the existing service-manager behavior for compatibility. Use `--force` only when you explicitly want the immediate override path. + Inline `--password` can be exposed in local process listings. Prefer `--password-file`, env, or a SecretRef-backed `gateway.auth.password`. diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index bd5cb7f3507..0603c738830 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -49,6 +49,7 @@ const probeGateway = vi.fn< configSnapshot: unknown; }> >(); +const callGatewayCli = vi.fn(); const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true); const loadConfig = vi.hoisted(() => vi.fn(() => ({}))); const recoverInstalledLaunchAgent = vi.hoisted(() => vi.fn()); @@ -77,6 +78,10 @@ vi.mock("../../gateway/probe.js", () => ({ }) => probeGateway(opts), })); +vi.mock("../../gateway/call.js", () => ({ + callGatewayCli: (opts: unknown) => callGatewayCli(opts), +})); + vi.mock("../../config/commands.js", () => ({ isRestartEnabled: (config?: { commands?: unknown }) => isRestartEnabled(config), })); @@ -113,7 +118,11 @@ vi.mock("./lifecycle-core.js", () => ({ describe("runDaemonRestart health checks", () => { let runDaemonStart: (opts?: { json?: boolean }) => Promise; - let runDaemonRestart: (opts?: { json?: boolean }) => Promise; + let runDaemonRestart: (opts?: { + json?: boolean; + safe?: boolean; + force?: boolean; + }) => Promise; let runDaemonStop: (opts?: { json?: boolean }) => Promise; let envSnapshot: ReturnType; @@ -162,6 +171,7 @@ describe("runDaemonRestart health checks", () => { signalVerifiedGatewayPidSync.mockReset(); formatGatewayPidList.mockReset(); probeGateway.mockReset(); + callGatewayCli.mockReset(); isRestartEnabled.mockReset(); loadConfig.mockReset(); recoverInstalledLaunchAgent.mockReset(); @@ -204,6 +214,31 @@ describe("runDaemonRestart health checks", () => { ok: true, configSnapshot: { commands: { restart: true } }, }); + callGatewayCli.mockResolvedValue({ + ok: true, + status: "deferred", + preflight: { + safe: false, + counts: { + queueSize: 1, + pendingReplies: 0, + embeddedRuns: 0, + activeTasks: 0, + totalActive: 1, + }, + blockers: [{ kind: "queue", count: 1, message: "1 queued or active operation(s)" }], + summary: "restart deferred: 1 queued or active operation(s)", + }, + restart: { + ok: true, + pid: 123, + signal: "SIGUSR1", + delayMs: 0, + mode: "emit", + coalesced: false, + cooldownMsApplied: 0, + }, + }); isRestartEnabled.mockReturnValue(true); signalVerifiedGatewayPidSync.mockImplementation(() => {}); formatGatewayPidList.mockImplementation((pids) => pids.join(", ")); @@ -230,6 +265,24 @@ describe("runDaemonRestart health checks", () => { expect(recoverInstalledLaunchAgent).toHaveBeenCalledWith({ result: "started" }); }); + it("requests a safe gateway restart over RPC without touching the service manager", async () => { + await runDaemonRestart({ json: true, safe: true }); + + expect(callGatewayCli).toHaveBeenCalledWith({ + method: "gateway.restart.request", + params: { reason: "gateway.restart.safe" }, + timeoutMs: 10_000, + }); + expect(runServiceRestart).not.toHaveBeenCalled(); + }); + + it("keeps force restart on the existing non-safe path", async () => { + await runDaemonRestart({ json: true, force: true }); + + expect(callGatewayCli).not.toHaveBeenCalled(); + expect(runServiceRestart).toHaveBeenCalled(); + }); + it("repairs stale loaded service definitions from gateway start", async () => { repairLoadedGatewayServiceForStart.mockResolvedValue({ result: "started", diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index cea631423c4..572530b82f1 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -1,12 +1,14 @@ import { isRestartEnabled } from "../../config/commands.flags.js"; import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js"; import { resolveGatewayService } from "../../daemon/service.js"; +import { callGatewayCli } from "../../gateway/call.js"; import { probeGateway } from "../../gateway/probe.js"; import { findVerifiedGatewayListenerPidsOnPortSync, formatGatewayPidList, signalVerifiedGatewayPidSync, } from "../../infra/gateway-processes.js"; +import type { SafeGatewayRestartRequestResult } from "../../infra/restart-coordinator.js"; import { type GatewayRestartIntent, writeGatewayRestartIntentSync } from "../../infra/restart.js"; import { defaultRuntime } from "../../runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -139,6 +141,50 @@ function resolveGatewayRestartIntentOptions( return undefined; } +function formatSafeRestartWarnings(result: SafeGatewayRestartRequestResult): string[] | undefined { + if (result.preflight.blockers.length === 0) { + return undefined; + } + return [result.preflight.summary]; +} + +async function requestSafeGatewayRestart(opts: DaemonLifecycleOptions): Promise { + if (opts.force) { + throw new Error("--safe cannot be combined with --force; omit --safe to force restart now"); + } + if (opts.wait !== undefined) { + throw new Error("--safe cannot be combined with --wait; safe restart uses gateway deferral"); + } + const result = await callGatewayCli({ + method: "gateway.restart.request", + params: { reason: "gateway.restart.safe" }, + timeoutMs: 10_000, + }); + const message = + result.status === "coalesced" + ? "safe restart request joined an existing pending gateway restart" + : result.status === "deferred" + ? "safe restart requested; gateway will restart after active work drains" + : "safe restart requested; gateway will restart momentarily"; + const payload = { + ok: true, + result: result.status, + message, + preflight: result.preflight, + restart: result.restart, + warnings: formatSafeRestartWarnings(result), + }; + if (opts.json) { + defaultRuntime.log(JSON.stringify(payload, null, 2)); + } else { + defaultRuntime.log(message); + if (result.preflight.blockers.length > 0) { + defaultRuntime.log(theme.warn(result.preflight.summary)); + } + } + return true; +} + async function restartGatewayWithoutServiceManager( port: number, restartIntent?: GatewayRestartIntent, @@ -218,6 +264,9 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) { * Throws/exits on check or restart failures. */ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promise { + if (opts.safe) { + return await requestSafeGatewayRestart(opts); + } const json = Boolean(opts.json); const service = resolveGatewayService(); let restartedWithoutServiceManager = false; diff --git a/src/cli/daemon-cli/register-service-commands.test.ts b/src/cli/daemon-cli/register-service-commands.test.ts index 0d33fe39bdc..983b4d90c93 100644 --- a/src/cli/daemon-cli/register-service-commands.test.ts +++ b/src/cli/daemon-cli/register-service-commands.test.ts @@ -70,6 +70,17 @@ describe("addGatewayServiceCommands", () => { ); }, }, + { + name: "forwards restart safe control", + argv: ["restart", "--safe"], + assert: () => { + expect(runDaemonRestart).toHaveBeenCalledWith( + expect.objectContaining({ + safe: true, + }), + ); + }, + }, { name: "forwards restart force control", argv: ["restart", "--force"], diff --git a/src/cli/daemon-cli/register-service-commands.ts b/src/cli/daemon-cli/register-service-commands.ts index 13081d38aff..335865cad6f 100644 --- a/src/cli/daemon-cli/register-service-commands.ts +++ b/src/cli/daemon-cli/register-service-commands.ts @@ -49,6 +49,7 @@ function resolveRestartOptions(cmdOpts: DaemonLifecycleOptions, command?: Comman return { ...cmdOpts, force: Boolean(cmdOpts.force || parentForce), + safe: Boolean(cmdOpts.safe), }; } @@ -122,6 +123,7 @@ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescri .command("restart") .description("Restart the Gateway service (launchd/systemd/schtasks)") .option("--force", "Restart immediately without waiting for active gateway work", false) + .option("--safe", "Request an OpenClaw-aware restart after active work drains", false) .option( "--wait ", "Wait duration before forcing restart (ms, 10s, 5m; 0 waits indefinitely)", diff --git a/src/cli/daemon-cli/types.ts b/src/cli/daemon-cli/types.ts index 90df43a6612..5d50d24fa34 100644 --- a/src/cli/daemon-cli/types.ts +++ b/src/cli/daemon-cli/types.ts @@ -27,5 +27,6 @@ export type DaemonInstallOptions = { export type DaemonLifecycleOptions = { json?: boolean; force?: boolean; + safe?: boolean; wait?: string; }; diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index 39c3effecd2..a73f9cf566f 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -390,7 +390,9 @@ describe("runGatewayLoop", () => { expect(waitForActiveEmbeddedRuns).not.toHaveBeenCalled(); expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "all" }); expect(gatewayLog.warn).toHaveBeenCalledWith( - expect.stringContaining("restart blocked by active task run(s): taskId=task-force"), + expect.stringContaining( + "restart blocked by active background task run(s): taskId=task-force", + ), ); expect(gatewayLog.warn).toHaveBeenCalledWith( "forced restart requested; skipping active work drain", diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 25e028219c7..d671b30521e 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -392,7 +392,7 @@ export async function runGatewayLoop(params: { `draining ${activeTasks} active task(s) and ${activeRuns} active embedded run(s) before restart ${formatRestartDrainBudget()}`, ); if (taskBlockers) { - gatewayLog.warn(`restart blocked by active task run(s): ${taskBlockers}`); + gatewayLog.warn(`restart blocked by active background task run(s): ${taskBlockers}`); } if (restartIntent?.force) { gatewayLog.warn("forced restart requested; skipping active work drain"); diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index af96bc0f09b..6cf956d3138 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -112,6 +112,7 @@ const METHOD_SCOPE_GROUPS: Record = { "cron.status", "cron.runs", "gateway.identity.get", + "gateway.restart.preflight", "system-presence", "last-heartbeat", "node.list", @@ -199,6 +200,7 @@ const METHOD_SCOPE_GROUPS: Record = { "system-event", "agents.files.set", "update.status", + "gateway.restart.request", ], [TALK_SECRETS_SCOPE]: [], }; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index c89d34e2b6a..48ade335a2c 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -148,6 +148,8 @@ const BASE_METHODS = [ "cron.run", "cron.runs", "gateway.identity.get", + "gateway.restart.preflight", + "gateway.restart.request", "system-presence", "system-event", "message.action", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 78a613a9188..dcb42620a5d 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -30,6 +30,7 @@ import { nodePendingHandlers } from "./server-methods/nodes-pending.js"; import { nodeHandlers } from "./server-methods/nodes.js"; import { pluginHostHookHandlers } from "./server-methods/plugin-host-hooks.js"; import { pushHandlers } from "./server-methods/push.js"; +import { restartHandlers } from "./server-methods/restart.js"; import { sendHandlers } from "./server-methods/send.js"; import { sessionsHandlers } from "./server-methods/sessions.js"; import { skillsHandlers } from "./server-methods/skills.js"; @@ -47,7 +48,12 @@ import { voicewakeHandlers } from "./server-methods/voicewake.js"; import { webHandlers } from "./server-methods/web.js"; import { wizardHandlers } from "./server-methods/wizard.js"; -const CONTROL_PLANE_WRITE_METHODS = new Set(["config.apply", "config.patch", "update.run"]); +const CONTROL_PLANE_WRITE_METHODS = new Set([ + "config.apply", + "config.patch", + "gateway.restart.request", + "update.run", +]); function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) { if (!client?.connect) { return null; @@ -110,6 +116,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...nodeHandlers, ...nodePendingHandlers, ...pushHandlers, + ...restartHandlers, ...sendHandlers, ...usageHandlers, ...agentHandlers, diff --git a/src/gateway/server-methods/restart.ts b/src/gateway/server-methods/restart.ts new file mode 100644 index 00000000000..7fdcabf15bf --- /dev/null +++ b/src/gateway/server-methods/restart.ts @@ -0,0 +1,22 @@ +import { + createSafeGatewayRestartPreflight, + requestSafeGatewayRestart, +} from "../../infra/restart-coordinator.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +function normalizeReason(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim().slice(0, 200) : undefined; +} + +export const restartHandlers: GatewayRequestHandlers = { + "gateway.restart.request": async ({ respond, params }) => { + const result = requestSafeGatewayRestart({ + reason: normalizeReason(params.reason), + delayMs: 0, + }); + respond(true, result); + }, + "gateway.restart.preflight": async ({ respond }) => { + respond(true, createSafeGatewayRestartPreflight()); + }, +}; diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index 9e038f29996..f5ea25d6ce3 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -126,7 +126,9 @@ describe("gateway restart deferral preflight", () => { ); expect(logReload.warn).toHaveBeenCalledWith( - expect.stringContaining("restart blocked by active task run(s): taskId=task-nightly"), + expect.stringContaining( + "restart blocked by active background task run(s): taskId=task-nightly", + ), ); expect(logReload.warn).toHaveBeenCalledWith(expect.stringContaining("runId=run-nightly")); diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index d04f9caa139..92754844cc8 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -22,7 +22,6 @@ import { } from "../secrets/runtime.js"; import { getInspectableActiveTaskRestartBlockers, - getInspectableTaskRegistrySummary, type ActiveTaskRestartBlocker, } from "../tasks/task-registry.maintenance.js"; import type { ChannelHealthMonitor } from "./channel-health-monitor.js"; @@ -143,7 +142,7 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) const queueSize = getTotalQueueSize(); const pendingReplies = getTotalPendingReplies(); const embeddedRuns = getActiveEmbeddedRunCount(); - const activeTasks = getInspectableTaskRegistrySummary().active; + const activeTasks = getInspectableActiveTaskRestartBlockers().length; return { queueSize, pendingReplies, @@ -164,7 +163,7 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) details.push(`${counts.embeddedRuns} embedded run(s)`); } if (counts.activeTasks > 0) { - details.push(`${counts.activeTasks} task run(s)`); + details.push(`${counts.activeTasks} background task run(s)`); } return details; }; @@ -420,7 +419,7 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) ); const taskBlockers = formatTaskBlockers(); if (taskBlockers) { - params.logReload.warn(`restart blocked by active task run(s): ${taskBlockers}`); + params.logReload.warn(`restart blocked by active background task run(s): ${taskBlockers}`); } deferGatewayRestartUntilIdle({ diff --git a/src/gateway/server-startup-early.ts b/src/gateway/server-startup-early.ts index 7153839b5cb..6d96316bcce 100644 --- a/src/gateway/server-startup-early.ts +++ b/src/gateway/server-startup-early.ts @@ -119,7 +119,8 @@ export async function startGatewayEarlyRuntime(params: { cronRuntimeAuthoritative: true, }); taskRegistryMaintenance.startTaskRegistryMaintenance(); - getActiveTaskCount = () => taskRegistryMaintenance.getInspectableTaskRegistrySummary().active; + getActiveTaskCount = () => + taskRegistryMaintenance.getInspectableActiveTaskRestartBlockers().length; } const skillsChangeUnsub = params.minimalTestGateway diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index f5f87acd370..e2296c94df4 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -763,7 +763,11 @@ describe("gateway hot reload", () => { const restartTesting = (await import("../infra/restart.js")).__testing; restartTesting.resetSigusr1State(); - hoisted.activeTaskCount.value = 1; + hoisted.activeTaskBlockers.push({ + taskId: "task-running-1", + status: "running", + runtime: "subagent", + }); const signalSpy = vi.fn(); process.once("SIGUSR1", signalSpy); vi.useFakeTimers(); @@ -792,7 +796,7 @@ describe("gateway hot reload", () => { await Promise.resolve(); expect(signalSpy).toHaveBeenCalledTimes(1); } finally { - hoisted.activeTaskCount.value = 0; + hoisted.activeTaskBlockers.length = 0; vi.useRealTimers(); process.removeListener("SIGUSR1", signalSpy); restartTesting.resetSigusr1State(); diff --git a/src/infra/restart-coordinator.test.ts b/src/infra/restart-coordinator.test.ts new file mode 100644 index 00000000000..c96567965fc --- /dev/null +++ b/src/infra/restart-coordinator.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createSafeGatewayRestartPreflight, + requestSafeGatewayRestart, +} from "./restart-coordinator.js"; + +const scheduleGatewaySigusr1Restart = vi.hoisted(() => vi.fn()); + +vi.mock("./restart.js", () => ({ + scheduleGatewaySigusr1Restart: (opts: unknown) => scheduleGatewaySigusr1Restart(opts), +})); + +describe("safe gateway restart coordinator", () => { + it("reports safe when no restart blockers are active", () => { + const preflight = createSafeGatewayRestartPreflight({ + getQueueSize: () => 0, + getPendingReplies: () => 0, + getEmbeddedRuns: () => 0, + getActiveTasks: () => 0, + getTaskBlockers: () => [], + }); + + expect(preflight).toEqual({ + safe: true, + counts: { + queueSize: 0, + pendingReplies: 0, + embeddedRuns: 0, + activeTasks: 0, + totalActive: 0, + }, + blockers: [], + summary: "safe to restart now", + }); + }); + + it("returns structured blockers for active work", () => { + const preflight = createSafeGatewayRestartPreflight({ + getQueueSize: () => 2, + getPendingReplies: () => 1, + getEmbeddedRuns: () => 1, + getActiveTasks: () => 1, + getTaskBlockers: () => [ + { + taskId: "task-1", + runId: "run-1", + status: "running", + runtime: "acp", + label: "build", + title: "Build branch", + }, + ], + }); + + expect(preflight.safe).toBe(false); + expect(preflight.counts.totalActive).toBe(5); + expect(preflight.blockers.map((blocker) => blocker.kind)).toEqual([ + "queue", + "reply", + "embedded-run", + "task", + ]); + expect(preflight.summary).toContain("restart deferred"); + expect(preflight.summary).toContain("taskId=task-1"); + }); + + it("schedules one restart request and marks active work as deferred", () => { + scheduleGatewaySigusr1Restart.mockReturnValueOnce({ + ok: true, + pid: 123, + signal: "SIGUSR1", + delayMs: 0, + mode: "emit", + coalesced: false, + cooldownMsApplied: 0, + }); + + const result = requestSafeGatewayRestart({ + reason: "test.safe", + inspect: { + getQueueSize: () => 1, + getPendingReplies: () => 0, + getEmbeddedRuns: () => 0, + getActiveTasks: () => 0, + getTaskBlockers: () => [], + }, + }); + + expect(result.status).toBe("deferred"); + expect(scheduleGatewaySigusr1Restart).toHaveBeenCalledWith({ + delayMs: 0, + reason: "test.safe", + }); + }); + + it("surfaces coalesced restart requests", () => { + scheduleGatewaySigusr1Restart.mockReturnValueOnce({ + ok: true, + pid: 123, + signal: "SIGUSR1", + delayMs: 500, + mode: "emit", + coalesced: true, + cooldownMsApplied: 0, + }); + + const result = requestSafeGatewayRestart({ + inspect: { + getQueueSize: () => 0, + getPendingReplies: () => 0, + getEmbeddedRuns: () => 0, + getActiveTasks: () => 0, + getTaskBlockers: () => [], + }, + }); + + expect(result.status).toBe("coalesced"); + }); +}); diff --git a/src/infra/restart-coordinator.ts b/src/infra/restart-coordinator.ts new file mode 100644 index 00000000000..aa2310c7ed3 --- /dev/null +++ b/src/infra/restart-coordinator.ts @@ -0,0 +1,166 @@ +import { getActiveEmbeddedRunCount } from "../agents/pi-embedded-runner/run-state.js"; +import { getTotalPendingReplies } from "../auto-reply/reply/dispatcher-registry.js"; +import { getTotalQueueSize } from "../process/command-queue.js"; +import { + getInspectableActiveTaskRestartBlockers, + type ActiveTaskRestartBlocker, +} from "../tasks/task-registry.maintenance.js"; +import { scheduleGatewaySigusr1Restart, type ScheduledRestart } from "./restart.js"; + +export type SafeGatewayRestartCounts = { + queueSize: number; + pendingReplies: number; + embeddedRuns: number; + activeTasks: number; + totalActive: number; +}; + +export type SafeGatewayRestartBlocker = { + kind: "queue" | "reply" | "embedded-run" | "task"; + count: number; + message: string; + task?: ActiveTaskRestartBlocker; +}; + +export type SafeGatewayRestartPreflight = { + safe: boolean; + counts: SafeGatewayRestartCounts; + blockers: SafeGatewayRestartBlocker[]; + summary: string; +}; + +export type SafeGatewayRestartRequestResult = { + ok: true; + status: "scheduled" | "deferred" | "coalesced"; + preflight: SafeGatewayRestartPreflight; + restart: ScheduledRestart; +}; + +type SafeRestartInspectors = { + getQueueSize: () => number; + getPendingReplies: () => number; + getEmbeddedRuns: () => number; + getActiveTasks: () => number; + getTaskBlockers: () => ActiveTaskRestartBlocker[]; +}; + +const defaultInspectors: SafeRestartInspectors = { + getQueueSize: getTotalQueueSize, + getPendingReplies: getTotalPendingReplies, + getEmbeddedRuns: getActiveEmbeddedRunCount, + getActiveTasks: () => getInspectableActiveTaskRestartBlockers().length, + getTaskBlockers: getInspectableActiveTaskRestartBlockers, +}; + +function normalizeCount(value: number): number { + return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; +} + +function formatTaskBlocker(task: ActiveTaskRestartBlocker): string { + return [ + `taskId=${task.taskId}`, + task.runId ? `runId=${task.runId}` : null, + `status=${task.status}`, + `runtime=${task.runtime}`, + task.label ? `label=${task.label}` : null, + task.title ? `title=${task.title.slice(0, 80)}` : null, + ] + .filter((value): value is string => Boolean(value)) + .join(" "); +} + +function createFallbackTaskBlocker(count: number): SafeGatewayRestartBlocker { + return { + kind: "task", + count, + message: `${count} active background task run(s)`, + }; +} + +export function createSafeGatewayRestartPreflight( + inspectors: Partial = {}, +): SafeGatewayRestartPreflight { + const resolved = { ...defaultInspectors, ...inspectors }; + const counts: SafeGatewayRestartCounts = { + queueSize: normalizeCount(resolved.getQueueSize()), + pendingReplies: normalizeCount(resolved.getPendingReplies()), + embeddedRuns: normalizeCount(resolved.getEmbeddedRuns()), + activeTasks: normalizeCount(resolved.getActiveTasks()), + totalActive: 0, + }; + counts.totalActive = + counts.queueSize + counts.pendingReplies + counts.embeddedRuns + counts.activeTasks; + + const blockers: SafeGatewayRestartBlocker[] = []; + if (counts.queueSize > 0) { + blockers.push({ + kind: "queue", + count: counts.queueSize, + message: `${counts.queueSize} queued or active operation(s)`, + }); + } + if (counts.pendingReplies > 0) { + blockers.push({ + kind: "reply", + count: counts.pendingReplies, + message: `${counts.pendingReplies} pending reply delivery operation(s)`, + }); + } + if (counts.embeddedRuns > 0) { + blockers.push({ + kind: "embedded-run", + count: counts.embeddedRuns, + message: `${counts.embeddedRuns} active embedded run(s)`, + }); + } + if (counts.activeTasks > 0) { + const taskBlockers = resolved.getTaskBlockers(); + if (taskBlockers.length === 0) { + blockers.push(createFallbackTaskBlocker(counts.activeTasks)); + } else { + for (const task of taskBlockers.slice(0, 8)) { + blockers.push({ + kind: "task", + count: 1, + message: formatTaskBlocker(task), + task, + }); + } + const omitted = counts.activeTasks - taskBlockers.length; + if (omitted > 0) { + blockers.push(createFallbackTaskBlocker(omitted)); + } + } + } + + const summary = + blockers.length === 0 + ? "safe to restart now" + : `restart deferred: ${blockers.map((blocker) => blocker.message).join("; ")}`; + return { + safe: counts.totalActive === 0, + counts, + blockers, + summary, + }; +} + +export function requestSafeGatewayRestart( + opts: { + reason?: string; + delayMs?: number; + inspect?: Partial; + } = {}, +): SafeGatewayRestartRequestResult { + const preflight = createSafeGatewayRestartPreflight(opts.inspect); + const restart = scheduleGatewaySigusr1Restart({ + delayMs: opts.delayMs ?? 0, + reason: opts.reason ?? "gateway.restart.safe", + }); + return { + ok: true, + status: restart.coalesced ? "coalesced" : preflight.safe ? "scheduled" : "deferred", + preflight, + restart, + }; +} diff --git a/src/tasks/task-registry.maintenance.issue-60299.test.ts b/src/tasks/task-registry.maintenance.issue-60299.test.ts index 9996e776898..fd010a6c4c8 100644 --- a/src/tasks/task-registry.maintenance.issue-60299.test.ts +++ b/src/tasks/task-registry.maintenance.issue-60299.test.ts @@ -10,6 +10,7 @@ import { getDetachedTaskLifecycleRuntime, } from "./detached-task-runtime.js"; import { + getInspectableActiveTaskRestartBlockers, previewTaskRegistryMaintenance, reconcileInspectableTasks, resetTaskRegistryMaintenanceRuntimeForTests, @@ -250,6 +251,44 @@ describe("task-registry maintenance issue #60299", () => { expect(currentTasks.get(task.taskId)).toMatchObject({ status: "running" }); }); + it("only treats started non-ended running tasks as restart blockers", () => { + const now = Date.now(); + const activeRunning = makeStaleTask({ + taskId: "task-running-live", + runtime: "cli", + status: "running", + createdAt: now, + startedAt: now, + lastEventAt: now, + runId: "run-running-live", + }); + const queued = makeStaleTask({ + taskId: "task-queued-durable", + runtime: "acp", + status: "queued", + createdAt: now, + startedAt: undefined, + lastEventAt: now, + }); + const staleInconsistent = makeStaleTask({ + taskId: "task-running-ended", + runtime: "subagent", + status: "running", + endedAt: now - 1_000, + }); + + createTaskRegistryMaintenanceHarness({ tasks: [activeRunning, queued, staleInconsistent] }); + + expect(getInspectableActiveTaskRestartBlockers()).toEqual([ + expect.objectContaining({ + taskId: "task-running-live", + status: "running", + runtime: "cli", + runId: "run-running-live", + }), + ]); + }); + it("marks subagent tasks lost when their child session recovery is tombstoned", async () => { const childSessionKey = "agent:main:subagent:wedged-child"; const task = makeStaleTask({ diff --git a/src/tasks/task-registry.maintenance.ts b/src/tasks/task-registry.maintenance.ts index 7b437baf3b6..11f66456584 100644 --- a/src/tasks/task-registry.maintenance.ts +++ b/src/tasks/task-registry.maintenance.ts @@ -839,7 +839,7 @@ configureTaskAuditTaskProvider(reconcileInspectableTasks); export type ActiveTaskRestartBlocker = { taskId: string; - status: Extract; + status: Extract; runtime: TaskRecord["runtime"]; runId?: string; label?: string; @@ -849,13 +849,23 @@ export type ActiveTaskRestartBlocker = { function isActiveTaskRestartBlockerStatus( status: TaskStatus, ): status is ActiveTaskRestartBlocker["status"] { - return status === "queued" || status === "running"; + return status === "running"; +} + +function isTaskRestartBlocker(task: TaskRecord): task is TaskRecord & { + status: ActiveTaskRestartBlocker["status"]; +} { + // A task that is merely queued has not started user work yet; durable queued + // work can survive a gateway restart and should not indefinitely block one. + // Likewise, stale records that still say "running" but already have endedAt + // are registry inconsistencies, not live restart blockers. + return isActiveTaskRestartBlockerStatus(task.status) && !task.endedAt; } export function getInspectableActiveTaskRestartBlockers(): ActiveTaskRestartBlocker[] { const blockers: ActiveTaskRestartBlocker[] = []; for (const task of reconcileInspectableTasks()) { - if (!isActiveTaskRestartBlockerStatus(task.status)) { + if (!isTaskRestartBlocker(task)) { continue; } const blocker: ActiveTaskRestartBlocker = { From 74ab62c6a297e2f92607c7f165a82598516f7fbb Mon Sep 17 00:00:00 2001 From: stain lu <109842185+stainlu@users.noreply.github.com> Date: Tue, 5 May 2026 02:13:53 +0800 Subject: [PATCH 005/107] fix: pass claude cli thinking effort (#77410) Summary: - Adds a plugin-owned CLI backend argument rewrite hook and wires Anthropic `claude-cli` to translate non-off `/think` levels into Claude Code `--effort`, with docs, changelog, API baseline, and tests. - Reproducibility: yes. Current main has a high-confidence source reproduction: choose `claude-cli`, set a non ... builds argv from backend args that contain no `--effort` even though `thinkLevel` exists on the run params. Automerge notes: - No ClawSweeper repair was needed after automerge opt-in. Validation: - ClawSweeper review passed for head be17754009e5651ec9df6472f46fbffdfe3346e7. - Required merge gates passed before the squash merge. Prepared head SHA: be17754009e5651ec9df6472f46fbffdfe3346e7 Review: https://github.com/openclaw/openclaw/pull/77410#issuecomment-4372812685 Co-authored-by: stainlu --- CHANGELOG.md | 1 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/gateway/cli-backends.md | 6 ++ docs/plugins/sdk-overview.md | 3 + docs/tools/thinking.md | 1 + extensions/anthropic/cli-backend.ts | 2 + extensions/anthropic/cli-shared.test.ts | 63 +++++++++++++++++++ extensions/anthropic/cli-shared.ts | 58 +++++++++++++++++ src/agents/cli-backends.test.ts | 26 ++++++++ src/agents/cli-backends.ts | 6 ++ src/agents/cli-runner.spawn.test.ts | 33 ++++++++++ src/agents/cli-runner/execute.ts | 20 ++++-- src/plugin-sdk/cli-backend.ts | 3 + src/plugins/cli-backend.types.ts | 33 ++++++++++ src/plugins/types.ts | 6 ++ 15 files changed, 259 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c34c89e193f..8a149cb9fb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. - fix(device-pair): require pairing scope for pair command [AI]. (#76377) Thanks @pgondhi987. - fix(qqbot): keep private commands off framework surface [AI]. (#77212) Thanks @pgondhi987. +- Claude CLI: honor non-off `/think` levels by passing Claude Code's session-scoped `--effort` flag through the CLI backend seam, so chat bridges no longer show an inert thinking control. Fixes #77303. Thanks @Petr1t. - Memory/wiki: preserve representation from both corpora in `corpus=all` searches while backfilling unused result capacity, so memory hits are not starved by numerically higher wiki integer scores. Fixes #77337. Thanks @hclsys. - Telegram: clean up tool-only draft previews after assistant message boundaries so transient `Surfacing...` tool-status bubbles do not linger when no matching final preview arrives. Thanks @BunsDev. - Cron: surface failed isolated-run diagnostics in `cron show`, status, and run history when requested tools are unavailable, so blocked cron runs report the actual tool-policy failure instead of a misleading green result. Fixes #75763. Thanks @RyanSandoval. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 95673ce3b0b..8d14cca3537 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -f8495c07213012748f099b12ddb02847ffd4eaa1b46f2ae9dfa574fa0ef3299a plugin-sdk-api-baseline.json -815ac868dda35d0af88b9c522233d6065c3eeb70775e19c111162b80390733fa plugin-sdk-api-baseline.jsonl +a7116e6c0cae4c7b9ee7cd6dc48f2978812f4b5be647f3e36eee91ec9a81d85e plugin-sdk-api-baseline.json +2b6c9883d701379761724e21946d417399c1247e6a244d6b00c4a982c8ef5968 plugin-sdk-api-baseline.jsonl diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 48eb1181a58..6cb5cb47b07 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -178,6 +178,12 @@ that agent. To force a different Claude mode, set explicit raw backend args such as `--permission-mode default` or `--permission-mode acceptEdits` under `agents.defaults.cliBackends.claude-cli.args` and matching `resumeArgs`. +The bundled Anthropic `claude-cli` backend also maps OpenClaw `/think` levels +to Claude Code's native `--effort` flag for non-off levels. `minimal` and +`low` map to `low`, `adaptive` and `medium` map to `medium`, and `high`, +`xhigh`, and `max` map directly. Other CLI backends need their owning plugin to +declare an equivalent argv mapper before `/think` can affect the spawned CLI. + Before OpenClaw can use the bundled `claude-cli` backend, Claude Code itself must already be logged in on the same host: diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index fa5474ac7fd..b3a253d1b3c 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -257,6 +257,9 @@ AI CLI backend such as `codex-cli`. plugin default before running the CLI. - Use `normalizeConfig` when a backend needs compatibility rewrites after merge (for example normalizing old flag shapes). +- Use `resolveExecutionArgs` for request-scoped argv rewrites that belong to + the CLI dialect, such as mapping OpenClaw thinking levels to a native effort + flag. ### Exclusive slots diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index 1b546bc2b09..659cd27ce43 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -54,6 +54,7 @@ title: "Thinking levels" ## Application by agent - **Embedded Pi**: the resolved level is passed to the in-process Pi agent runtime. +- **Claude CLI backend**: non-off levels are passed to Claude Code as `--effort` when using `claude-cli`; see [CLI backends](/gateway/cli-backends). ## Fast mode (/fast) diff --git a/extensions/anthropic/cli-backend.ts b/extensions/anthropic/cli-backend.ts index ae7bf5c39b5..6141ac99df2 100644 --- a/extensions/anthropic/cli-backend.ts +++ b/extensions/anthropic/cli-backend.ts @@ -10,6 +10,7 @@ import { CLAUDE_CLI_MODEL_ALIASES, CLAUDE_CLI_SESSION_ID_FIELDS, normalizeClaudeBackendConfig, + resolveClaudeCliExecutionArgs, } from "./cli-shared.js"; export function buildAnthropicCliBackend(): CliBackendPlugin { @@ -76,5 +77,6 @@ export function buildAnthropicCliBackend(): CliBackendPlugin { serialize: true, }, normalizeConfig: normalizeClaudeBackendConfig, + resolveExecutionArgs: resolveClaudeCliExecutionArgs, }; } diff --git a/extensions/anthropic/cli-shared.test.ts b/extensions/anthropic/cli-shared.test.ts index 4c4c577682d..27d5f2589bd 100644 --- a/extensions/anthropic/cli-shared.test.ts +++ b/extensions/anthropic/cli-shared.test.ts @@ -6,6 +6,7 @@ import { normalizeClaudePermissionArgs, normalizeClaudeSettingSourcesArgs, resolveClaudePermissionMode, + resolveClaudeCliExecutionArgs, } from "./cli-shared.js"; describe("normalizeClaudePermissionArgs", () => { @@ -75,6 +76,67 @@ describe("normalizeClaudeSettingSourcesArgs", () => { }); }); +describe("resolveClaudeCliExecutionArgs", () => { + it("omits effort args when thinking is off", () => { + expect( + resolveClaudeCliExecutionArgs({ + workspaceDir: "/tmp", + provider: "claude-cli", + modelId: "claude-sonnet-4-6", + thinkingLevel: "off", + useResume: false, + baseArgs: ["-p", "--output-format", "stream-json"], + }), + ).toEqual(["-p", "--output-format", "stream-json"]); + }); + + it("maps OpenClaw thinking levels to Claude effort args", () => { + expect( + resolveClaudeCliExecutionArgs({ + workspaceDir: "/tmp", + provider: "claude-cli", + modelId: "claude-opus-4-7", + thinkingLevel: "minimal", + useResume: false, + baseArgs: ["-p"], + }), + ).toEqual(["-p", "--effort", "low"]); + expect( + resolveClaudeCliExecutionArgs({ + workspaceDir: "/tmp", + provider: "claude-cli", + modelId: "claude-opus-4-7", + thinkingLevel: "adaptive", + useResume: false, + baseArgs: ["-p"], + }), + ).toEqual(["-p", "--effort", "medium"]); + expect( + resolveClaudeCliExecutionArgs({ + workspaceDir: "/tmp", + provider: "claude-cli", + modelId: "claude-opus-4-7", + thinkingLevel: "xhigh", + useResume: true, + baseArgs: ["-p", "--resume", "{sessionId}"], + }), + ).toEqual(["-p", "--resume", "{sessionId}", "--effort", "xhigh"]); + }); + + it("replaces static effort args when a session thinking level is active", () => { + expect( + resolveClaudeCliExecutionArgs({ + workspaceDir: "/tmp", + provider: "claude-cli", + modelId: "claude-opus-4-7", + thinkingLevel: "max", + useResume: false, + baseArgs: ["-p", "--effort", "low", "--effort=high"], + }), + ).toEqual(["-p", "--effort", "max"]); + }); +}); + describe("normalizeClaudeBackendConfig", () => { it("normalizes both args and resumeArgs for custom overrides", () => { const normalized = normalizeClaudeBackendConfig({ @@ -196,6 +258,7 @@ describe("normalizeClaudeBackendConfig", () => { expect(normalized?.resumeArgs).toContain("--permission-mode"); expect(normalized?.resumeArgs).toContain("bypassPermissions"); expect(normalized?.liveSession).toBe("claude-stdio"); + expect(backend.resolveExecutionArgs).toBe(resolveClaudeCliExecutionArgs); }); it("leaves claude cli subscription-managed, restricts setting sources, and clears inherited env overrides", () => { diff --git a/extensions/anthropic/cli-shared.ts b/extensions/anthropic/cli-shared.ts index f73c2ccb142..e981e207eb9 100644 --- a/extensions/anthropic/cli-shared.ts +++ b/extensions/anthropic/cli-shared.ts @@ -1,6 +1,7 @@ import type { CliBackendConfig, CliBackendNormalizeConfigContext, + CliBackendResolveExecutionArgsContext, } from "openclaw/plugin-sdk/cli-backend"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js"; @@ -60,9 +61,12 @@ export const CLAUDE_CLI_CLEAR_ENV = [ const CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG = "--dangerously-skip-permissions"; const CLAUDE_PERMISSION_MODE_ARG = "--permission-mode"; const CLAUDE_SETTING_SOURCES_ARG = "--setting-sources"; +const CLAUDE_EFFORT_ARG = "--effort"; const CLAUDE_SAFE_SETTING_SOURCES = "user"; const CLAUDE_BYPASS_PERMISSION_MODE = "bypassPermissions"; +type ClaudeCliEffort = "low" | "medium" | "high" | "xhigh" | "max"; + export function isClaudeCliProvider(providerId: string): boolean { return normalizeOptionalLowercaseString(providerId) === CLAUDE_CLI_BACKEND_ID; } @@ -168,6 +172,60 @@ export function normalizeClaudeSettingSourcesArgs(args?: string[]): string[] | u return normalized; } +export function mapClaudeCliThinkingLevelToEffort( + thinkingLevel?: string | null, +): ClaudeCliEffort | undefined { + switch (normalizeOptionalLowercaseString(thinkingLevel)) { + case "minimal": + case "low": + return "low"; + case "adaptive": + case "medium": + return "medium"; + case "high": + return "high"; + case "xhigh": + return "xhigh"; + case "max": + return "max"; + default: + return undefined; + } +} + +function stripClaudeEffortArgs(args: readonly string[]): string[] { + const normalized: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] ?? ""; + if (arg === CLAUDE_EFFORT_ARG) { + const maybeValue = args[i + 1]; + if ( + typeof maybeValue === "string" && + maybeValue.trim().length > 0 && + !maybeValue.startsWith("-") + ) { + i += 1; + } + continue; + } + if (arg.startsWith(`${CLAUDE_EFFORT_ARG}=`)) { + continue; + } + normalized.push(arg); + } + return normalized; +} + +export function resolveClaudeCliExecutionArgs( + context: CliBackendResolveExecutionArgsContext, +): string[] { + const effort = mapClaudeCliThinkingLevelToEffort(context.thinkingLevel); + if (!effort) { + return [...context.baseArgs]; + } + return [...stripClaudeEffortArgs(context.baseArgs), CLAUDE_EFFORT_ARG, effort]; +} + export function normalizeClaudeBackendConfig( config: CliBackendConfig, context?: CliBackendNormalizeConfigContext, diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index ae385fd130f..56914ee1cc9 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -4,6 +4,7 @@ import type { CliBackendConfig } from "../config/types.js"; import type { CliBackendAuthEpochMode, CliBackendNormalizeConfigContext, + CliBackendResolveExecutionArgs, CliBundleMcpMode, } from "../plugins/types.js"; import { @@ -31,6 +32,7 @@ function createBackendEntry(params: { defaultAuthProfileId?: string; authEpochMode?: CliBackendAuthEpochMode; prepareExecution?: () => Promise; + resolveExecutionArgs?: CliBackendResolveExecutionArgs; normalizeConfig?: ( config: CliBackendConfig, context?: CliBackendNormalizeConfigContext, @@ -47,6 +49,7 @@ function createBackendEntry(params: { ...(params.defaultAuthProfileId ? { defaultAuthProfileId: params.defaultAuthProfileId } : {}), ...(params.authEpochMode ? { authEpochMode: params.authEpochMode } : {}), ...(params.prepareExecution ? { prepareExecution: params.prepareExecution } : {}), + ...(params.resolveExecutionArgs ? { resolveExecutionArgs: params.resolveExecutionArgs } : {}), ...(params.normalizeConfig ? { normalizeConfig: params.normalizeConfig } : {}), liveTest: { defaultModelRef: @@ -968,6 +971,29 @@ describe("resolveCliBackendConfig google-gemini-cli defaults", () => { expect(resolved?.config.systemPromptWhen).toBe("first"); expect(resolved?.config.imagePathScope).toBe("workspace"); }); + + it("preserves backend-owned per-run arg resolvers", () => { + const resolveExecutionArgs: CliBackendResolveExecutionArgs = ({ baseArgs }) => [ + ...baseArgs, + "--effort", + "high", + ]; + runtimeBackendEntries = [ + createRuntimeBackendEntry({ + pluginId: "anthropic", + id: "claude-cli", + config: { + command: "claude", + args: ["-p"], + }, + resolveExecutionArgs, + }), + ]; + + const resolved = resolveCliBackendConfig("claude-cli"); + + expect(resolved?.resolveExecutionArgs).toBe(resolveExecutionArgs); + }); }); describe("resolveCliBackendConfig alias precedence", () => { diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index b86d259e833..ae9718893ee 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -38,6 +38,7 @@ export type ResolvedCliBackend = { defaultAuthProfileId?: string; authEpochMode?: CliBackendAuthEpochMode; prepareExecution?: CliBackendPlugin["prepareExecution"]; + resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"]; nativeToolMode?: CliBackendNativeToolMode; }; @@ -62,6 +63,7 @@ type FallbackCliBackendPolicy = { defaultAuthProfileId?: string; authEpochMode?: CliBackendAuthEpochMode; prepareExecution?: CliBackendPlugin["prepareExecution"]; + resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"]; nativeToolMode?: CliBackendNativeToolMode; }; @@ -99,6 +101,7 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic defaultAuthProfileId: entry.backend.defaultAuthProfileId, authEpochMode: entry.backend.authEpochMode, prepareExecution: entry.backend.prepareExecution, + resolveExecutionArgs: entry.backend.resolveExecutionArgs, nativeToolMode: entry.backend.nativeToolMode, }; } @@ -237,6 +240,7 @@ export function resolveCliBackendConfig( defaultAuthProfileId: registered.defaultAuthProfileId, authEpochMode: registered.authEpochMode, prepareExecution: registered.prepareExecution, + resolveExecutionArgs: registered.resolveExecutionArgs, nativeToolMode: registered.nativeToolMode, }; } @@ -266,6 +270,7 @@ export function resolveCliBackendConfig( defaultAuthProfileId: fallbackPolicy.defaultAuthProfileId, authEpochMode: fallbackPolicy.authEpochMode, prepareExecution: fallbackPolicy.prepareExecution, + resolveExecutionArgs: fallbackPolicy.resolveExecutionArgs, nativeToolMode: fallbackPolicy.nativeToolMode, }; } @@ -292,6 +297,7 @@ export function resolveCliBackendConfig( defaultAuthProfileId: fallbackPolicy?.defaultAuthProfileId, authEpochMode: fallbackPolicy?.authEpochMode, prepareExecution: fallbackPolicy?.prepareExecution, + resolveExecutionArgs: fallbackPolicy?.resolveExecutionArgs, nativeToolMode: fallbackPolicy?.nativeToolMode, }; } diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index b994402f5c0..9099f994e2e 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -59,9 +59,11 @@ function buildPreparedCliRunContext(params: { sessionId?: string; sessionKey?: string; backend?: Partial; + resolveExecutionArgs?: PreparedCliRunContext["backendResolved"]["resolveExecutionArgs"]; config?: PreparedCliRunContext["params"]["config"]; mcpConfigHash?: string; skillsSnapshot?: PreparedCliRunContext["params"]["skillsSnapshot"]; + thinkLevel?: PreparedCliRunContext["params"]["thinkLevel"]; workspaceDir?: string; }): PreparedCliRunContext { const workspaceDir = params.workspaceDir ?? "/tmp"; @@ -103,6 +105,7 @@ function buildPreparedCliRunContext(params: { prompt: params.prompt ?? "hi", provider: params.provider, model: params.model, + thinkLevel: params.thinkLevel, timeoutMs: 1_000, runId: params.runId, skillsSnapshot: params.skillsSnapshot, @@ -114,6 +117,7 @@ function buildPreparedCliRunContext(params: { config: backend, bundleMcp: params.provider === "claude-cli", pluginId: params.provider === "claude-cli" ? "anthropic" : "openai", + resolveExecutionArgs: params.resolveExecutionArgs, }, preparedBackend: { backend, @@ -329,6 +333,35 @@ describe("runCliAgent spawn path", () => { expect(input.argv).not.toContain("hi"); }); + it("applies backend-owned per-run args before spawning", async () => { + mockSuccessfulCliRun(); + const resolveExecutionArgs = vi.fn(({ baseArgs }) => [...baseArgs, "--effort", "high"]); + + await executePreparedCliRun( + buildPreparedCliRunContext({ + provider: "claude-cli", + model: "sonnet", + runId: "run-claude-thinking-args", + thinkLevel: "high", + resolveExecutionArgs, + }), + ); + + expect(resolveExecutionArgs).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "claude-cli", + modelId: "sonnet", + thinkingLevel: "high", + useResume: false, + baseArgs: ["-p", "--output-format", "stream-json"], + }), + ); + const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[] }; + const effortArgIndex = input.argv?.indexOf("--effort") ?? -1; + expect(effortArgIndex).toBeGreaterThanOrEqual(0); + expect(input.argv?.[effortArgIndex + 1]).toBe("high"); + }); + it("passes OpenClaw skills to Claude as a session plugin", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-skills-")); const skillDir = path.join(workspaceDir, "skills", "weather"); diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index e3a265a6dad..b043fb616a1 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -279,12 +279,24 @@ export async function executePreparedCliRun( skillsSnapshot: params.skillsSnapshot, }); let claudeSkillsPluginCleanupOwned = false; + const baseArgsWithSkills = + claudeSkillsPlugin.args.length > 0 + ? [...resolvedArgs, ...claudeSkillsPlugin.args] + : resolvedArgs; + const executionBaseArgs = + context.backendResolved.resolveExecutionArgs?.({ + config: params.config, + workspaceDir: context.workspaceDir, + provider: params.provider, + modelId: context.modelId, + authProfileId: context.effectiveAuthProfileId, + thinkingLevel: params.thinkLevel, + useResume, + baseArgs: baseArgsWithSkills, + }) ?? baseArgsWithSkills; const args = buildCliArgs({ backend, - baseArgs: - claudeSkillsPlugin.args.length > 0 - ? [...resolvedArgs, ...claudeSkillsPlugin.args] - : resolvedArgs, + baseArgs: Array.from(executionBaseArgs), modelId: context.normalizedModel, sessionId: resolvedSessionId, systemPrompt: systemPromptArg, diff --git a/src/plugin-sdk/cli-backend.ts b/src/plugin-sdk/cli-backend.ts index 738165d4e29..82191e48381 100644 --- a/src/plugin-sdk/cli-backend.ts +++ b/src/plugin-sdk/cli-backend.ts @@ -6,6 +6,9 @@ export type { CliBackendPlugin, CliBackendPreparedExecution, CliBackendPrepareExecutionContext, + CliBackendResolveExecutionArgs, + CliBackendResolveExecutionArgsContext, + CliBackendThinkingLevel, } from "../plugins/types.js"; export { CLI_FRESH_WATCHDOG_DEFAULTS, diff --git a/src/plugins/cli-backend.types.ts b/src/plugins/cli-backend.types.ts index 97492e4a834..01771a80292 100644 --- a/src/plugins/cli-backend.types.ts +++ b/src/plugins/cli-backend.types.ts @@ -33,6 +33,31 @@ export type CliBackendPreparedExecution = { cleanup?: () => Promise; }; +export type CliBackendThinkingLevel = + | "off" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "adaptive" + | "max"; + +export type CliBackendResolveExecutionArgsContext = { + config?: OpenClawConfig; + workspaceDir: string; + provider: string; + modelId: string; + authProfileId?: string; + thinkingLevel?: CliBackendThinkingLevel; + useResume: boolean; + baseArgs: readonly string[]; +}; + +export type CliBackendResolveExecutionArgs = ( + ctx: CliBackendResolveExecutionArgsContext, +) => readonly string[] | null | undefined; + export type CliBackendAuthEpochMode = "combined" | "profile-only"; export type CliBackendNativeToolMode = "none" | "always-on"; @@ -141,6 +166,14 @@ export type CliBackendPlugin = { | CliBackendPreparedExecution | null | undefined; + /** + * Backend-owned per-run argv rewrite. + * + * Use this for request-scoped CLI dialect flags that should not be modeled + * as static config, such as mapping OpenClaw thinking levels to a backend's + * native effort flag. + */ + resolveExecutionArgs?: CliBackendResolveExecutionArgs; /** * Whether this CLI backend can expose native tools outside OpenClaw's tool * catalog. Backends that cannot provide a true no-tools mode must mark diff --git a/src/plugins/types.ts b/src/plugins/types.ts index b7df4b8b781..3718330c388 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -84,6 +84,9 @@ import type { CliBackendNormalizeConfigContext, CliBackendPreparedExecution, CliBackendPrepareExecutionContext, + CliBackendResolveExecutionArgs, + CliBackendResolveExecutionArgsContext, + CliBackendThinkingLevel, CliBackendPlugin, CliBundleMcpMode, PluginTextReplacement, @@ -194,6 +197,9 @@ export type { CliBackendNativeToolMode, CliBackendPreparedExecution, CliBackendPrepareExecutionContext, + CliBackendResolveExecutionArgs, + CliBackendResolveExecutionArgsContext, + CliBackendThinkingLevel, CliBackendPlugin, CliBundleMcpMode, PluginTextReplacement, From 2511be52446608ea1d27d7241f22c17ef96c4c86 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 11:09:13 -0700 Subject: [PATCH 006/107] test(release): skip restart in package upgrade lane --- scripts/openclaw-cross-os-release-checks.ts | 23 +++++++++++-------- .../openclaw-cross-os-release-checks.test.ts | 14 +++++++++++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index 4b6fe265fee..6e0fef6477e 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -765,15 +765,7 @@ async function runUpgradeLane(params) { logLanePhase(lane, "update"); const updateEnv = buildRealUpdateEnv(env); - const updateArgs = [ - "update", - "--tag", - params.candidateUrl, - "--yes", - "--json", - "--timeout", - String(updateStepTimeoutSeconds()), - ]; + const updateArgs = buildPackagedUpgradeUpdateArgs(params.candidateUrl); const updateResult = await runOpenClaw({ lane, env: updateEnv, @@ -1345,6 +1337,19 @@ export function verifyPackagedUpgradeUpdateResult(result, _options) { ); } +export function buildPackagedUpgradeUpdateArgs(candidateUrl) { + return [ + "update", + "--tag", + candidateUrl, + "--yes", + "--json", + "--no-restart", + "--timeout", + String(updateStepTimeoutSeconds()), + ]; +} + export function isRecoverableWindowsPackagedUpgradeSwapCleanupFailure( result, platform = process.platform, diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index ba536f51e0d..4ff4c4e6966 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -16,6 +16,7 @@ import { LOCAL_BUILD_METADATA_DIST_PATHS } from "../../scripts/lib/local-build-m import { agentOutputHasExpectedOkMarker, buildCrossOsReleaseSmokePluginAllowlist, + buildPackagedUpgradeUpdateArgs, buildReleaseOnboardArgs, buildWindowsDevUpdateToolchainCheckScript, buildWindowsFreshShellVersionCheckScript, @@ -232,6 +233,19 @@ describe("scripts/openclaw-cross-os-release-checks", () => { ]); }); + it("keeps packaged-upgrade release updates out of service restart flow", () => { + const args = buildPackagedUpgradeUpdateArgs("http://127.0.0.1:49152/openclaw-current.tgz"); + expect(args.slice(0, 6)).toEqual([ + "update", + "--tag", + "http://127.0.0.1:49152/openclaw-current.tgz", + "--yes", + "--json", + "--no-restart", + ]); + expect(args.at(-2)).toBe("--timeout"); + }); + it("keeps cross-OS live smoke agent turns on GPT-5-safe timeouts and minimal context", () => { const source = readFileSync("scripts/openclaw-cross-os-release-checks.ts", "utf8"); const providerOverride = "models.providers.${params.providerConfig.extensionId}"; From fc7e2a10c8fcadf8f38d3645cb05848accfa4b5d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 11:18:13 -0700 Subject: [PATCH 007/107] fix(plugins): reserve pending delivery session slots --- src/plugins/session-entry-slot-keys.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 8415d82249c..2873878c3c9 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -66,6 +66,13 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "inputTokens", "outputTokens", "totalTokens", + "pendingFinalDelivery", + "pendingFinalDeliveryCreatedAt", + "pendingFinalDeliveryLastAttemptAt", + "pendingFinalDeliveryAttemptCount", + "pendingFinalDeliveryLastError", + "pendingFinalDeliveryText", + "pendingFinalDeliveryContext", "totalTokensFresh", "estimatedCostUsd", "cacheRead", From 841eb81baf379e6d38e417cfe41734915df67aaf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 11:00:22 -0700 Subject: [PATCH 008/107] chore: better explicit message on whatsapp --- extensions/whatsapp/src/login.coverage.test.ts | 4 +++- extensions/whatsapp/src/login.ts | 2 +- extensions/whatsapp/src/session.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/extensions/whatsapp/src/login.coverage.test.ts b/extensions/whatsapp/src/login.coverage.test.ts index 593bd62e02f..f7902601fc6 100644 --- a/extensions/whatsapp/src/login.coverage.test.ts +++ b/extensions/whatsapp/src/login.coverage.test.ts @@ -142,7 +142,9 @@ describe("loginWeb coverage", () => { restartOpts?.onQr?.("restart-qr"); await flushTasks(); - expect(runtime.log).toHaveBeenCalledWith("Scan this QR in WhatsApp (Linked Devices):"); + expect(runtime.log).toHaveBeenCalledWith( + "Open the WhatsApp app, go to Linked Devices, then scan this QR:", + ); expect(runtime.log).toHaveBeenCalledWith("terminal:initial-qr"); expect(runtime.log).toHaveBeenCalledWith("terminal:restart-qr"); expect(renderQrTerminalMock).toHaveBeenCalledWith("initial-qr", { small: true }); diff --git a/extensions/whatsapp/src/login.ts b/extensions/whatsapp/src/login.ts index 014ec77d263..0019b10e746 100644 --- a/extensions/whatsapp/src/login.ts +++ b/extensions/whatsapp/src/login.ts @@ -21,7 +21,7 @@ export async function loginWeb( const socketTiming = resolveWhatsAppSocketTiming(cfg); const restoredFromBackup = await restoreCredsFromBackupIfNeeded(account.authDir); const onQr = (qr: string) => { - runtime.log("Scan this QR in WhatsApp (Linked Devices):"); + runtime.log("Open the WhatsApp app, go to Linked Devices, then scan this QR:"); void renderQrTerminal(qr, { small: true }) .then((output) => { runtime.log(output.endsWith("\n") ? output.slice(0, -1) : output); diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index e7a569ba0c3..6eb31a6a994 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -192,7 +192,7 @@ export async function createWaSocket( if (qr) { opts.onQr?.(qr); if (printQr) { - console.log("Scan this QR in WhatsApp (Linked Devices):"); + console.log("Open the WhatsApp app, go to Linked Devices, then scan this QR:"); void printTerminalQr(qr).catch((err) => { sessionLogger.warn({ error: String(err) }, "failed rendering WhatsApp QR"); }); From 9dc38f37eab4273ca807eb5b28fa8f74473fe000 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 11:26:52 -0700 Subject: [PATCH 009/107] chore: ignore crabbox artifacts --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1d1a75a9a1b..ac9d57de4ec 100644 --- a/.gitignore +++ b/.gitignore @@ -219,3 +219,4 @@ extensions/**/.openclaw-runtime-deps-stamp.json # Output dir for scripts/run-opengrep.sh (local opengrep scans) /.opengrep-out/ +/.crabbox-artifacts From daefb5e3412ff4583e39970add35f65acbe8e038 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 11:30:26 -0700 Subject: [PATCH 010/107] fix(plugins): trust catalog package installs --- src/channels/plugins/catalog.test.ts | 2 +- src/cli/plugins-cli.install.test.ts | 36 ++++++++++++++++++++++++++-- src/cli/plugins-install-command.ts | 8 +++---- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/channels/plugins/catalog.test.ts b/src/channels/plugins/catalog.test.ts index d9c11823b07..d26f05d92d9 100644 --- a/src/channels/plugins/catalog.test.ts +++ b/src/channels/plugins/catalog.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { getChannelPluginCatalogEntry } from "./catalog.js"; describe("channel plugin catalog", () => { - it("keeps third-party official channel ids mapped to their published plugin ids", () => { + it("keeps third-party channel ids mapped with catalog install trust", () => { const options = { workspaceDir: "/tmp/openclaw-channel-catalog-empty-workspace", env: {}, diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 68da54a32cb..c3e21744aa8 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -734,7 +734,7 @@ describe("plugins cli install", () => { expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); }); - it("passes official external catalog integrity to npm installs", async () => { + it("passes third-party external catalog integrity with catalog install trust", async () => { const cfg = createEmptyPluginConfig(); const enabledCfg = createEnabledPluginConfig("wecom-openclaw-plugin"); loadConfig.mockReturnValue(cfg); @@ -796,7 +796,7 @@ describe("plugins cli install", () => { }, ); - it("passes official external catalog integrity to hook-pack fallback", async () => { + it("passes third-party external catalog integrity to hook-pack fallback", async () => { loadConfig.mockReturnValue(createEmptyPluginConfig()); findBundledPluginSourceMock.mockReturnValue(undefined); installPluginFromNpmSpec.mockResolvedValue({ @@ -992,6 +992,38 @@ describe("plugins cli install", () => { expect(installPluginFromClawHub).not.toHaveBeenCalled(); }); + it("marks catalog npm package installs with alternate selectors as trusted", async () => { + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("wecom-openclaw-plugin"); + + loadConfig.mockReturnValue(cfg); + installPluginFromNpmSpec.mockResolvedValue( + createNpmPluginInstallResult("wecom-openclaw-plugin"), + ); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "@wecom/wecom-openclaw-plugin@latest"]); + + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@wecom/wecom-openclaw-plugin@latest", + expectedPluginId: "wecom-openclaw-plugin", + trustedSourceLinkedOfficialInstall: true, + }), + ); + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.not.objectContaining({ + expectedIntegrity: expect.any(String), + }), + ); + expect(installPluginFromClawHub).not.toHaveBeenCalled(); + }); + it("passes the active profile extensions dir to npm installs", async () => { const extensionsDir = useProfileExtensionsDir(); const cfg = createEmptyPluginConfig(); diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index ee7593898cd..796c705139e 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -64,7 +64,7 @@ function resolveInstallSafetyOverrides(overrides: InstallSafetyOverrides): Insta }; } -function findTrustedOfficialExternalPackageInstall(packageName: string): +function findTrustedCatalogPackageInstall(packageName: string): | { pluginId: string; npmSpec?: string; @@ -72,7 +72,7 @@ function findTrustedOfficialExternalPackageInstall(packageName: string): } | undefined { const entry = getOfficialExternalPluginCatalogEntryForPackage(packageName); - if (entry?.source !== "official") { + if (!entry) { return undefined; } const pluginId = resolveOfficialExternalPluginId(entry); @@ -723,7 +723,7 @@ export async function runPluginInstallCommand(params: { } const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({ npmSpec: npmPrefixSpec, - findOfficialExternalPackage: findTrustedOfficialExternalPackageInstall, + findOfficialExternalPackage: findTrustedCatalogPackageInstall, }); const npmPrefixResult = await tryInstallPluginOrHookPackFromNpmSpec({ snapshot, @@ -870,7 +870,7 @@ export async function runPluginInstallCommand(params: { const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({ npmSpec: raw, - findOfficialExternalPackage: findTrustedOfficialExternalPackageInstall, + findOfficialExternalPackage: findTrustedCatalogPackageInstall, }); const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({ snapshot, From 417660b662ccd1be1345918cca44759c7802a6ab Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 11:31:29 -0700 Subject: [PATCH 011/107] docs(plugins): explain catalog install trust --- src/cli/plugins-cli.install.test.ts | 2 ++ src/cli/plugins-install-command.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index c3e21744aa8..6a0d53451ce 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -1009,6 +1009,8 @@ describe("plugins cli install", () => { await runPluginsCommand(["plugins", "install", "@wecom/wecom-openclaw-plugin@latest"]); + // Alternate selectors stay trusted by catalog package name, but must not + // inherit catalog integrity unless the install spec matches exactly. expect(installPluginFromNpmSpec).toHaveBeenCalledWith( expect.objectContaining({ spec: "@wecom/wecom-openclaw-plugin@latest", diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 796c705139e..89e33e2a741 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -71,6 +71,9 @@ function findTrustedCatalogPackageInstall(packageName: string): expectedIntegrity?: string; } | undefined { + // The catalog is the trust list. Raw npm selectors such as + // @scope/pkg@latest inherit install-scan trust when their package name is + // cataloged; integrity remains tied to exact catalog specs in the planner. const entry = getOfficialExternalPluginCatalogEntryForPackage(packageName); if (!entry) { return undefined; From dff437a1cbe7b5753799fc4466fe2a175a2b9460 Mon Sep 17 00:00:00 2001 From: hcl Date: Tue, 5 May 2026 02:37:05 +0800 Subject: [PATCH 012/107] fix(active-memory): skip colon-containing session-store channels to prevent crash with QQ c2c agent IDs (#77402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: - The PR filters colon-containing store-derived Active Memory channel values before embedded recall resolution, adds a QQ c2c regression test, and records the user-facing changelog entry. - Reproducibility: yes. Source inspection on current main shows a stored colon-containing `lastChannel` or `ch ... come the strong embedded recall channel, and the downstream bundled-plugin directory validator rejects `:`. Automerge notes: - PR branch already contained follow-up commit before automerge: fixup! fix(active-memory): add changelog contributor credit (clawswee… - PR branch already contained follow-up commit before automerge: fix(active-memory): skip colon-containing session-store channels Validation: - ClawSweeper review passed for head 4bf00dd6acfc95d861779ec1fdd1ae36cab93797. - Required merge gates passed before the squash merge. Prepared head SHA: 4bf00dd6acfc95d861779ec1fdd1ae36cab93797 Review: https://github.com/openclaw/openclaw/pull/77402#issuecomment-4372618783 Co-authored-by: HCL Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/active-memory/index.test.ts | 27 ++++++++++++++++++++++++++ extensions/active-memory/index.ts | 10 +++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a149cb9fb5..7a363cf4da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Plugins/active-memory: skip session-store channel entries that contain `:` when resolving the recall subagent's channel, so QQ c2c agent IDs (e.g. `c2c:10D4F7C2…`) and other scoped conversation IDs do not reach bundled-plugin `dirName` validation and crash the recall run. The same guard already applied to explicit `channelId` params (#76704); this extends it to store-derived channels. (#77396) Thanks @hclsys. - Models/auth: add `openclaw models auth list [--provider ] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments” path. Thanks @vincentkoc. - Control UI/header: show the active agent name in dashboard breadcrumbs without adding the current session key, keeping non-chat views oriented without crowding the topbar. - Control UI/cron: make the New Job sidebar collapsible so the jobs list can reclaim space while keeping the form one click away. Thanks @BunsDev. diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 9d695573c09..695c15bde84 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -2753,6 +2753,33 @@ describe("active-memory plugin", () => { }); }); + it("skips colon-containing session-store channels for embedded recall (#77396)", async () => { + hoisted.sessionStore["agent:main:qqbot:direct:12345"] = { + sessionId: "session-a", + updatedAt: 25, + channel: "c2c:10D4F7C2", + origin: { + provider: "qqbot", + }, + }; + + await hooks.before_prompt_build( + { prompt: "what wings should i order? scoped stored channel", messages: [] }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:qqbot:direct:12345", + messageProvider: "qqbot", + channelId: "qqbot", + }, + ); + + expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({ + messageChannel: "qqbot", + messageProvider: "qqbot", + }); + }); + it("preserves an explicit real channel hint over a stale stored wrapper channel", async () => { hoisted.sessionStore["agent:main:telegram:direct:12345"] = { sessionId: "session-a", diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index a7d4e195953..314c3df6490 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -560,9 +560,17 @@ function resolveRecallRunChannelContext(params: { store, sessionKey: resolvedSessionKey, }).existing; - const strongEntryChannel = + const rawStrongEntryChannel = normalizeOptionalString(sessionEntry?.lastChannel) ?? normalizeOptionalString(sessionEntry?.channel); + // Channel IDs containing ":" are scoped conversation IDs (e.g. QQ c2c + // "c2c:10D4F7C2..."), not runnable channel names. The same guard that + // applies to explicit channelId (#76704) must also apply to channels + // read from the session store (#77396). + const strongEntryChannel = + rawStrongEntryChannel && !rawStrongEntryChannel.includes(":") + ? rawStrongEntryChannel + : undefined; const weakEntryChannel = normalizeOptionalString(sessionEntry?.origin?.provider); return resolveReturnValue({ resolvedChannel: strongEntryChannel ?? weakEntryChannel, From edddb07f20554740d239d1cf73b8a8e623bfe271 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Mon, 4 May 2026 12:38:51 -0600 Subject: [PATCH 013/107] fix(qqbot): preserve framework command authorization (#77453) * fix(qqbot): preserve framework command authorization * Add changelog entry for PR #77453 --- CHANGELOG.md | 1 + .../framework-context-adapter.test.ts | 55 +++++++++++++++++++ .../commands/framework-context-adapter.ts | 2 +- 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 extensions/qqbot/src/bridge/commands/framework-context-adapter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a363cf4da0..d10ebc829ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -207,6 +207,7 @@ Docs: https://docs.openclaw.ai - TUI: replace the stale-response watchdog notice with plain user-facing copy so stalled replies no longer surface backend or streaming internals. (#77120) Thanks @davemorin. - Security/Windows: validate `SystemRoot`/`WINDIR` env values through the Windows install-root validator and add them to the dangerous-host-env policy when resolving `icacls.exe`/`whoami.exe` for `openclaw security audit`, so workspace `.env` overrides and bare command names cannot redirect Windows ACL helpers to attacker-controlled binaries. (#74458) Thanks @mmaps. - Security/Windows: pin Windows registry-probe `reg.exe` resolution to the canonical Windows install root in install-root probing, so `SystemRoot`/`WINDIR` env overrides cannot redirect registry queries during Windows host detection. (#74454) Thanks @mmaps. +- QQBot: preserve the framework command authorization decision when converting framework command contexts into engine slash command contexts, so downstream slash handlers see `commandAuthorized` matching the channel's resolved `isAuthorizedSender` instead of a hardcoded `true`. (#77453) Thanks @drobison00. ## 2026.5.3-1 diff --git a/extensions/qqbot/src/bridge/commands/framework-context-adapter.test.ts b/extensions/qqbot/src/bridge/commands/framework-context-adapter.test.ts new file mode 100644 index 00000000000..f362d4c9fd8 --- /dev/null +++ b/extensions/qqbot/src/bridge/commands/framework-context-adapter.test.ts @@ -0,0 +1,55 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; +import { describe, expect, it } from "vitest"; +import { buildFrameworkSlashContext } from "./framework-context-adapter.js"; + +function createCommandContext(isAuthorizedSender: boolean): PluginCommandContext { + return { + senderId: "SENDER_OPENID", + channel: "qqbot", + isAuthorizedSender, + args: "on", + commandBody: "/bot-streaming on", + config: {} as OpenClawConfig, + from: "qqbot:c2c:SENDER_OPENID", + requestConversationBinding: async () => undefined, + detachConversationBinding: async () => ({ removed: false }), + getCurrentConversationBinding: async () => null, + } as unknown as PluginCommandContext; +} + +describe("buildFrameworkSlashContext", () => { + it("preserves the framework authorization decision in the slash context", () => { + const authorized = buildFrameworkSlashContext({ + ctx: createCommandContext(true), + account: { + accountId: "default", + enabled: true, + appId: "app", + clientSecret: "secret", + secretSource: "config", + markdownSupport: true, + config: {}, + }, + from: { msgType: "c2c", targetType: "c2c", targetId: "SENDER_OPENID" }, + commandName: "bot-streaming", + }); + const unauthorized = buildFrameworkSlashContext({ + ctx: createCommandContext(false), + account: { + accountId: "default", + enabled: true, + appId: "app", + clientSecret: "secret", + secretSource: "config", + markdownSupport: true, + config: {}, + }, + from: { msgType: "c2c", targetType: "c2c", targetId: "SENDER_OPENID" }, + commandName: "bot-streaming", + }); + + expect(authorized.commandAuthorized).toBe(true); + expect(unauthorized.commandAuthorized).toBe(false); + }); +}); diff --git a/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts b/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts index 437c72cc3f4..fb6e7ccad0e 100644 --- a/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts +++ b/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts @@ -54,7 +54,7 @@ export function buildFrameworkSlashContext({ accountId: account.accountId, appId: account.appId, accountConfig: account.config as unknown as Record, - commandAuthorized: true, + commandAuthorized: ctx.isAuthorizedSender, queueSnapshot: { ...DEFAULT_QUEUE_SNAPSHOT }, }; } From 8c7ec5d1f9ff4e90640af4e72eb47390744412b4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 11:48:46 -0700 Subject: [PATCH 014/107] docs(changelog): credit @NikolaFC and @MertBasar0 for gateway and main-session fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #76923 (Satoshi F. / @NikolaFC) added user-facing `gateway.restart.safe` preflight alignment and #75280 (Mert Başar / @MertBasar0) added user-facing main-session pending-delivery marker preservation, but both entries landed without contributor attribution. Add the merging PR refs and credit the human contributors per CLAUDE.md changelog-attribution rules. --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d10ebc829ea..4299059ed75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -460,7 +460,7 @@ Docs: https://docs.openclaw.ai - Status/update: resolve beta update-channel checks from the installed version when config still says `stable`, and let `status --deep` reuse live gateway channel credential state instead of warning on command-path-only token misses. - Doctor/plugins: preserve unmanaged third-party plugin `node_modules` during `doctor --fix`, while still pruning OpenClaw-managed runtime dependency caches. - Gateway/restart: add `openclaw gateway restart --force` and `--wait `, log active task run IDs before restart deferral timers, and report timeout restarts as explicit forced restarts. -- Gateway/restart: align `gateway.restart.safe` preflight with scheduled restart deferral by counting only active restart blockers (running non-ended tasks), so queued task records no longer keep "safe" restarts deferred indefinitely. +- Gateway/restart: align `gateway.restart.safe` preflight with scheduled restart deferral by counting only active restart blockers (running non-ended tasks), so queued task records no longer keep "safe" restarts deferred indefinitely. (#76923) Thanks @NikolaFC. - Discord: persist slash-command deploy hashes across process restarts so unchanged command sets skip redeploy and avoid restart-loop 429s. - Providers/LM Studio: normalize binary `off`/`on` reasoning metadata from Gemma 4 and other local models to LM Studio's accepted OpenAI-compatible `reasoning_effort` values. - Plugins/externalization: keep official external install docs, update examples, and live Codex npm checks on default npm tags instead of `@beta`. Thanks @vincentkoc. @@ -468,7 +468,7 @@ Docs: https://docs.openclaw.ai - Plugins/ClawHub: fall back to version metadata when the artifact resolver route is missing and keep the Docker ClawHub fixture aligned with npm-pack artifact resolution, avoiding false version-not-found failures during plugin install validation. Thanks @vincentkoc. - Providers/openai-codex: honor `providerConfig.baseUrl` in the dynamic-model synthesis fallback so codex providers configured with a custom upstream (for example a forwarding proxy) no longer silently bypass the configured URL when the registry has no template row to clone for the requested model id. (#76428) Thanks @arniesaha. - Status/channels: show configured channels in `openclaw status` and config-only `openclaw channels status` output even when the Gateway is unreachable, avoiding empty Channels tables on WSL and other no-Gateway paths. Thanks @vincentkoc. -- Agents/main-session: keep pending final delivery markers until the final reply is actually routed or queued, so restart and heartbeat recovery can retry failed delivery. Refs #65037. +- Agents/main-session: keep pending final delivery markers until the final reply is actually routed or queued, so restart and heartbeat recovery can retry failed delivery. Refs #65037. (#75280) Thanks @MertBasar0. - Plugins/ClawHub: explain unavailable explicit ClawHub ClawPack artifact downloads with a temporary npm install hint while ClawHub artifact routing rolls out. Thanks @vincentkoc. - Media: accept home-relative `MEDIA:~/...` attachment paths while preserving existing file-read policy, traversal checks, and media type validation. Fixes #73796. Thanks @fabkury. - Onboarding/search: install official external web-search plugins such as Brave before saving provider config, and make doctor repair reconcile selected external search providers whose npm payload is missing. Thanks @vincentkoc. From 9aad2b82c30be700ed721f93a6ae7ba4f0cdbb09 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Mon, 4 May 2026 12:51:26 -0600 Subject: [PATCH 015/107] Use trusted Windows browser helper root (#77469) --- src/infra/browser-open.test.ts | 47 ++++++++++++++++++++++++++++++++++ src/infra/browser-open.ts | 3 ++- 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/infra/browser-open.test.ts diff --git a/src/infra/browser-open.test.ts b/src/infra/browser-open.test.ts new file mode 100644 index 00000000000..1547af15fbe --- /dev/null +++ b/src/infra/browser-open.test.ts @@ -0,0 +1,47 @@ +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveBrowserOpenCommand } from "./browser-open.js"; +import { _resetWindowsInstallRootsForTests } from "./windows-install-roots.js"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + _resetWindowsInstallRootsForTests(); +}); + +describe("resolveBrowserOpenCommand", () => { + it("does not resolve Windows browser launching through a relative SystemRoot", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + vi.stubEnv("SystemRoot", ".\\fake-root"); + vi.stubEnv("windir", ".\\fake-windir"); + _resetWindowsInstallRootsForTests({ queryRegistryValue: () => null }); + + const resolved = await resolveBrowserOpenCommand(); + + const rundll32 = path.win32.join("C:\\Windows", "System32", "rundll32.exe"); + expect(resolved.argv).toEqual([rundll32, "url.dll,FileProtocolHandler"]); + expect(resolved.command).toBe(rundll32); + }); + + it("prefers the registry-backed Windows system root over process env", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + vi.stubEnv("SystemRoot", "C:\\PoisonedWindows"); + _resetWindowsInstallRootsForTests({ + queryRegistryValue: (key, valueName) => { + if ( + key === "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" && + valueName === "SystemRoot" + ) { + return "D:\\Windows"; + } + return null; + }, + }); + + const resolved = await resolveBrowserOpenCommand(); + + const rundll32 = path.win32.join("D:\\Windows", "System32", "rundll32.exe"); + expect(resolved.argv).toEqual([rundll32, "url.dll,FileProtocolHandler"]); + expect(resolved.command).toBe(rundll32); + }); +}); diff --git a/src/infra/browser-open.ts b/src/infra/browser-open.ts index d48261aa7ad..13d29ab8671 100644 --- a/src/infra/browser-open.ts +++ b/src/infra/browser-open.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { runCommandWithTimeout } from "../process/exec.js"; import { detectBinary } from "./detect-binary.js"; +import { getWindowsInstallRoots } from "./windows-install-roots.js"; import { isWSL } from "./wsl.js"; type BrowserOpenCommand = { @@ -23,7 +24,7 @@ function shouldSkipBrowserOpenInTests(): boolean { } function resolveWindowsRundll32Path(): string { - const systemRoot = process.env.SystemRoot?.trim() || process.env.windir?.trim() || "C:\\Windows"; + const { systemRoot } = getWindowsInstallRoots(); return path.win32.join(systemRoot, "System32", "rundll32.exe"); } From de4903ec7a830b34ef2f5c10cf6f29bdb7005598 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 11:50:37 -0700 Subject: [PATCH 016/107] fix(agents): refresh deferred subagent delivery text --- CHANGELOG.md | 1 + src/agents/subagent-registry-lifecycle.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4299059ed75..7b8d1a2e76f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai - fix(device-pair): require pairing scope for pair command [AI]. (#76377) Thanks @pgondhi987. - fix(qqbot): keep private commands off framework surface [AI]. (#77212) Thanks @pgondhi987. - Claude CLI: honor non-off `/think` levels by passing Claude Code's session-scoped `--effort` flag through the CLI backend seam, so chat bridges no longer show an inert thinking control. Fixes #77303. Thanks @Petr1t. +- Agents/subagents: refresh deferred final-delivery payloads when same-session completion output changes, so retried parent notifications use the final child summary instead of stale progress text. Thanks @vincentkoc. - Memory/wiki: preserve representation from both corpora in `corpus=all` searches while backfilling unused result capacity, so memory hits are not starved by numerically higher wiki integer scores. Fixes #77337. Thanks @hclsys. - Telegram: clean up tool-only draft previews after assistant message boundaries so transient `Surfacing...` tool-status bubbles do not linger when no matching final preview arrives. Thanks @BunsDev. - Cron: surface failed isolated-run diagnostics in `cron show`, status, and run history when requested tools are unavailable, so blocked cron runs report the actual tool-policy failure instead of a misleading green result. Fixes #75763. Thanks @RyanSandoval. diff --git a/src/agents/subagent-registry-lifecycle.ts b/src/agents/subagent-registry-lifecycle.ts index e629366fa83..d4f9f7ef2b8 100644 --- a/src/agents/subagent-registry-lifecycle.ts +++ b/src/agents/subagent-registry-lifecycle.ts @@ -288,6 +288,12 @@ export function createSubagentRegistryLifecycleController(params: { } entry.frozenResultText = nextFrozen; entry.frozenResultCapturedAt = capturedAt; + if (entry.pendingFinalDeliveryPayload) { + entry.pendingFinalDeliveryPayload = { + ...entry.pendingFinalDeliveryPayload, + frozenResultText: nextFrozen, + }; + } changed = true; } if (changed) { From ee314e423650ea3e4e98d650332058b4a26ea77e Mon Sep 17 00:00:00 2001 From: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Date: Mon, 4 May 2026 12:00:05 -0700 Subject: [PATCH 017/107] fix(doctor): restore group config drift migrations (#77465) --- CHANGELOG.md | 1 + docs/gateway/doctor.md | 1 + .../shared/legacy-config-migrate.test.ts | 70 +++++- .../legacy-config-migrate.validation.test.ts | 35 +++ .../legacy-config-migrations.channels.ts | 230 ++++++++++++++++++ 5 files changed, 331 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8d1a2e76f..63ba6b8cb35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. - fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987. - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. - fix(device-pair): require pairing scope for pair command [AI]. (#76377) Thanks @pgondhi987. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 3f4549c2c59..aaf68a22176 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -189,6 +189,7 @@ That stages grounded durable candidates into the short-term dreaming store while - `routing.groupChat.requireMention` → `channels.whatsapp/telegram/imessage.groups."*".requireMention` - `routing.groupChat.historyLimit` → `messages.groupChat.historyLimit` - `routing.groupChat.mentionPatterns` → `messages.groupChat.mentionPatterns` + - `channels.telegram.requireMention` → `channels.telegram.groups."*".requireMention` - configured-channel configs missing visible reply policy → `messages.groupChat.visibleReplies: "message_tool"` - `routing.queue` → `messages.queue` - `routing.bindings` → top-level `bindings` diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 8e422ce2647..2497f26a455 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -182,7 +182,56 @@ describe("legacy migrate audio transcription", () => { }); describe("legacy migrate mention routing", () => { - it("does not rewrite removed routing.groupChat.requireMention migrations", () => { + it("moves legacy routing group chat settings into current channel and message config", () => { + const res = migrateLegacyConfigForTest({ + routing: { + allowFrom: ["+15550001111"], + groupChat: { + requireMention: false, + historyLimit: 12, + mentionPatterns: ["@openclaw"], + }, + }, + channels: { + whatsapp: {}, + telegram: { + groups: { + "*": { requireMention: true }, + }, + }, + imessage: {}, + }, + }); + + const migratedConfig = res.config as Record | null; + expect(migratedConfig?.routing).toBeUndefined(); + expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15550001111"]); + expect(res.config?.channels?.whatsapp?.groups).toEqual({ + "*": { requireMention: false }, + }); + expect(res.config?.channels?.telegram?.groups).toEqual({ + "*": { requireMention: true }, + }); + expect(res.config?.channels?.imessage?.groups).toEqual({ + "*": { requireMention: false }, + }); + expect(res.config?.messages?.groupChat).toEqual({ + historyLimit: 12, + mentionPatterns: ["@openclaw"], + }); + expect(res.changes).toEqual( + expect.arrayContaining([ + "Moved routing.allowFrom → channels.whatsapp.allowFrom.", + 'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.', + 'Removed routing.groupChat.requireMention (channels.telegram.groups."*" already set).', + 'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.', + "Moved routing.groupChat.historyLimit → messages.groupChat.historyLimit.", + "Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.", + ]), + ); + }); + + it("removes legacy routing requireMention when no compatible channel exists", () => { const res = migrateLegacyConfigForTest({ routing: { groupChat: { @@ -191,11 +240,14 @@ describe("legacy migrate mention routing", () => { }, }); - expect(res.changes).toEqual([]); - expect(res.config).toBeNull(); + const migratedConfig = res.config as Record | null; + expect(migratedConfig?.routing).toBeUndefined(); + expect(res.changes).toEqual([ + "Removed routing.groupChat.requireMention (no configured WhatsApp, Telegram, or iMessage channel found).", + ]); }); - it("does not rewrite removed channels.telegram.requireMention migrations", () => { + it("moves channels.telegram.requireMention into the wildcard group default", () => { const res = migrateLegacyConfigForTest({ channels: { telegram: { @@ -204,8 +256,14 @@ describe("legacy migrate mention routing", () => { }, }); - expect(res.changes).toEqual([]); - expect(res.config).toBeNull(); + expect(res.config?.channels?.telegram).toEqual({ + groups: { + "*": { requireMention: false }, + }, + }); + expect(res.changes).toContain( + 'Moved channels.telegram.requireMention → channels.telegram.groups."*".requireMention.', + ); }); }); diff --git a/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts b/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts index fb470a1e94e..ae53796654f 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts @@ -2,6 +2,41 @@ import { describe, expect, it } from "vitest"; import { migrateLegacyConfig } from "./legacy-config-migrate.js"; describe("legacy config migrate validation", () => { + it("returns valid migrated config for legacy group chat routing drift", () => { + const res = migrateLegacyConfig({ + routing: { + allowFrom: ["+15550001111"], + groupChat: { + requireMention: false, + historyLimit: 8, + mentionPatterns: ["@openclaw"], + }, + }, + channels: { + whatsapp: {}, + telegram: {}, + }, + }); + + expect(res.partiallyValid).toBeUndefined(); + const migratedConfig = res.config as Record | null; + expect(migratedConfig?.routing).toBeUndefined(); + expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15550001111"]); + expect(res.config?.channels?.whatsapp?.groups).toEqual({ + "*": { requireMention: false }, + }); + expect(res.config?.channels?.telegram?.groups).toEqual({ + "*": { requireMention: false }, + }); + expect(res.config?.messages?.groupChat).toEqual({ + historyLimit: 8, + mentionPatterns: ["@openclaw"], + }); + expect(res.changes).toContain( + 'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.', + ); + }); + it("returns migrated config when unrelated plugin validation issues remain (#76798)", () => { const res = migrateLegacyConfig({ agents: { diff --git a/src/commands/doctor/shared/legacy-config-migrations.channels.ts b/src/commands/doctor/shared/legacy-config-migrations.channels.ts index 65b0c8826a6..2eee9cd6e71 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.channels.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.channels.ts @@ -1,5 +1,6 @@ import { defineLegacyConfigMigration, + ensureRecord, getRecord, type LegacyConfigMigrationSpec, type LegacyConfigRule, @@ -9,6 +10,196 @@ function hasOwnKey(target: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(target, key); } +function cleanupEmptyRecord(parent: Record, key: string): void { + const value = getRecord(parent[key]); + if (value && Object.keys(value).length === 0) { + delete parent[key]; + } +} + +function resolveCompatibleDefaultGroupEntry(section: Record): { + groups: Record; + entry: Record; +} | null { + const existingGroups = section.groups; + if (existingGroups !== undefined && !getRecord(existingGroups)) { + return null; + } + const groups = getRecord(existingGroups) ?? {}; + const defaultKey = "*"; + const existingEntry = groups[defaultKey]; + if (existingEntry !== undefined && !getRecord(existingEntry)) { + return null; + } + const entry = getRecord(existingEntry) ?? {}; + return { groups, entry }; +} + +function migrateChannelDefaultRequireMention(params: { + section: Record; + channelId: string; + legacyPath: string; + requireMention: unknown; + changes: string[]; +}): boolean { + const defaultGroupEntry = resolveCompatibleDefaultGroupEntry(params.section); + if (!defaultGroupEntry) { + params.changes.push( + `Removed ${params.legacyPath} (channels.${params.channelId}.groups has an incompatible shape; fix remaining issues manually).`, + ); + return false; + } + + const { groups, entry } = defaultGroupEntry; + if (entry.requireMention === undefined) { + entry.requireMention = params.requireMention; + groups["*"] = entry; + params.section.groups = groups; + params.changes.push( + `Moved ${params.legacyPath} → channels.${params.channelId}.groups."*".requireMention.`, + ); + return true; + } + + params.changes.push( + `Removed ${params.legacyPath} (channels.${params.channelId}.groups."*" already set).`, + ); + return false; +} + +function migrateRoutingAllowFrom(raw: Record, changes: string[]): void { + const routing = getRecord(raw.routing); + if (!routing || routing.allowFrom === undefined) { + return; + } + + const channels = getRecord(raw.channels); + const whatsapp = getRecord(channels?.whatsapp); + if (!channels || !whatsapp) { + delete routing.allowFrom; + cleanupEmptyRecord(raw, "routing"); + changes.push("Removed routing.allowFrom (channels.whatsapp not configured)."); + return; + } + + if (whatsapp.allowFrom === undefined) { + whatsapp.allowFrom = routing.allowFrom; + changes.push("Moved routing.allowFrom → channels.whatsapp.allowFrom."); + } else { + changes.push("Removed routing.allowFrom (channels.whatsapp.allowFrom already set)."); + } + + delete routing.allowFrom; + channels.whatsapp = whatsapp; + raw.channels = channels; + cleanupEmptyRecord(raw, "routing"); +} + +function migrateRoutingGroupChatMessages(params: { + raw: Record; + routing: Record; + groupChat: Record; + changes: string[]; +}): void { + const migrateMessageGroupField = (field: "historyLimit" | "mentionPatterns") => { + const value = params.groupChat[field]; + if (value === undefined) { + return; + } + + const messages = ensureRecord(params.raw, "messages"); + const messagesGroup = ensureRecord(messages, "groupChat"); + if (messagesGroup[field] === undefined) { + messagesGroup[field] = value; + params.changes.push(`Moved routing.groupChat.${field} → messages.groupChat.${field}.`); + } else { + params.changes.push( + `Removed routing.groupChat.${field} (messages.groupChat.${field} already set).`, + ); + } + delete params.groupChat[field]; + }; + + migrateMessageGroupField("historyLimit"); + migrateMessageGroupField("mentionPatterns"); + + if (Object.keys(params.groupChat).length === 0) { + delete params.routing.groupChat; + } else { + params.routing.groupChat = params.groupChat; + } +} + +function migrateRoutingGroupChatRequireMention(params: { + raw: Record; + groupChat: Record; + changes: string[]; +}): void { + const requireMention = params.groupChat.requireMention; + if (requireMention === undefined) { + return; + } + + const channels = getRecord(params.raw.channels); + let matchedChannel = false; + if (channels) { + for (const channelId of ["whatsapp", "telegram", "imessage"]) { + const section = getRecord(channels[channelId]); + if (!section) { + continue; + } + matchedChannel = true; + migrateChannelDefaultRequireMention({ + section, + channelId, + legacyPath: "routing.groupChat.requireMention", + requireMention, + changes: params.changes, + }); + channels[channelId] = section; + } + params.raw.channels = channels; + } + + if (!matchedChannel) { + params.changes.push( + "Removed routing.groupChat.requireMention (no configured WhatsApp, Telegram, or iMessage channel found).", + ); + } + delete params.groupChat.requireMention; +} + +function migrateRoutingGroupChat(raw: Record, changes: string[]): void { + const routing = getRecord(raw.routing); + const groupChat = getRecord(routing?.groupChat); + if (!routing || !groupChat) { + return; + } + + migrateRoutingGroupChatRequireMention({ raw, groupChat, changes }); + migrateRoutingGroupChatMessages({ raw, routing, groupChat, changes }); + cleanupEmptyRecord(raw, "routing"); +} + +function migrateTelegramRequireMention(raw: Record, changes: string[]): void { + const channels = getRecord(raw.channels); + const telegram = getRecord(channels?.telegram); + if (!channels || !telegram || telegram.requireMention === undefined) { + return; + } + + migrateChannelDefaultRequireMention({ + section: telegram, + channelId: "telegram", + legacyPath: "channels.telegram.requireMention", + requireMention: telegram.requireMention, + changes, + }); + delete telegram.requireMention; + channels.telegram = telegram; + raw.channels = channels; +} + function hasLegacyThreadBindingTtl(value: unknown): boolean { const threadBindings = getRecord(value); return Boolean(threadBindings && hasOwnKey(threadBindings, "ttlHours")); @@ -190,7 +381,46 @@ const THREAD_BINDING_RULES: LegacyConfigRule[] = [ }, ]; +const GROUP_ROUTING_RULES: LegacyConfigRule[] = [ + { + path: ["routing", "allowFrom"], + message: + 'routing.allowFrom was removed; use channels.whatsapp.allowFrom instead. Run "openclaw doctor --fix".', + }, + { + path: ["routing", "groupChat", "requireMention"], + message: + 'routing.groupChat.requireMention was removed; use channels..groups."*".requireMention instead. Run "openclaw doctor --fix".', + }, + { + path: ["routing", "groupChat", "historyLimit"], + message: + 'routing.groupChat.historyLimit was moved; use messages.groupChat.historyLimit instead. Run "openclaw doctor --fix".', + }, + { + path: ["routing", "groupChat", "mentionPatterns"], + message: + 'routing.groupChat.mentionPatterns was moved; use messages.groupChat.mentionPatterns instead. Run "openclaw doctor --fix".', + }, + { + path: ["channels", "telegram", "requireMention"], + message: + 'channels.telegram.requireMention was removed; use channels.telegram.groups."*".requireMention instead. Run "openclaw doctor --fix".', + }, +]; + export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "legacy-group-routing->channel-groups", + describe: + "Move legacy routing group chat settings to current channel group and messages config", + legacyRules: GROUP_ROUTING_RULES, + apply: (raw, changes) => { + migrateRoutingAllowFrom(raw, changes); + migrateRoutingGroupChat(raw, changes); + migrateTelegramRequireMention(raw, changes); + }, + }), defineLegacyConfigMigration({ id: "thread-bindings.ttlHours->idleHours", describe: From 2d849bbafa418397429fec1be364afd38c8c1c51 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 20:02:13 +0100 Subject: [PATCH 018/107] docs(changelog): credit group config migration fix Credit @scoootscooob for #77465. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63ba6b8cb35..79fea17b3cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,7 +47,7 @@ Docs: https://docs.openclaw.ai ### Fixes -- Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. +- Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob. - fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987. - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. - fix(device-pair): require pairing scope for pair command [AI]. (#76377) Thanks @pgondhi987. From a3f6f24b79a5c35bb2267c8936e733c55be67c5f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 12:13:43 -0700 Subject: [PATCH 019/107] ci: gate slack live qa credentials --- .github/workflows/openclaw-release-checks.yml | 14 ++++++++++---- .github/workflows/qa-live-transports-convex.yml | 1 + test/scripts/package-acceptance-workflow.test.ts | 12 ++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 95c2a3f2757..92686c9c7ac 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -208,12 +208,19 @@ jobs: RELEASE_PROFILE_INPUT: ${{ inputs.release_profile }} RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }} RELEASE_LIVE_SUITE_FILTER_INPUT: ${{ inputs.live_suite_filter }} + RELEASE_QA_SLACK_LIVE_CI_ENABLED: ${{ vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED || 'false' }} RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT: ${{ inputs.package_acceptance_package_spec }} run: | set -euo pipefail qa_live_matrix_enabled=true qa_live_telegram_enabled=true - qa_live_slack_enabled=true + qa_live_slack_enabled=false + qa_live_slack_ci_enabled="$(printf '%s' "$RELEASE_QA_SLACK_LIVE_CI_ENABLED" | tr '[:upper:]' '[:lower:]')" + if [[ "$qa_live_slack_ci_enabled" != "true" && "$qa_live_slack_ci_enabled" != "1" && "$qa_live_slack_ci_enabled" != "yes" ]]; then + qa_live_slack_ci_enabled=false + else + qa_live_slack_ci_enabled=true + fi filter="$(printf '%s' "$RELEASE_LIVE_SUITE_FILTER_INPUT" | tr '[:upper:]' '[:lower:]')" if [[ -n "${filter// }" ]]; then @@ -233,7 +240,6 @@ jobs: qa_filter_seen=true matrix_selected=true telegram_selected=true - slack_selected=true ;; qa-live-non-slack|qa-non-slack|non-slack|no-slack|without-slack) qa_filter_seen=true @@ -250,7 +256,7 @@ jobs: ;; qa-live-slack|qa-slack|slack) qa_filter_seen=true - slack_selected=true + slack_selected="$qa_live_slack_ci_enabled" ;; esac done @@ -883,7 +889,7 @@ jobs: qa_live_slack_release_checks: name: Run QA Lab live Slack lane needs: [resolve_target] - if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_slack_enabled == 'true' + if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_slack_enabled == 'true' && vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED == 'true' runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 60 permissions: diff --git a/.github/workflows/qa-live-transports-convex.yml b/.github/workflows/qa-live-transports-convex.yml index f2306dbfdf8..e385423ea99 100644 --- a/.github/workflows/qa-live-transports-convex.yml +++ b/.github/workflows/qa-live-transports-convex.yml @@ -562,6 +562,7 @@ jobs: run_live_slack: name: Run Slack live QA lane with Convex leases needs: [authorize_actor, validate_selected_ref] + if: vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED == 'true' runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 60 environment: qa-live-shared diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index b4ac1ca9d04..81b9ca8edfb 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -575,6 +575,18 @@ describe("package artifact reuse", () => { ); }); + it("keeps Slack live QA disabled in CI until credentials are provisioned", () => { + const releaseWorkflow = readFileSync(RELEASE_CHECKS_WORKFLOW, "utf8"); + const qaWorkflow = readFileSync(QA_LIVE_TRANSPORTS_WORKFLOW, "utf8"); + + expect(releaseWorkflow).toContain("qa_live_slack_enabled=false"); + expect(releaseWorkflow).toContain( + "RELEASE_QA_SLACK_LIVE_CI_ENABLED: ${{ vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED || 'false' }}", + ); + expect(releaseWorkflow).toContain("vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED == 'true'"); + expect(qaWorkflow).toContain("if: vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED == 'true'"); + }); + it("names package acceptance Telegram as artifact-backed package validation", () => { const workflow = readFileSync(PACKAGE_ACCEPTANCE_WORKFLOW, "utf8"); From 3921e1b0b7c7282a8a8e9a450c3386b7f166c7d5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 14:07:54 +0100 Subject: [PATCH 020/107] fix(process): kill Windows command trees on timeout (cherry picked from commit 9cc3ae100b846437dd3dcbcfaf20b242d9f6f6a2) --- src/process/exec.ts | 11 +++++++++++ src/process/exec.windows.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/process/exec.ts b/src/process/exec.ts index c0ed7128900..6907b63cc2e 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -333,6 +333,17 @@ export async function runCommandWithTimeout( return; } killIssuedByTimeout = true; + if (process.platform === "win32" && typeof child.pid === "number" && child.pid > 0) { + try { + spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + return; + } catch { + // Fall through to Node's direct child kill as a last resort. + } + } child.kill("SIGKILL"); }; diff --git a/src/process/exec.windows.test.ts b/src/process/exec.windows.test.ts index 66065bec8be..9be869c0bb8 100644 --- a/src/process/exec.windows.test.ts +++ b/src/process/exec.windows.test.ts @@ -370,6 +370,37 @@ describe("windows command wrapper behavior", () => { } }); + it("kills the Windows process tree when the overall timeout elapses", async () => { + vi.useFakeTimers(); + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const child = createMockChild({ autoClose: false }); + const taskkillChild = createMockChild(); + + spawnMock.mockImplementationOnce(() => child).mockImplementationOnce(() => taskkillChild); + + try { + const resultPromise = runCommandWithTimeout(["node", "idle.js"], { timeoutMs: 80 }); + + await vi.advanceTimersByTimeAsync(81); + expect(child.kill).not.toHaveBeenCalled(); + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock.mock.calls[1]?.[0]).toBe("taskkill"); + expect(spawnMock.mock.calls[1]?.[1]).toEqual(["/PID", "1234", "/T", "/F"]); + expect(spawnMock.mock.calls[1]?.[2]).toMatchObject({ + stdio: "ignore", + windowsHide: true, + }); + + child.emit("close", null, "SIGKILL"); + const result = await resultPromise; + expect(result.termination).toBe("timeout"); + expect(result.code).not.toBe(0); + } finally { + platformSpy.mockRestore(); + vi.useRealTimers(); + } + }); + it("decodes GBK stdout and stderr from runExec on Windows", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); const stdout = Buffer.from([0xb2, 0xe2, 0xca, 0xd4]); From 2e399e6f1ab3bf076d540d21de5e87e8ecb902f9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 14:51:14 +0100 Subject: [PATCH 021/107] test(release): recover known Windows packaged upgrade timeout (cherry picked from commit 8f7399e9e9decbb6e2125278a7ffb6327e9f0088) --- scripts/openclaw-cross-os-release-checks.ts | 52 ++++++++++++++++--- .../openclaw-cross-os-release-checks.test.ts | 16 ++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index 6e0fef6477e..ae3962c2078 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -766,15 +766,35 @@ async function runUpgradeLane(params) { logLanePhase(lane, "update"); const updateEnv = buildRealUpdateEnv(env); const updateArgs = buildPackagedUpgradeUpdateArgs(params.candidateUrl); - const updateResult = await runOpenClaw({ - lane, - env: updateEnv, - args: updateArgs, - logPath: join(params.logsDir, "upgrade-update.log"), - timeoutMs: updateTimeoutMs(), - check: false, - }); + const updateLogPath = join(params.logsDir, "upgrade-update.log"); + let updateResult; + let usedWindowsPackagedUpgradeTimeoutFallback = false; + try { + updateResult = await runOpenClaw({ + lane, + env: updateEnv, + args: updateArgs, + logPath: updateLogPath, + timeoutMs: updateTimeoutMs(), + check: false, + }); + } catch (error) { + if (!isRecoverableWindowsPackagedUpgradeTimeoutError(error, process.platform)) { + throw error; + } + usedWindowsPackagedUpgradeTimeoutFallback = true; + appendFileSync( + updateLogPath, + `\n[release-checks] Windows baseline updater timed out after fetching candidate; falling back to direct candidate install: ${formatError(error)}\n`, + ); + updateResult = { + exitCode: 124, + stdout: "", + stderr: formatError(error), + }; + } const usedWindowsPackagedUpgradeFallback = + usedWindowsPackagedUpgradeTimeoutFallback || isRecoverableWindowsPackagedUpgradeSwapCleanupFailure(updateResult, process.platform); if (usedWindowsPackagedUpgradeFallback) { logLanePhase(lane, "update-fallback-install"); @@ -1367,6 +1387,22 @@ export function isRecoverableWindowsPackagedUpgradeSwapCleanupFailure( ); } +export function isRecoverableWindowsPackagedUpgradeTimeoutError( + error, + platform = process.platform, +) { + if (platform !== "win32") { + return false; + } + const message = error instanceof Error ? error.message : String(error); + return ( + /\bCommand timed out:/u.test(message) && + /[/\\]openclaw\.mjs update --tag http:\/\/127\.0\.0\.1:\d+\/openclaw-current\.tgz --yes --json --timeout \d+/u.test( + message, + ) + ); +} + export function shouldRunPackagedUpgradeStatusProbe({ platform = process.platform, usedWindowsPackagedUpgradeFallback, diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index 4ff4c4e6966..a88a992c13b 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -38,6 +38,7 @@ import { CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS, isImmutableReleaseRef, isRecoverableWindowsPackagedUpgradeSwapCleanupFailure, + isRecoverableWindowsPackagedUpgradeTimeoutError, looksLikeReleaseVersionRef, normalizeRequestedRef, normalizeWindowsCommandShimPath, @@ -744,6 +745,21 @@ describe("scripts/openclaw-cross-os-release-checks", () => { ).toBe(true); }); + it("recognizes the shipped Windows updater packaged-upgrade timeout", () => { + const error = new Error( + "Command timed out: C:\\hostedtoolcache\\windows\\node\\24.15.0\\x64\\node.exe C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\openclaw-upgrade-q9DsA7\\prefix\\node_modules\\openclaw\\openclaw.mjs update --tag http://127.0.0.1:49951/openclaw-current.tgz --yes --json --timeout 1500", + ); + + expect(isRecoverableWindowsPackagedUpgradeTimeoutError(error, "win32")).toBe(true); + expect(isRecoverableWindowsPackagedUpgradeTimeoutError(error, "linux")).toBe(false); + expect( + isRecoverableWindowsPackagedUpgradeTimeoutError( + new Error("Command timed out: node openclaw.mjs update --tag openclaw@beta"), + "win32", + ), + ).toBe(false); + }); + it("skips the packaged upgrade status probe after the Windows fallback install", () => { expect( shouldRunPackagedUpgradeStatusProbe({ From 94f8f1914e36c6541b43f7099c8c91e6d7e743d9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 15:50:21 +0100 Subject: [PATCH 022/107] test(release): match versioned Windows upgrade tarballs (cherry picked from commit b70dbe32d0311808e95448e0f78dee8931e09664) --- scripts/openclaw-cross-os-release-checks.ts | 2 +- test/scripts/openclaw-cross-os-release-checks.test.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index ae3962c2078..668291cdaf9 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -1397,7 +1397,7 @@ export function isRecoverableWindowsPackagedUpgradeTimeoutError( const message = error instanceof Error ? error.message : String(error); return ( /\bCommand timed out:/u.test(message) && - /[/\\]openclaw\.mjs update --tag http:\/\/127\.0\.0\.1:\d+\/openclaw-current\.tgz --yes --json --timeout \d+/u.test( + /[/\\]openclaw\.mjs update --tag http:\/\/127\.0\.0\.1:\d+\/openclaw[^/\s]*\.tgz --yes --json --timeout \d+/u.test( message, ) ); diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index a88a992c13b..c469743dfca 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -747,10 +747,18 @@ describe("scripts/openclaw-cross-os-release-checks", () => { it("recognizes the shipped Windows updater packaged-upgrade timeout", () => { const error = new Error( - "Command timed out: C:\\hostedtoolcache\\windows\\node\\24.15.0\\x64\\node.exe C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\openclaw-upgrade-q9DsA7\\prefix\\node_modules\\openclaw\\openclaw.mjs update --tag http://127.0.0.1:49951/openclaw-current.tgz --yes --json --timeout 1500", + "Command timed out: C:\\hostedtoolcache\\windows\\node\\24.15.0\\x64\\node.exe C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\openclaw-upgrade-q9DsA7\\prefix\\node_modules\\openclaw\\openclaw.mjs update --tag http://127.0.0.1:49951/openclaw-2026.5.4-beta.1.tgz --yes --json --timeout 1500", ); expect(isRecoverableWindowsPackagedUpgradeTimeoutError(error, "win32")).toBe(true); + expect( + isRecoverableWindowsPackagedUpgradeTimeoutError( + new Error( + "Command timed out: C:\\prefix\\node_modules\\openclaw\\openclaw.mjs update --tag http://127.0.0.1:49951/openclaw-current.tgz --yes --json --timeout 1500", + ), + "win32", + ), + ).toBe(true); expect(isRecoverableWindowsPackagedUpgradeTimeoutError(error, "linux")).toBe(false); expect( isRecoverableWindowsPackagedUpgradeTimeoutError( From 5de7f99801914d1df641f39fea71c24de1c184c1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 18:47:31 +0100 Subject: [PATCH 023/107] ci(release): fix ClawHub runtime preflight command (cherry picked from commit 954b25e129f3d42a69d495a250c0f6c87bcf47f2) --- .github/workflows/plugin-clawhub-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-clawhub-release.yml b/.github/workflows/plugin-clawhub-release.yml index 52c9d017223..06769668731 100644 --- a/.github/workflows/plugin-clawhub-release.yml +++ b/.github/workflows/plugin-clawhub-release.yml @@ -241,7 +241,7 @@ jobs: echo "$RUNNER_TEMP" >> "$GITHUB_PATH" - name: Verify package-local runtime build - run: pnpm release:plugins:npm:runtime:check --package "${{ matrix.plugin.packageDir }}" + run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}" - name: Preview publish command env: From 8cf1800ee961813b7f88f7de422c20e100ab58b4 Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Sun, 3 May 2026 12:54:14 -0400 Subject: [PATCH 024/107] fix codex thread continuity --- CHANGELOG.md | 1 + .../codex/src/app-server/run-attempt.test.ts | 120 ++++++++++++++++++ .../codex/src/app-server/run-attempt.ts | 33 +++++ .../codex/src/app-server/thread-lifecycle.ts | 105 +++++++++++---- 4 files changed, 233 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79fea17b3cb..098b1dfd3b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -326,6 +326,7 @@ Docs: https://docs.openclaw.ai - CLI/message: exit cleanly with a nonzero status when message-command plugin registry loading fails before dispatch, preventing `openclaw-message` children from staying alive after plugin load errors. Fixes #76168. - Plugins/config: report configured plugins that are present but blocked by path-safety checks as blocked instead of stale `plugin not found` entries, and deduplicate repeated blocked-candidate warnings during discovery. Fixes #76144. Thanks @mayank6136. - Gateway/update: recover an installed-but-unloaded macOS LaunchAgent after package updates, rerun Gateway health/version/channel readiness checks, and print restart, reinstall, and rollback guidance before reporting update failure. (#76790) Thanks @jonathanlindsay. +- Codex/runtime: preserve native Codex thread bindings across dynamic-tool reorder and no-tool maintenance turns, and project mirrored history when a legacy Codex run must start without a native binding, preventing follow-up requests from losing conversation context. Thanks @VACInc. - CLI/plugins: explain when a missing plugin command alias belongs to a bundled plugin that is disabled by default, including the `openclaw plugins enable ` repair command. (#76835) - Gateway/Bonjour: auto-start LAN multicast discovery only on macOS hosts while preserving explicit `openclaw plugins enable bonjour` startup elsewhere, so Linux servers and containers that do not need LAN discovery avoid default mDNS probing and watchdog churn. Refs #74209. - Gateway/macOS: stop `doctor` and LaunchAgent recovery from running `launchctl kickstart -k` after a fresh bootstrap, avoiding an immediate SIGTERM of the just-started gateway while still nudging already-loaded launchd jobs. Fixes #76261. Thanks @solosage1. diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 36b3d026f15..bf33227e364 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -146,6 +146,14 @@ function assistantMessage(text: string, timestamp: number) { }; } +function userMessage(text: string, timestamp: number) { + return { + role: "user" as const, + content: [{ type: "text" as const, text }], + timestamp, + }; +} + function createAppServerHarness( requestImpl: (method: string, params: unknown) => Promise, options: { @@ -752,6 +760,34 @@ describe("runCodexAppServerAttempt", () => { ); }); + it("projects mirrored history when starting Codex without a native thread binding", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const sessionManager = SessionManager.open(sessionFile); + sessionManager.appendMessage(userMessage("we are fixing the Opik default project", Date.now())); + sessionManager.appendMessage(assistantMessage("Opik default project context", Date.now() + 1)); + const harness = createStartedThreadHarness(); + const params = createParams(sessionFile, workspaceDir); + params.prompt = "make the default webpage openclaw"; + + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + await new Promise((resolve) => setImmediate(resolve)); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + + const turnStart = harness.requests.find((request) => request.method === "turn/start"); + const inputText = + (turnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text ?? + ""; + + expect(inputText).toContain("OpenClaw assembled context for this turn:"); + expect(inputText).toContain("we are fixing the Opik default project"); + expect(inputText).toContain("Opik default project context"); + expect(inputText).toContain("Current user request:"); + expect(inputText).toContain("make the default webpage openclaw"); + }); + it("passes OpenClaw bootstrap files through Codex config instructions", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); @@ -2048,6 +2084,90 @@ describe("runCodexAppServerAttempt", () => { expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]); }); + it("resumes a bound Codex thread when dynamic tools are reordered", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult("thread-existing"); + } + if (method === "thread/resume") { + return threadStartResult("thread-existing"); + } + throw new Error(`unexpected method: ${method}`); + }); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [createNamedDynamicTool("wiki_status"), createNamedDynamicTool("diffs")], + appServer, + }); + const binding = await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [createNamedDynamicTool("diffs"), createNamedDynamicTool("wiki_status")], + appServer, + }); + + expect(binding.threadId).toBe("thread-existing"); + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]); + }); + + it("keeps the previous dynamic tool fingerprint for transient no-tool maintenance turns", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + let nextThread = 1; + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult(`thread-${nextThread++}`); + } + if (method === "thread/resume") { + return threadStartResult("thread-1"); + } + throw new Error(`unexpected method: ${method}`); + }); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [createMessageDynamicTool("Send and manage messages.")], + appServer, + }); + const fingerprint = (await readCodexAppServerBinding(sessionFile))?.dynamicToolsFingerprint; + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer, + }); + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [createMessageDynamicTool("Send and manage messages.")], + appServer, + }); + + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + dynamicToolsFingerprint: fingerprint, + threadId: "thread-1", + }); + expect(request.mock.calls.map(([method]) => method)).toEqual([ + "thread/start", + "thread/start", + "thread/resume", + ]); + }); + it("preserves the binding when the app-server closes during thread resume", 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 6f195eaf368..a082ecb7325 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -88,8 +88,10 @@ import { readCodexAppServerBinding, type CodexAppServerThreadBinding } from "./s import { readCodexMirroredSessionHistoryMessages } from "./session-history.js"; import { clearSharedCodexAppServerClientIfCurrent } from "./shared-client.js"; import { + areCodexDynamicToolFingerprintsCompatible, buildDeveloperInstructions, buildTurnStartParams, + codexDynamicToolsFingerprint, startOrResumeThread, } from "./thread-lifecycle.js"; import { @@ -500,6 +502,20 @@ export async function runCodexAppServerAttempt( error: formatErrorMessage(assembleErr), }); } + } else if ( + shouldProjectMirroredHistoryForCodexStart({ + startupBinding, + dynamicToolsFingerprint: codexDynamicToolsFingerprint(toolBridge.specs), + historyMessages, + }) + ) { + const projection = projectContextEngineAssemblyForCodex({ + assembledMessages: historyMessages, + originalHistoryMessages: historyMessages, + prompt: params.prompt, + }); + promptText = projection.promptText; + prePromptMessageCount = projection.prePromptMessageCount; } const promptBuild = await resolveAgentHarnessBeforePromptBuildResult({ prompt: promptText, @@ -1546,6 +1562,23 @@ async function buildDynamicTools(input: DynamicToolBuildParams) { }); } +function shouldProjectMirroredHistoryForCodexStart(params: { + startupBinding: CodexAppServerThreadBinding | undefined; + dynamicToolsFingerprint: string; + historyMessages: AgentMessage[]; +}): boolean { + if (!params.historyMessages.some((message) => message.role === "user")) { + return false; + } + if (!params.startupBinding?.threadId) { + return true; + } + return !areCodexDynamicToolFingerprintsCompatible({ + previous: params.startupBinding.dynamicToolsFingerprint, + next: params.dynamicToolsFingerprint, + }); +} + async function withCodexStartupTimeout(params: { timeoutMs: number; timeoutFloorMs?: number; diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 023eb85544b..a43f3c3f279 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -47,20 +47,37 @@ export async function startOrResumeThread(params: { agentDir: params.params.agentDir, config: params.params.config, }); + let preserveExistingBinding = false; if (binding?.threadId) { // `/codex resume ` writes a binding before the next turn can know // the dynamic tool catalog, so only invalidate fingerprints we actually have. if ( binding.dynamicToolsFingerprint && - binding.dynamicToolsFingerprint !== dynamicToolsFingerprint + !areDynamicToolFingerprintsCompatible( + binding.dynamicToolsFingerprint, + dynamicToolsFingerprint, + ) ) { - embeddedAgentLog.debug( - "codex app-server dynamic tool catalog changed; starting a new thread", - { - threadId: binding.threadId, - }, - ); - await clearCodexAppServerBinding(params.params.sessionFile); + preserveExistingBinding = shouldStartTransientNoToolThread({ + previous: binding.dynamicToolsFingerprint, + next: dynamicToolsFingerprint, + }); + if (preserveExistingBinding) { + embeddedAgentLog.debug( + "codex app-server dynamic tools unavailable for turn; starting transient thread", + { + threadId: binding.threadId, + }, + ); + } else { + embeddedAgentLog.debug( + "codex app-server dynamic tool catalog changed; starting a new thread", + { + threadId: binding.threadId, + }, + ); + await clearCodexAppServerBinding(params.params.sessionFile); + } } else { try { const authProfileId = params.params.authProfileId ?? binding.authProfileId; @@ -142,23 +159,25 @@ export async function startOrResumeThread(params: { config: params.params.config, }); const createdAt = new Date().toISOString(); - await writeCodexAppServerBinding( - params.params.sessionFile, - { - threadId: response.thread.id, - cwd: params.cwd, - authProfileId: params.params.authProfileId, - model: response.model ?? params.params.modelId, - modelProvider: response.modelProvider ?? modelProvider, - dynamicToolsFingerprint, - createdAt, - }, - { - authProfileStore: params.params.authProfileStore, - agentDir: params.params.agentDir, - config: params.params.config, - }, - ); + if (!preserveExistingBinding) { + await writeCodexAppServerBinding( + params.params.sessionFile, + { + threadId: response.thread.id, + cwd: params.cwd, + authProfileId: params.params.authProfileId, + model: response.model ?? params.params.modelId, + modelProvider: response.modelProvider ?? modelProvider, + dynamicToolsFingerprint, + createdAt, + }, + { + authProfileStore: params.params.authProfileStore, + agentDir: params.params.agentDir, + config: params.params.config, + }, + ); + } return { schemaVersion: 1, threadId: response.thread.id, @@ -284,8 +303,21 @@ function buildHeartbeatCollaborationInstructions(): string { ].join("\n\n"); } +export function codexDynamicToolsFingerprint(dynamicTools: CodexDynamicToolSpec[]): string { + return fingerprintDynamicTools(dynamicTools); +} + +export function areCodexDynamicToolFingerprintsCompatible(params: { + previous?: string; + next: string; +}): boolean { + return areDynamicToolFingerprintsCompatible(params.previous, params.next); +} + function fingerprintDynamicTools(dynamicTools: CodexDynamicToolSpec[]): string { - return JSON.stringify(dynamicTools.map(fingerprintDynamicToolSpec)); + return JSON.stringify( + dynamicTools.map(fingerprintDynamicToolSpec).toSorted(compareJsonFingerprint), + ); } function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue { @@ -320,6 +352,27 @@ function stabilizeJsonValue(value: JsonValue): JsonValue { return stable; } +const EMPTY_DYNAMIC_TOOLS_FINGERPRINT = JSON.stringify([]); + +function areDynamicToolFingerprintsCompatible(previous: string | undefined, next: string): boolean { + return !previous || previous === next; +} + +function shouldStartTransientNoToolThread(params: { + previous: string | undefined; + next: string; +}): boolean { + return Boolean( + params.previous && + params.previous !== EMPTY_DYNAMIC_TOOLS_FINGERPRINT && + params.next === EMPTY_DYNAMIC_TOOLS_FINGERPRINT, + ); +} + +function compareJsonFingerprint(left: JsonValue, right: JsonValue): number { + return JSON.stringify(left).localeCompare(JSON.stringify(right)); +} + export function buildDeveloperInstructions(params: EmbeddedRunAttemptParams): string { const promptOverlay = renderCodexRuntimePromptOverlay(params); const sections = [ From f368201790d2ee07da0c99021eb74a6ba7f9874b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 20:43:38 +0100 Subject: [PATCH 025/107] docs: credit Codex context PR (#76824) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 098b1dfd3b1..1dd930619e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -326,7 +326,7 @@ Docs: https://docs.openclaw.ai - CLI/message: exit cleanly with a nonzero status when message-command plugin registry loading fails before dispatch, preventing `openclaw-message` children from staying alive after plugin load errors. Fixes #76168. - Plugins/config: report configured plugins that are present but blocked by path-safety checks as blocked instead of stale `plugin not found` entries, and deduplicate repeated blocked-candidate warnings during discovery. Fixes #76144. Thanks @mayank6136. - Gateway/update: recover an installed-but-unloaded macOS LaunchAgent after package updates, rerun Gateway health/version/channel readiness checks, and print restart, reinstall, and rollback guidance before reporting update failure. (#76790) Thanks @jonathanlindsay. -- Codex/runtime: preserve native Codex thread bindings across dynamic-tool reorder and no-tool maintenance turns, and project mirrored history when a legacy Codex run must start without a native binding, preventing follow-up requests from losing conversation context. Thanks @VACInc. +- Codex/runtime: preserve native Codex thread bindings across dynamic-tool reorder and no-tool maintenance turns, and project mirrored history when a legacy Codex run must start without a native binding, preventing follow-up requests from losing conversation context. (#76824) Thanks @VACInc. - CLI/plugins: explain when a missing plugin command alias belongs to a bundled plugin that is disabled by default, including the `openclaw plugins enable ` repair command. (#76835) - Gateway/Bonjour: auto-start LAN multicast discovery only on macOS hosts while preserving explicit `openclaw plugins enable bonjour` startup elsewhere, so Linux servers and containers that do not need LAN discovery avoid default mDNS probing and watchdog churn. Refs #74209. - Gateway/macOS: stop `doctor` and LaunchAgent recovery from running `launchctl kickstart -k` after a fresh bootstrap, avoiding an immediate SIGTERM of the just-started gateway while still nudging already-loaded launchd jobs. Fixes #76261. Thanks @solosage1. From 8b2bf7b2e9fd3a25dd578601cf9d6c529b3ad680 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Mon, 4 May 2026 13:51:09 -0600 Subject: [PATCH 026/107] Harden update environment path resolution (#77470) * Harden update environment path resolution * docs(changelog): credit windows update env path hardening Adds the user-facing Unreleased Fixes entry for the workspace LOCALAPPDATA blocklist + portable Git path-prepend hardening change in this PR. --- CHANGELOG.md | 1 + src/infra/dotenv.test.ts | 5 ++++ src/infra/dotenv.ts | 1 + src/infra/update-global.test.ts | 44 +++++++++++++++++++++++++++++++++ src/infra/update-global.ts | 8 +++--- 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dd930619e3..f3dcb300dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -210,6 +210,7 @@ Docs: https://docs.openclaw.ai - Security/Windows: validate `SystemRoot`/`WINDIR` env values through the Windows install-root validator and add them to the dangerous-host-env policy when resolving `icacls.exe`/`whoami.exe` for `openclaw security audit`, so workspace `.env` overrides and bare command names cannot redirect Windows ACL helpers to attacker-controlled binaries. (#74458) Thanks @mmaps. - Security/Windows: pin Windows registry-probe `reg.exe` resolution to the canonical Windows install root in install-root probing, so `SystemRoot`/`WINDIR` env overrides cannot redirect registry queries during Windows host detection. (#74454) Thanks @mmaps. - QQBot: preserve the framework command authorization decision when converting framework command contexts into engine slash command contexts, so downstream slash handlers see `commandAuthorized` matching the channel's resolved `isAuthorizedSender` instead of a hardcoded `true`. (#77453) Thanks @drobison00. +- Security/Windows: block `LOCALAPPDATA` from workspace `.env` and resolve Windows update-flow portable Git path prepends from the trusted process-local `LOCALAPPDATA` only, so workspace-supplied values cannot redirect `git` discovery during `openclaw update`. (#77470) Thanks @drobison00. ## 2026.5.3-1 diff --git a/src/infra/dotenv.test.ts b/src/infra/dotenv.test.ts index 1beb8e9e19c..d9f73a84dfd 100644 --- a/src/infra/dotenv.test.ts +++ b/src/infra/dotenv.test.ts @@ -44,6 +44,8 @@ const BUNDLED_TRUST_ROOT_ENV_KEYS = BUNDLED_TRUST_ROOT_ENV_LINES.map( const WINDOWS_SHELL_TRUST_ROOT_ENV_KEYS = [ "ComSpec", "COMSPEC", + "LocalAppData", + "LOCALAPPDATA", "ProgramFiles", "PROGRAMFILES", "ProgramW6432", @@ -338,6 +340,8 @@ describe("loadDotEnv", () => { [ "ComSpec=.\\evil-comspec", "COMSPEC=.\\evil-comspec-upper", + "LocalAppData=.\\evil-local-app-data", + "LOCALAPPDATA=.\\evil-local-app-data-upper", "ProgramFiles=.\\evil-pfiles", "PROGRAMFILES=.\\evil-pfiles-upper", "ProgramW6432=.\\evil-pw6432", @@ -715,6 +719,7 @@ describe("workspace .env blocklist completeness", () => { "HOMEBREW_BREW_FILE", "HOMEBREW_PREFIX", "IRC_HOST", + "LOCALAPPDATA", "MATTERMOST_URL", "MATRIX_HOMESERVER", "MINIMAX_API_HOST", diff --git a/src/infra/dotenv.ts b/src/infra/dotenv.ts index d3ac84122b2..9bdb55277e1 100644 --- a/src/infra/dotenv.ts +++ b/src/infra/dotenv.ts @@ -29,6 +29,7 @@ const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([ "HOMEBREW_BREW_FILE", "HOMEBREW_PREFIX", "IRC_HOST", + "LOCALAPPDATA", "MATTERMOST_URL", "MATRIX_HOMESERVER", "MINIMAX_API_HOST", diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index 73d44192b0f..fdd3b25f53d 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -150,6 +150,50 @@ describe("update global helpers", () => { }); }); + it("resolves portable Git paths from process-local app data only", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + await withTempDir({ prefix: "openclaw-update-portable-git-" }, async (base) => { + envSnapshot = captureEnv(["LOCALAPPDATA"]); + const injectedLocalAppData = path.join(base, "injected-local-app-data"); + const trustedLocalAppData = path.join(base, "trusted-local-app-data"); + const injectedGitDir = path.join( + injectedLocalAppData, + "OpenClaw", + "deps", + "portable-git", + "cmd", + ); + const trustedGitDir = path.join( + trustedLocalAppData, + "OpenClaw", + "deps", + "portable-git", + "cmd", + ); + await fs.mkdir(injectedGitDir, { recursive: true }); + await fs.mkdir(trustedGitDir, { recursive: true }); + + delete process.env.LOCALAPPDATA; + const injectedOnlyEnv = await createGlobalInstallEnv({ + LOCALAPPDATA: injectedLocalAppData, + PATH: "base-bin", + }); + expect(injectedOnlyEnv?.PATH).not.toContain(injectedGitDir); + + process.env.LOCALAPPDATA = trustedLocalAppData; + const trustedEnv = await createGlobalInstallEnv({ + LOCALAPPDATA: injectedLocalAppData, + PATH: "base-bin", + }); + expect(trustedEnv?.PATH).toContain(trustedGitDir); + expect(trustedEnv?.PATH).not.toContain(injectedGitDir); + }); + } finally { + platformSpy.mockRestore(); + } + }); + it("classifies main and raw install specs separately from registry selectors", () => { expect(isMainPackageTarget("main")).toBe(true); expect(isMainPackageTarget(" MAIN ")).toBe(true); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index 88f910c2f55..a80017f062e 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -274,13 +274,11 @@ export function canResolveRegistryVersionForPackageTarget(value: string): boolea return !isMainPackageTarget(trimmed) && !isExplicitPackageInstallSpec(trimmed); } -async function resolvePortableGitPathPrepend( - env: NodeJS.ProcessEnv | undefined, -): Promise { +async function resolvePortableGitPathPrepend(): Promise { if (process.platform !== "win32") { return []; } - const localAppData = env?.LOCALAPPDATA?.trim() || process.env.LOCALAPPDATA?.trim(); + const localAppData = process.env.LOCALAPPDATA?.trim(); if (!localAppData) { return []; } @@ -341,7 +339,7 @@ export function resolveGlobalInstallSpec(params: { export async function createGlobalInstallEnv( env?: NodeJS.ProcessEnv, ): Promise { - const pathPrepend = await resolvePortableGitPathPrepend(env); + const pathPrepend = await resolvePortableGitPathPrepend(); const sourceEnv = env ?? process.env; const hasCorepackDownloadPromptSetting = Boolean( sourceEnv.COREPACK_ENABLE_DOWNLOAD_PROMPT?.trim(), From f2efe33afc5d27f583e5520c4db9e311c33f087c Mon Sep 17 00:00:00 2001 From: Syu <62822461+SYU8384@users.noreply.github.com> Date: Tue, 5 May 2026 05:05:15 +0900 Subject: [PATCH 027/107] Fix Active Memory memory-only recall latency (#75200) Summary: - The PR adds a bounded latest-message search-query section to Active Memory recall prompts, regression coverage for metadata stripping, a changelog entry, and pending-final-delivery session slot reservations. - Reproducibility: yes. for a source-level reproduction path: an eligible interactive turn reaches Active Memo ... om current releases, but I did not run a live gateway/provider reproduction under the read-only constraint. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(plugins): reserve final delivery session slots Validation: - ClawSweeper review passed for head 24bf408e75d87081c8736f1df7fd06bc3b4f887e. - Required merge gates passed before the squash merge. Prepared head SHA: 24bf408e75d87081c8736f1df7fd06bc3b4f887e Review: https://github.com/openclaw/openclaw/pull/75200#issuecomment-4354978044 Co-authored-by: SYU8384 --- CHANGELOG.md | 1 + extensions/active-memory/index.test.ts | 49 +++++++++++- extensions/active-memory/index.ts | 104 ++++++++++++++++++++++++- 3 files changed, 149 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3dcb300dd2..b8812b358f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob. - fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987. - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. +- Active Memory: send a bounded latest-message search query to the recall worker so channel/runtime metadata does not become the memory search string. Fixes #65309. Thanks @joeykrug, @westley3601, @pimenov, and @tasi333. - fix(device-pair): require pairing scope for pair command [AI]. (#76377) Thanks @pgondhi987. - fix(qqbot): keep private commands off framework surface [AI]. (#77212) Thanks @pgondhi987. - Claude CLI: honor non-off `/think` levels by passing Claude Code's session-scoped `--effort` flag through the CLI backend seam, so chat bridges no longer show an inert thinking control. Fixes #77303. Thanks @Petr1t. diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 695c15bde84..ff6b452c36a 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -1074,9 +1074,12 @@ describe("active-memory plugin", () => { "Your job is to search memory and return only the most relevant memory context for that model.", ); expect(runParams?.prompt).toContain( - "You receive conversation context, including the user's latest message.", + "You receive a bounded search query plus conversation context, including the user's latest message.", ); expect(runParams?.prompt).toContain("Use only the available memory tools."); + expect(runParams?.prompt).toContain( + "Use the bounded search query as the memory_search or memory_recall query.", + ); expect(runParams?.prompt).toContain("Prefer memory_recall when available."); expect(runParams?.prompt).toContain( "If memory_recall is unavailable, use memory_search and memory_get.", @@ -2894,10 +2897,54 @@ describe("active-memory plugin", () => { ); const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt; + expect(prompt).toContain("Bounded memory search query:\nwhat should i grab on the way?"); expect(prompt).toContain("Conversation context:\nwhat should i grab on the way?"); expect(prompt).not.toContain("Recent conversation tail:"); }); + it("sends a bounded latest-message query instead of channel metadata to memory search", async () => { + api.pluginConfig = { + agents: ["main"], + queryMode: "recent", + }; + plugin.register(api as unknown as OpenClawPluginApi); + + await hooks.before_prompt_build( + { + prompt: [ + "Conversation info:", + "Sender: discord:user-123", + "Untrusted Discord message body", + "---", + "do you remember my flight preferences?", + ].join("\n"), + messages: [ + { role: "user", content: "i have a flight tomorrow" }, + { role: "assistant", content: "got it" }, + ], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt; + expect(prompt).toContain( + "Bounded memory search query:\ndo you remember my flight preferences?", + ); + expect(prompt).toContain( + "Do not use channel metadata, provider metadata, debug output, or the full conversation context as the memory tool query.", + ); + expect(prompt).toContain("Conversation context:"); + expect(prompt).toContain("Conversation info:"); + expect(prompt).not.toContain("Bounded memory search query:\nConversation info:"); + expect(prompt).not.toContain("Bounded memory search query:\nSender:"); + expect(prompt).not.toContain("Bounded memory search query:\nUntrusted Discord message body"); + }); + it("supports full mode by sending the whole conversation", async () => { api.pluginConfig = { agents: ["main"], diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 314c3df6490..918e4c04d81 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -46,6 +46,7 @@ const DEFAULT_PARTIAL_TRANSCRIPT_MAX_CHARS = 32_000; const DEFAULT_TRANSCRIPT_READ_MAX_LINES = 2_000; const DEFAULT_TRANSCRIPT_READ_MAX_BYTES = 50 * 1024 * 1024; const TIMEOUT_PARTIAL_DATA_GRACE_MS = 50; +const MAX_ACTIVE_MEMORY_SEARCH_QUERY_CHARS = 480; const TERMINAL_MEMORY_SEARCH_POLL_INTERVAL_MS = 25; const NO_RECALL_VALUES = new Set([ @@ -940,13 +941,16 @@ function buildPromptStyleLines(style: ActiveMemoryPromptStyle): string[] { function buildRecallPrompt(params: { config: ResolvedActiveRecallPluginConfig; query: string; + searchQuery: string; }): string { const defaultInstructions = [ "You are a memory search agent.", "Another model is preparing the final user-facing answer.", "Your job is to search memory and return only the most relevant memory context for that model.", - "You receive conversation context, including the user's latest message.", + "You receive a bounded search query plus conversation context, including the user's latest message.", "Use only the available memory tools.", + "Use the bounded search query as the memory_search or memory_recall query.", + "Do not use channel metadata, provider metadata, debug output, or the full conversation context as the memory tool query.", "Prefer memory_recall when available.", "If memory_recall is unavailable, use memory_search and memory_get.", "When searching for preference or habit recall, use a permissive recall limit or memory_search threshold before deciding that no useful memory exists.", @@ -998,7 +1002,11 @@ function buildRecallPrompt(params: { ] .filter((section) => section.length > 0) .join("\n\n"); - return `${instructionBlock}\n\nConversation context:\n${params.query}`; + return [ + instructionBlock, + `Bounded memory search query:\n${params.searchQuery}`, + `Conversation context:\n${params.query}`, + ].join("\n\n"); } function isEnabledForAgent( @@ -2056,6 +2064,83 @@ function buildQuery(params: { ].join("\n"); } +function stripExternalUntrustedBlocks(text: string): string { + return text.replace( + /<<]*>>>[\s\S]*?<<]*>>>/g, + " ", + ); +} + +function stripJsonFences(text: string): string { + return text.replace(/```(?:json)?\s*[\s\S]*?```/gi, " "); +} + +function stripActiveMemoryXmlBlocks(text: string): string { + return text.replace(/[\s\S]*?<\/active_memory_plugin>/gi, " "); +} + +function normalizeSearchQueryText(text: string): string { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => { + if (!line) { + return false; + } + if (/^(conversation info|sender|untrusted context)\b/i.test(line)) { + return false; + } + if (/^(source: external|---|untrusted discord message body)$/i.test(line)) { + return false; + } + if (/^⚠️?\s*Agent couldn't generate a response/i.test(line)) { + return false; + } + if (/^Please try again\.?$/i.test(line)) { + return false; + } + return true; + }) + .join(" ") + .replace(/\s+/g, " ") + .trim(); +} + +function clampSearchQuery(text: string): string { + const normalized = text.replace(/\s+/g, " ").trim(); + return normalized.length > MAX_ACTIVE_MEMORY_SEARCH_QUERY_CHARS + ? normalized.slice(0, MAX_ACTIVE_MEMORY_SEARCH_QUERY_CHARS).trim() + : normalized; +} + +function buildSearchQuery(params: { + latestUserMessage: string; + recentTurns?: ActiveRecallRecentTurn[]; +}): string { + const latest = clampSearchQuery( + normalizeSearchQueryText( + stripActiveMemoryXmlBlocks( + stripJsonFences(stripExternalUntrustedBlocks(params.latestUserMessage)), + ), + ), + ); + if (latest.length >= 12 || !params.recentTurns?.length) { + return latest || clampSearchQuery(params.latestUserMessage); + } + const previousUser = [...params.recentTurns] + .toReversed() + .find((turn) => turn.role === "user" && turn.text.trim() !== params.latestUserMessage.trim()); + if (!previousUser) { + return latest || clampSearchQuery(params.latestUserMessage); + } + const context = clampSearchQuery( + normalizeSearchQueryText(stripRecalledContextNoise(previousUser.text)), + ) + .slice(0, 120) + .trim(); + return clampSearchQuery(context ? `${context} ${latest}` : latest); +} + function extractTextContent(content: unknown): string { if (typeof content === "string") { return content; @@ -2224,6 +2309,7 @@ async function runRecallSubagent(params: { messageProvider?: string; channelId?: string; query: string; + searchQuery: string; currentModelProviderId?: string; currentModelId?: string; modelRef?: { provider: string; model: string }; @@ -2278,6 +2364,7 @@ async function runRecallSubagent(params: { const prompt = buildRecallPrompt({ config: params.config, query: params.query, + searchQuery: params.searchQuery, }); const { messageChannel, messageProvider } = resolveRecallRunChannelContext({ api: params.api, @@ -2367,6 +2454,7 @@ async function maybeResolveActiveRecall(params: { messageProvider?: string; channelId?: string; query: string; + searchQuery: string; currentModelProviderId?: string; currentModelId?: string; }): Promise { @@ -2444,7 +2532,9 @@ async function maybeResolveActiveRecall(params: { if (params.config.logging) { params.api.logger.info?.( - `${logPrefix} start timeoutMs=${String(params.config.timeoutMs)} queryChars=${String(params.query.length)}`, + `${logPrefix} start timeoutMs=${String(params.config.timeoutMs)} queryChars=${String( + params.query.length, + )} searchQueryChars=${String(params.searchQuery.length)}`, ); } @@ -2813,11 +2903,16 @@ export default definePluginEntry({ }); return undefined; } + const recentTurns = extractRecentTurns(event.messages); const query = buildQuery({ latestUserMessage: event.prompt, - recentTurns: extractRecentTurns(event.messages), + recentTurns, config, }); + const searchQuery = buildSearchQuery({ + latestUserMessage: event.prompt, + recentTurns, + }); const result = await maybeResolveActiveRecall({ api, config, @@ -2827,6 +2922,7 @@ export default definePluginEntry({ messageProvider: ctx.messageProvider, channelId: ctx.channelId, query, + searchQuery, currentModelProviderId: ctx.modelProviderId, currentModelId: ctx.modelId, }); From ef0dbcf49d858763a73d2ce5996dc541a45f78e0 Mon Sep 17 00:00:00 2001 From: Agustin Rivera <31522568+eleqtrizit@users.noreply.github.com> Date: Mon, 4 May 2026 13:07:17 -0700 Subject: [PATCH 028/107] Guard current browser tab exports (#75731) * fix(browser): guard current tab exports * fix(browser): expand tab guard coverage * fix(browser): guard tab reads * fix(browser): guard screenshot route * changelog: PR #75731 --------- Co-authored-by: Devin Robison --- CHANGELOG.md | 1 + .../browser/src/browser/routes/agent.act.ts | 1 + .../browser/src/browser/routes/agent.debug.ts | 5 + .../src/browser/routes/agent.shared.test.ts | 70 +++++++++++++- .../src/browser/routes/agent.shared.ts | 26 +++++ .../src/browser/routes/agent.snapshot.ts | 9 +- .../src/browser/routes/agent.storage.ts | 8 ++ ...-contract-form-layout-act-commands.test.ts | 96 +++++++++++++++++++ .../server.control-server.test-harness.ts | 24 ++++- 9 files changed, 230 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8812b358f8..259fb809ed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -212,6 +212,7 @@ Docs: https://docs.openclaw.ai - Security/Windows: pin Windows registry-probe `reg.exe` resolution to the canonical Windows install root in install-root probing, so `SystemRoot`/`WINDIR` env overrides cannot redirect registry queries during Windows host detection. (#74454) Thanks @mmaps. - QQBot: preserve the framework command authorization decision when converting framework command contexts into engine slash command contexts, so downstream slash handlers see `commandAuthorized` matching the channel's resolved `isAuthorizedSender` instead of a hardcoded `true`. (#77453) Thanks @drobison00. - Security/Windows: block `LOCALAPPDATA` from workspace `.env` and resolve Windows update-flow portable Git path prepends from the trusted process-local `LOCALAPPDATA` only, so workspace-supplied values cannot redirect `git` discovery during `openclaw update`. (#77470) Thanks @drobison00. +- Browser/SSRF: enforce the existing current-tab URL navigation policy before tab-scoped debug, export, and read routes (console, page errors, network requests, trace start/stop, response body, screenshot, snapshot, storage, etc.) collect from an already-selected tab, so blocked tabs return a policy error instead of being read first and redacted only at response time. (#75731) Thanks @eleqtrizit. ## 2026.5.3-1 diff --git a/extensions/browser/src/browser/routes/agent.act.ts b/extensions/browser/src/browser/routes/agent.act.ts index ab33f4fb07f..9820ebbb8a8 100644 --- a/extensions/browser/src/browser/routes/agent.act.ts +++ b/extensions/browser/src/browser/routes/agent.act.ts @@ -695,6 +695,7 @@ export function registerBrowserAgentActRoutes( res, ctx, targetId, + enforceCurrentUrlAllowed: true, run: async ({ profileCtx, cdpUrl, tab, resolveTabUrl }) => { if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { return jsonError(res, 501, EXISTING_SESSION_LIMITS.responseBody); diff --git a/extensions/browser/src/browser/routes/agent.debug.ts b/extensions/browser/src/browser/routes/agent.debug.ts index 948f1f03be4..81ba1f1b4af 100644 --- a/extensions/browser/src/browser/routes/agent.debug.ts +++ b/extensions/browser/src/browser/routes/agent.debug.ts @@ -29,6 +29,7 @@ export function registerBrowserAgentDebugRoutes( ctx, targetId, feature: "console messages", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => { const messages = await pw.getConsoleMessagesViaPlaywright({ cdpUrl, @@ -54,6 +55,7 @@ export function registerBrowserAgentDebugRoutes( ctx, targetId, feature: "page errors", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => { const result = await pw.getPageErrorsViaPlaywright({ cdpUrl, @@ -80,6 +82,7 @@ export function registerBrowserAgentDebugRoutes( ctx, targetId, feature: "network requests", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => { const result = await pw.getNetworkRequestsViaPlaywright({ cdpUrl, @@ -109,6 +112,7 @@ export function registerBrowserAgentDebugRoutes( ctx, targetId, feature: "trace start", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => { await pw.traceStartViaPlaywright({ cdpUrl, @@ -137,6 +141,7 @@ export function registerBrowserAgentDebugRoutes( ctx, targetId, feature: "trace stop", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw, resolveTabUrl }) => { const id = crypto.randomUUID(); const tracePath = await resolveWritableOutputPathOrRespond({ diff --git a/extensions/browser/src/browser/routes/agent.shared.test.ts b/extensions/browser/src/browser/routes/agent.shared.test.ts index a6247d35724..1de49298c01 100644 --- a/extensions/browser/src/browser/routes/agent.shared.test.ts +++ b/extensions/browser/src/browser/routes/agent.shared.test.ts @@ -1,10 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; import { readBody, resolveSafeRouteTabUrl, resolveTargetIdFromBody, resolveTargetIdFromQuery, + withRouteTabContext, } from "./agent.shared.js"; +import { createBrowserRouteResponse } from "./test-helpers.js"; import type { BrowserRequest } from "./types.js"; function requestWithBody(body: unknown): BrowserRequest { @@ -36,6 +39,31 @@ function profileContext(tabs: Array<{ targetId: string; url: string }>) { }; } +function routeContextForTab(url: string): BrowserRouteContext { + const profileCtx = { + profile: { + cdpUrl: "http://127.0.0.1:9222", + name: "default", + }, + ensureTabAvailable: vi.fn(async () => ({ + targetId: "tab-1", + title: "Tab", + url, + type: "page", + })), + } as unknown as ProfileContext; + + return { + forProfile: () => profileCtx, + state: () => ({ + resolved: { + ssrfPolicy: {}, + }, + }), + mapTabError: () => null, + } as unknown as BrowserRouteContext; +} + describe("browser route shared helpers", () => { describe("readBody", () => { it("returns object bodies", () => { @@ -100,4 +128,44 @@ describe("browser route shared helpers", () => { ).resolves.toBeUndefined(); }); }); + + describe("withRouteTabContext", () => { + it("does not enforce current-tab URL policy unless requested", async () => { + const response = createBrowserRouteResponse(); + const run = vi.fn(async () => { + response.res.json({ ok: true }); + }); + + await withRouteTabContext({ + req: requestWithBody({}), + res: response.res, + ctx: routeContextForTab("http://127.0.0.1:8080/admin"), + run, + }); + + expect(run).toHaveBeenCalledOnce(); + expect(response.body).toEqual({ ok: true }); + }); + + it("blocks guarded routes before running on a disallowed current tab", async () => { + const response = createBrowserRouteResponse(); + const run = vi.fn(async () => { + response.res.json({ ok: true }); + }); + + await withRouteTabContext({ + req: requestWithBody({}), + res: response.res, + ctx: routeContextForTab("http://127.0.0.1:8080/admin"), + enforceCurrentUrlAllowed: true, + run, + }); + + expect(run).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(400); + expect(response.body).toMatchObject({ error: expect.any(String) }); + const body = response.body as { error?: unknown }; + expect(body.error).not.toBe(""); + }); + }); }); diff --git a/extensions/browser/src/browser/routes/agent.shared.ts b/extensions/browser/src/browser/routes/agent.shared.ts index e3c1af2acf4..d95e3f13216 100644 --- a/extensions/browser/src/browser/routes/agent.shared.ts +++ b/extensions/browser/src/browser/routes/agent.shared.ts @@ -107,6 +107,11 @@ type RouteWithTabParams = { res: BrowserResponse; ctx: BrowserRouteContext; targetId?: string; + /** + * Set for routes that read from or return data scoped to the selected tab. + * Leave false only for routes that navigate, activate, close, or otherwise manage the tab. + */ + enforceCurrentUrlAllowed?: boolean; run: (ctx: RouteTabContext) => Promise; }; @@ -119,6 +124,17 @@ export async function withRouteTabContext( } try { const tab = await profileCtx.ensureTabAvailable(params.targetId); + if (params.enforceCurrentUrlAllowed) { + await assertBrowserNavigationResultAllowed({ + url: tab.url, + ...withBrowserNavigationPolicy(params.ctx.state().resolved.ssrfPolicy, { + browserProxyMode: resolveBrowserNavigationProxyMode({ + resolved: params.ctx.state().resolved, + profile: profileCtx.profile, + }), + }), + }); + } return await params.run({ profileCtx, tab, @@ -137,6 +153,10 @@ export async function withRouteTabContext( } } +/** + * Response-only URL redaction. This swallows policy failures and must not be used as + * an execution gate; use enforceCurrentUrlAllowed on the route helper instead. + */ export async function resolveSafeRouteTabUrl(params: { ctx: BrowserRouteContext; profileCtx: ProfileContext; @@ -171,6 +191,11 @@ type RouteWithPwParams = { ctx: BrowserRouteContext; targetId?: string; feature: string; + /** + * Set for routes that read from or return data scoped to the selected tab. + * Leave false only for routes that navigate, activate, close, or otherwise manage the tab. + */ + enforceCurrentUrlAllowed?: boolean; run: (ctx: RouteTabPwContext) => Promise; }; @@ -182,6 +207,7 @@ export async function withPlaywrightRouteContext( res: params.res, ctx: params.ctx, targetId: params.targetId, + enforceCurrentUrlAllowed: params.enforceCurrentUrlAllowed, run: async ({ profileCtx, tab, cdpUrl, resolveTabUrl }) => { const pw = await requirePwAi(params.res, params.feature); if (!pw) { diff --git a/extensions/browser/src/browser/routes/agent.snapshot.ts b/extensions/browser/src/browser/routes/agent.snapshot.ts index 36d6d3039ee..37446db2267 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.ts @@ -318,6 +318,7 @@ export function registerBrowserAgentSnapshotRoutes( ctx, targetId, feature: "pdf", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw }) => { const pdf = await pw.pdfViaPlaywright({ cdpUrl, @@ -361,18 +362,12 @@ export function registerBrowserAgentSnapshotRoutes( res, ctx, targetId, + enforceCurrentUrlAllowed: true, run: async ({ profileCtx, tab, cdpUrl }) => { if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { - const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx); if (element) { return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement); } - if (ssrfPolicyOpts.ssrfPolicy) { - await assertBrowserNavigationResultAllowed({ - url: tab.url, - ...ssrfPolicyOpts, - }); - } if (labels) { const snapshot = await takeChromeMcpSnapshot({ profileName: profileCtx.profile.name, diff --git a/extensions/browser/src/browser/routes/agent.storage.ts b/extensions/browser/src/browser/routes/agent.storage.ts index 0ec039f14b6..cc78dbd1f03 100644 --- a/extensions/browser/src/browser/routes/agent.storage.ts +++ b/extensions/browser/src/browser/routes/agent.storage.ts @@ -85,6 +85,7 @@ export function registerBrowserAgentStorageRoutes( ctx, targetId, feature: "cookies", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw }) => { const result = await pw.cookiesGetViaPlaywright({ cdpUrl, @@ -109,6 +110,7 @@ export function registerBrowserAgentStorageRoutes( return jsonError(res, 400, "cookie is required"); } + // Intentional: mutation routes are outside the tab-scoped read/export guard scope. await withPlaywrightRouteContext({ req, res, @@ -148,6 +150,7 @@ export function registerBrowserAgentStorageRoutes( const body = readBody(req); const targetId = resolveTargetIdFromBody(body); + // Intentional: mutation routes are outside the tab-scoped read/export guard scope. await withPlaywrightRouteContext({ req, res, @@ -181,6 +184,7 @@ export function registerBrowserAgentStorageRoutes( ctx, targetId, feature: "storage get", + enforceCurrentUrlAllowed: true, run: async ({ cdpUrl, tab, pw }) => { const result = await pw.storageGetViaPlaywright({ cdpUrl, @@ -207,6 +211,7 @@ export function registerBrowserAgentStorageRoutes( } const value = typeof mutation.body.value === "string" ? mutation.body.value : ""; + // Intentional: mutation routes are outside the tab-scoped read/export guard scope. await withPlaywrightRouteContext({ req, res, @@ -235,6 +240,7 @@ export function registerBrowserAgentStorageRoutes( return; } + // Intentional: mutation routes are outside the tab-scoped read/export guard scope. await withPlaywrightRouteContext({ req, res, @@ -263,6 +269,7 @@ export function registerBrowserAgentStorageRoutes( return jsonError(res, 400, "offline is required"); } + // Intentional: mutation routes are outside the tab-scoped read/export guard scope. await withPlaywrightRouteContext({ req, res, @@ -301,6 +308,7 @@ export function registerBrowserAgentStorageRoutes( } } + // Intentional: mutation routes are outside the tab-scoped read/export guard scope. await withPlaywrightRouteContext({ req, res, diff --git a/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts b/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts index 97a958cbbe2..dc5947309d8 100644 --- a/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts +++ b/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts @@ -11,6 +11,8 @@ import { import { getBrowserControlServerTestState, getPwMocks, + setBrowserControlServerSsrFPolicy, + setBrowserControlServerTabUrl, } from "./server.control-server.test-harness.js"; import { getBrowserTestFetch, type BrowserTestFetch } from "./test-support/fetch.js"; @@ -18,6 +20,81 @@ const state = getBrowserControlServerTestState(); const pwMocks = getPwMocks(); const realFetch: BrowserTestFetch = (input, init) => getBrowserTestFetch()(input, init); +type GuardedCurrentTabRouteCase = { + method: "GET" | "POST"; + path: string; + body?: Record; + mockName: + | "cookiesGetViaPlaywright" + | "pdfViaPlaywright" + | "getConsoleMessagesViaPlaywright" + | "getPageErrorsViaPlaywright" + | "getNetworkRequestsViaPlaywright" + | "responseBodyViaPlaywright" + | "storageGetViaPlaywright" + | "takeScreenshotViaPlaywright" + | "traceStartViaPlaywright" + | "traceStopViaPlaywright"; +}; + +const guardedCurrentTabRouteCases: readonly GuardedCurrentTabRouteCase[] = [ + { + method: "GET", + path: "/console?targetId=abcd1234", + mockName: "getConsoleMessagesViaPlaywright", + }, + { + method: "GET", + path: "/errors?targetId=abcd1234", + mockName: "getPageErrorsViaPlaywright", + }, + { + method: "GET", + path: "/requests?targetId=abcd1234", + mockName: "getNetworkRequestsViaPlaywright", + }, + { + method: "POST", + path: "/pdf", + body: { targetId: "abcd1234" }, + mockName: "pdfViaPlaywright", + }, + { + method: "POST", + path: "/screenshot", + body: { targetId: "abcd1234" }, + mockName: "takeScreenshotViaPlaywright", + }, + { + method: "POST", + path: "/response/body", + body: { targetId: "abcd1234", url: "**/api/data" }, + mockName: "responseBodyViaPlaywright", + }, + { + method: "GET", + path: "/cookies?targetId=abcd1234", + mockName: "cookiesGetViaPlaywright", + }, + { + method: "GET", + path: "/storage/local?targetId=abcd1234", + mockName: "storageGetViaPlaywright", + }, + { + method: "POST", + path: "/trace/start", + body: { targetId: "abcd1234" }, + mockName: "traceStartViaPlaywright", + }, + { + method: "POST", + path: "/trace/stop", + body: { targetId: "abcd1234" }, + mockName: "traceStopViaPlaywright", + }, +] as const; + async function withSymlinkPathEscape(params: { rootDir: string; run: (relativePath: string) => Promise; @@ -439,6 +516,25 @@ describe("browser control server", () => { ); }); + it.each(guardedCurrentTabRouteCases)( + "blocks $method $path on disallowed current tab URLs", + async (routeCase) => { + setBrowserControlServerSsrFPolicy({ allowPrivateNetwork: false }); + setBrowserControlServerTabUrl("http://127.0.0.1:8080/admin"); + const base = await startServerAndBase(); + + const res = await realFetch(`${base}${routeCase.path}`, { + method: routeCase.method, + headers: routeCase.body ? { "Content-Type": "application/json" } : undefined, + body: routeCase.body ? JSON.stringify(routeCase.body) : undefined, + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error?: unknown }; + expect(body.error).toEqual(expect.stringMatching(/(blocked|denied|not allowed|policy)/i)); + expect(pwMocks[routeCase.mockName]).not.toHaveBeenCalled(); + }, + ); + it("wait/download rejects traversal path outside downloads dir", async () => { const base = await startServerAndBase(); const waitRes = await postJson<{ error?: string }>(`${base}/wait/download`, { diff --git a/extensions/browser/src/browser/server.control-server.test-harness.ts b/extensions/browser/src/browser/server.control-server.test-harness.ts index fe7a525aee4..bc5d16eb00c 100644 --- a/extensions/browser/src/browser/server.control-server.test-harness.ts +++ b/extensions/browser/src/browser/server.control-server.test-harness.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, vi } from "vitest"; import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; import type { MockFn } from "../test-utils/vitest-mock-fn.js"; import { installChromeUserDataDirHooks } from "./chrome-user-data-dir.test-harness.js"; import { getFreePort } from "./test-port.js"; @@ -10,6 +11,7 @@ type HarnessState = { reachable: boolean; cfgAttachOnly: boolean; cfgEvaluateEnabled: boolean; + cfgSsrfPolicy: SsrFPolicy | undefined; cfgDefaultProfile: string; cfgProfiles: Record< string, @@ -21,6 +23,7 @@ type HarnessState = { attachOnly?: boolean; } >; + tabUrl: string; prevGatewayPort: string | undefined; prevGatewayToken: string | undefined; prevGatewayPassword: string | undefined; @@ -32,8 +35,10 @@ const state: HarnessState = { reachable: false, cfgAttachOnly: false, cfgEvaluateEnabled: true, + cfgSsrfPolicy: undefined, cfgDefaultProfile: "openclaw", cfgProfiles: {}, + tabUrl: "https://example.com", prevGatewayPort: undefined, prevGatewayToken: undefined, prevGatewayPassword: undefined, @@ -59,10 +64,18 @@ export function setBrowserControlServerEvaluateEnabled(enabled: boolean): void { state.cfgEvaluateEnabled = enabled; } +export function setBrowserControlServerSsrFPolicy(policy: SsrFPolicy | undefined): void { + state.cfgSsrfPolicy = policy; +} + export function setBrowserControlServerReachable(reachable: boolean): void { state.reachable = reachable; } +export function setBrowserControlServerTabUrl(url: string): void { + state.tabUrl = url; +} + export function setBrowserControlServerProfiles( profiles: HarnessState["cfgProfiles"], defaultProfile = Object.keys(profiles)[0] ?? "openclaw", @@ -152,6 +165,7 @@ const pwMocks = vi.hoisted(() => ({ clickViaPlaywright: vi.fn(async (_opts?: unknown) => {}), closePageViaPlaywright: vi.fn(async (_opts?: unknown) => {}), closePlaywrightBrowserConnection: vi.fn(async () => {}), + cookiesGetViaPlaywright: vi.fn(async () => ({ cookies: [] })), downloadViaPlaywright: vi.fn(async () => ({ url: "https://example.com/report.pdf", suggestedFilename: "report.pdf", @@ -161,6 +175,8 @@ const pwMocks = vi.hoisted(() => ({ evaluateViaPlaywright: vi.fn(async (_opts?: unknown) => "ok"), fillFormViaPlaywright: vi.fn(async (_opts?: unknown) => {}), getConsoleMessagesViaPlaywright: vi.fn(async () => []), + getNetworkRequestsViaPlaywright: vi.fn(async () => ({ requests: [] })), + getPageErrorsViaPlaywright: vi.fn(async () => ({ errors: [] })), hoverViaPlaywright: vi.fn(async (_opts?: unknown) => {}), scrollIntoViewViaPlaywright: vi.fn(async (_opts?: unknown) => {}), navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })), @@ -181,7 +197,9 @@ const pwMocks = vi.hoisted(() => ({ refs: { e1: { role: "button", name: "Role" } }, stats: { lines: 1, chars: 24, refs: 1, interactive: 1 }, })), + storageGetViaPlaywright: vi.fn(async () => ({ values: {} })), storeAriaSnapshotRefsViaPlaywright: vi.fn(async () => {}), + traceStartViaPlaywright: vi.fn(async () => {}), traceStopViaPlaywright: vi.fn(async () => {}), takeScreenshotViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("png"), @@ -393,13 +411,13 @@ vi.mock("../config/config.js", async () => { evaluateEnabled: state.cfgEvaluateEnabled, color: "#FF4500", attachOnly: state.cfgAttachOnly, + ssrfPolicy: state.cfgSsrfPolicy ?? { dangerouslyAllowPrivateNetwork: true }, headless: true, defaultProfile: state.cfgDefaultProfile, profiles: Object.keys(state.cfgProfiles).length > 0 ? state.cfgProfiles : defaultProfilesForState(state.testPort), - ssrfPolicy: { dangerouslyAllowPrivateNetwork: true }, }, }; }; @@ -513,8 +531,10 @@ export async function resetBrowserControlServerTestContext(): Promise { state.reachable = false; state.cfgAttachOnly = false; state.cfgEvaluateEnabled = true; + state.cfgSsrfPolicy = undefined; state.cfgDefaultProfile = "openclaw"; state.cfgProfiles = defaultProfilesForState(state.testPort); + state.tabUrl = "https://example.com"; mockClearAll(pwMocks); mockClearAll(cdpMocks); @@ -580,7 +600,7 @@ export function installBrowserControlServerHooks() { { id: "abcd1234", title: "Tab", - url: "https://example.com", + url: state.tabUrl, webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234", type: "page", }, From 3fb8c405eda48af76bb8b8d7d3f8e5b0ee3c049c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 13:07:05 -0700 Subject: [PATCH 029/107] fix(update): finish post-core package updates --- src/cli/update-cli.test.ts | 27 ++++++++ src/cli/update-cli/update-command.ts | 92 +++++++++++++++++++++++----- 2 files changed, 102 insertions(+), 17 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 59dfa45883f..14c7a949df6 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -623,6 +623,33 @@ describe("update-cli", () => { expect(runDaemonRestart).not.toHaveBeenCalled(); }); + it("finishes package updates when the post-core process writes a result but keeps handles open", async () => { + setupUpdatedRootRefresh(); + const kill = vi.fn(); + spawn.mockImplementationOnce((_command: unknown, _argv: unknown, options: unknown) => { + const resultPath = (options as { env?: NodeJS.ProcessEnv }).env + ?.OPENCLAW_UPDATE_POST_CORE_RESULT_PATH; + if (!resultPath) { + throw new Error("missing post-core result path"); + } + queueMicrotask(() => { + void fs.writeFile(resultPath, `${JSON.stringify({ status: "ok" })}\n`, "utf-8"); + }); + const child = new EventEmitter() as EventEmitter & { + kill: typeof kill; + once: EventEmitter["once"]; + }; + child.kill = kill; + return child; + }); + + await updateCommand({ yes: true, restart: false }); + + expect(kill).toHaveBeenCalledTimes(1); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); + }); + it("does not carry gateway service markers into the post-core update process", async () => { setupUpdatedRootRefresh(); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 758438c7992..29aa81745a5 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -110,6 +110,7 @@ const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE"; const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL"; const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL"; const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH"; +const POST_CORE_UPDATE_RESULT_POLL_MS = 100; const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV = "OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE"; const SERVICE_REFRESH_PATH_ENV_KEYS = [ @@ -1608,6 +1609,25 @@ async function readPostCorePluginUpdateResultFile( return undefined; } +function stopPostCoreUpdateChild(child: ChildProcess): void { + if (process.platform === "win32" && child.pid) { + try { + const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + killer.once("error", () => { + child.kill(); + }); + return; + } catch { + child.kill(); + return; + } + } + child.kill(); +} + async function continuePostCoreUpdateInFreshProcess(params: { root: string; channel: "stable" | "beta" | "dev"; @@ -1632,11 +1652,8 @@ async function continuePostCoreUpdateInFreshProcess(params: { if (params.opts.timeout) { argv.push("--timeout", params.opts.timeout); } - const resultDir = - params.opts.json === true - ? await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-post-core-")) - : null; - const resultPath = resultDir ? path.join(resultDir, "plugins.json") : null; + const resultDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-post-core-")); + const resultPath = path.join(resultDir, "plugins.json"); try { const child = spawn(resolveNodeRunner(), argv, { @@ -1648,24 +1665,65 @@ async function continuePostCoreUpdateInFreshProcess(params: { ...(params.requestedChannel ? { [POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]: params.requestedChannel } : {}), - ...(resultPath ? { [POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath } : {}), + [POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath, }, }); - const exitCode = await new Promise((resolve, reject) => { - child.once("error", reject); + const childResult = await new Promise< + | { kind: "exit"; exitCode: number } + | { kind: "plugin-update"; pluginUpdate: PostCorePluginUpdateResult } + >((resolve, reject) => { + let settled = false; + const finish = ( + result: + | { kind: "exit"; exitCode: number } + | { kind: "plugin-update"; pluginUpdate: PostCorePluginUpdateResult }, + ) => { + if (settled) { + return; + } + settled = true; + clearInterval(resultPoll); + resolve(result); + }; + const resultPoll = setInterval(() => { + void readPostCorePluginUpdateResultFile(resultPath) + .then((pluginUpdate) => { + if (!pluginUpdate) { + return; + } + stopPostCoreUpdateChild(child); + finish({ kind: "plugin-update", pluginUpdate }); + }) + .catch(() => undefined); + }, POST_CORE_UPDATE_RESULT_POLL_MS); + child.once("error", (error) => { + if (settled) { + return; + } + settled = true; + clearInterval(resultPoll); + reject(error); + }); child.once("exit", (code, signal) => { + if (settled) { + return; + } if (signal) { + settled = true; + clearInterval(resultPoll); reject(new Error(`post-update process terminated by signal ${signal}`)); return; } - resolve(code ?? 1); + finish({ kind: "exit", exitCode: code ?? 1 }); }); }); - const pluginUpdate = resultPath - ? await readPostCorePluginUpdateResultFile(resultPath) - : undefined; + const pluginUpdate = + childResult.kind === "plugin-update" + ? childResult.pluginUpdate + : await readPostCorePluginUpdateResultFile(resultPath); + const exitCode = childResult.kind === "exit" ? childResult.exitCode : 0; if (exitCode !== 0) { if (pluginUpdate) { return { resumed: true, pluginUpdate }; @@ -1675,9 +1733,7 @@ async function continuePostCoreUpdateInFreshProcess(params: { } return { resumed: true, ...(pluginUpdate ? { pluginUpdate } : {}) }; } finally { - if (resultDir) { - await fs.rm(resultDir, { recursive: true, force: true }).catch(() => undefined); - } + await fs.rm(resultDir, { recursive: true, force: true }).catch(() => undefined); } } @@ -1752,11 +1808,13 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { opts, timeoutMs: updateStepTimeoutMs, }); - if (opts.json) { + if (process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) { await writePostCorePluginUpdateResultFile( process.env[POST_CORE_UPDATE_RESULT_PATH_ENV], pluginUpdate, ); + } + if (opts.json) { if (!process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) { const result: UpdateRunResult = { status: pluginUpdate.status === "error" ? "error" : "ok", From 3af3fcfebe908e29c74fd7e3c1a07e17080a08d5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 13:16:02 -0700 Subject: [PATCH 030/107] fix(update): exit post-core package child --- src/cli/update-cli.test.ts | 25 +++++++++++++++++++++++++ src/cli/update-cli/update-command.ts | 4 ++++ 2 files changed, 29 insertions(+) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 14c7a949df6..480b1b64466 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -782,6 +782,31 @@ describe("update-cli", () => { expect(spawn).not.toHaveBeenCalled(); }); + it("post-core resume children exit after writing a plugin update result", async () => { + const resultDir = createCaseDir("openclaw-post-core-result"); + const resultPath = path.join(resultDir, "plugins.json"); + await fs.mkdir(resultDir, { recursive: true }); + + await withEnvAsync( + { + OPENCLAW_UPDATE_POST_CORE: "1", + OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable", + OPENCLAW_UPDATE_POST_CORE_RESULT_PATH: resultPath, + }, + async () => { + await updateCommand({ restart: false }); + }, + ); + + const result = JSON.parse(await fs.readFile(resultPath, "utf-8")) as { + status?: string; + }; + expect(result.status).toBe("ok"); + expect(defaultRuntime.exit).toHaveBeenCalledWith(0); + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + }); + it("post-core resume mode persists the requested update channel with the updated process", async () => { vi.mocked(readConfigFileSnapshot).mockResolvedValue({ ...baseSnapshot, diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 29aa81745a5..774eab7bf61 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1831,6 +1831,10 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { defaultRuntime.exit(1); return; } + if (process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) { + defaultRuntime.exit(0); + return; + } return; } From 4fab34a63b177d875aaa2b637d7d1f18c823478e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 13:19:31 -0700 Subject: [PATCH 031/107] docs(changelog): note update and slack fixes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 259fb809ed5..5e4681389d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ Docs: https://docs.openclaw.ai ### Fixes - Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob. +- CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc. +- Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc. - fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987. - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. - Active Memory: send a bounded latest-message search query to the recall worker so channel/runtime metadata does not become the memory search string. Fixes #65309. Thanks @joeykrug, @westley3601, @pimenov, and @tasi333. From 982d123b80525067a02ff1c26e0f04dd47697614 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Mon, 4 May 2026 14:33:18 -0600 Subject: [PATCH 032/107] Harden Windows command wrapper resolution (#77472) * Harden Windows command wrapper resolution * clawsweeper: route Windows cmd.exe wrapper through getWindowsInstallRoots Replace the local SystemRoot/windir/SYSTEMROOT/WINDIR scan in resolveTrustedWindowsCmdExe with the shared getWindowsInstallRoots() resolver from src/infra/windows-install-roots.ts. The shared resolver already rejects UNC paths, root-relative values, semicolon-delimited path-lists, and missing-drive-letter roots, and prefers registry-derived roots over env, so the wrapper-launch trust boundary now matches the existing Windows install-root boundary on main. Tests: - _resetWindowsInstallRootsForTests in beforeEach so cached roots track per-test process.env mutations - expectedTrustedCmdExe helper now joins the resolved systemRoot, so the expected wrapper executable matches the production resolver on Linux CI (where it falls back to DEFAULT_WINDOWS_SYSTEM_ROOT) - new "rejects unsafe Windows root values" test covers UNC, semicolon-delimited path-list, root-relative, and bare-relative SystemRoot inputs * Add CHANGELOG entry for #77472 Windows command wrapper hardening * clawsweeper: stub registry probe in Windows wrapper tests On real Windows CI runners getWindowsInstallRoots() reads the canonical SystemRoot from the registry (e.g. C:\WINDOWS) before falling back to process.env, which shadowed the env-only setup in the ComSpec-poisoning and unsafe-root tests and produced casing mismatches like "C:\WINDOWS\System32\cmd.exe" vs the expected "C:\Windows\...". Pass a queryRegistryValue stub returning null in beforeEach (and inside the unsafe-root loop) so install-root resolution is fully driven by the test's process.env setup on every platform. * clawsweeper: overwrite WINDIR alongside SystemRoot in unsafe-root test Real Windows runners did not honor `delete process.env.windir`, so the unsafe-root iteration's WINDIR fallback still resolved to the canonical `C:\WINDOWS` and produced a casing mismatch against the expected default `C:\Windows\System32\cmd.exe`. Set both `SystemRoot` and `WINDIR` to the unsafe payload so every install-root env source is rejected by `normalizeWindowsInstallRoot` and the resolver falls through to `DEFAULT_WINDOWS_SYSTEM_ROOT`. --- CHANGELOG.md | 1 + src/process/exec.ts | 10 ++- src/process/exec.windows.test.ts | 102 +++++++++++++++++++++++++++++-- 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e4681389d5..5abe53398a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -215,6 +215,7 @@ Docs: https://docs.openclaw.ai - QQBot: preserve the framework command authorization decision when converting framework command contexts into engine slash command contexts, so downstream slash handlers see `commandAuthorized` matching the channel's resolved `isAuthorizedSender` instead of a hardcoded `true`. (#77453) Thanks @drobison00. - Security/Windows: block `LOCALAPPDATA` from workspace `.env` and resolve Windows update-flow portable Git path prepends from the trusted process-local `LOCALAPPDATA` only, so workspace-supplied values cannot redirect `git` discovery during `openclaw update`. (#77470) Thanks @drobison00. - Browser/SSRF: enforce the existing current-tab URL navigation policy before tab-scoped debug, export, and read routes (console, page errors, network requests, trace start/stop, response body, screenshot, snapshot, storage, etc.) collect from an already-selected tab, so blocked tabs return a policy error instead of being read first and redacted only at response time. (#75731) Thanks @eleqtrizit. +- Security/Windows: route the `.cmd`/`.bat` process wrapper through the shared Windows install-root resolver instead of `process.env.ComSpec`, so workspace dotenv-blocked `SystemRoot`/`WINDIR` overrides and unsafe values like UNC paths or path-lists cannot redirect `cmd.exe` selection on Windows. (#77472) Thanks @drobison00. ## 2026.5.3-1 diff --git a/src/process/exec.ts b/src/process/exec.ts index 6907b63cc2e..d0bd312f113 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -9,6 +9,7 @@ import { decodeWindowsOutputBuffer, resolveWindowsConsoleEncoding, } from "../infra/windows-encoding.js"; +import { getWindowsInstallRoots } from "../infra/windows-install-roots.js"; import { logDebug, logError } from "../logger.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { resolveCommandStdio } from "./spawn-utils.js"; @@ -45,6 +46,13 @@ function buildCmdExeCommandLine(resolvedCommand: string, args: string[]): string return [escapeForCmdExe(resolvedCommand), ...args.map(escapeForCmdExe)].join(" "); } +function resolveTrustedWindowsCmdExe(): string { + if (process.platform !== "win32") { + return "cmd.exe"; + } + return path.win32.join(getWindowsInstallRoots().systemRoot, "System32", "cmd.exe"); +} + /** * On Windows, Node 18.20.2+ (CVE-2024-27980) rejects spawning .cmd/.bat directly * without shell, causing EINVAL. Resolve npm/npx to node + cli script so we @@ -107,7 +115,7 @@ function resolveChildProcessInvocation(params: { const useCmdWrapper = isWindowsBatchCommand(resolvedCommand); return { - command: useCmdWrapper ? (process.env.ComSpec ?? "cmd.exe") : resolvedCommand, + command: useCmdWrapper ? resolveTrustedWindowsCmdExe() : resolvedCommand, args: useCmdWrapper ? ["/d", "/s", "/c", buildCmdExeCommandLine(resolvedCommand, finalArgv.slice(1))] : finalArgv.slice(1), diff --git a/src/process/exec.windows.test.ts b/src/process/exec.windows.test.ts index 9be869c0bb8..7feccf18e81 100644 --- a/src/process/exec.windows.test.ts +++ b/src/process/exec.windows.test.ts @@ -3,6 +3,10 @@ import { EventEmitter } from "node:events"; import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + _resetWindowsInstallRootsForTests, + getWindowsInstallRoots, +} from "../infra/windows-install-roots.js"; const { spawnMock, spawnSyncMock, execFileMock, execFilePromisifyMock } = vi.hoisted(() => { const execFilePromisifyMock = vi.fn(); @@ -101,6 +105,10 @@ function expectCmdWrappedInvocation(params: { expect(params.captured[2].windowsVerbatimArguments).toBe(true); } +function expectedTrustedCmdExe(): string { + return path.win32.join(getWindowsInstallRoots().systemRoot, "System32", "cmd.exe"); +} + async function expectShimmedWindowsCommandWithoutExitCodeSucceeds(params?: { killed?: boolean }) { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); const child = createMockChild({ @@ -127,6 +135,10 @@ describe("windows command wrapper behavior", () => { }); beforeEach(() => { + // Stub the registry probe so install-root resolution is fully driven by + // process.env in tests; on real Windows runners the registry returns the + // canonical SystemRoot and would shadow the test's env setup. + _resetWindowsInstallRootsForTests({ queryRegistryValue: () => null }); spawnMock.mockReset(); spawnSyncMock.mockReset(); spawnSyncMock.mockReturnValue({ stdout: "Active code page: 936", stderr: "" }); @@ -157,7 +169,7 @@ describe("windows command wrapper behavior", () => { it("wraps .cmd commands via cmd.exe in runCommandWithTimeout", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - const expectedComSpec = process.env.ComSpec ?? "cmd.exe"; + const expectedComSpec = expectedTrustedCmdExe(); spawnMock.mockImplementation( (_command: string, _args: string[], _options: Record) => createMockChild(), @@ -173,9 +185,91 @@ describe("windows command wrapper behavior", () => { } }); + it("ignores ComSpec when selecting the Windows command wrapper", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const previousComSpec = process.env.ComSpec; + const previousSystemRoot = process.env.SystemRoot; + process.env.ComSpec = "C:\\workspace\\evil\\cmd.exe"; + process.env.SystemRoot = "C:\\Windows"; + + spawnMock.mockImplementation( + (_command: string, _args: string[], _options: Record) => createMockChild(), + ); + + try { + const result = await runCommandWithTimeout(["pnpm", "--version"], { timeoutMs: 1000 }); + expect(result.code).toBe(0); + const captured = spawnMock.mock.calls[0] as SpawnCall | undefined; + expectCmdWrappedInvocation({ + captured, + expectedComSpec: path.win32.join("C:\\Windows", "System32", "cmd.exe"), + }); + } finally { + if (previousComSpec === undefined) { + delete process.env.ComSpec; + } else { + process.env.ComSpec = previousComSpec; + } + if (previousSystemRoot === undefined) { + delete process.env.SystemRoot; + } else { + process.env.SystemRoot = previousSystemRoot; + } + platformSpy.mockRestore(); + } + }); + + it("rejects unsafe Windows root values when selecting the command wrapper", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const previousSystemRoot = process.env.SystemRoot; + const previousWindir = process.env.WINDIR; + + spawnMock.mockImplementation( + (_command: string, _args: string[], _options: Record) => createMockChild(), + ); + + try { + for (const unsafeRoot of [ + "\\\\evil\\share", + "C:\\Windows;C:\\evil", + "\\Windows", + "relative\\path", + ]) { + _resetWindowsInstallRootsForTests({ queryRegistryValue: () => null }); + // Set every install-root env source to the unsafe value so the + // resolver rejects each one and falls through to the safe default. + // Deleting WINDIR here is unreliable on real Windows runners, so + // overwrite it with the same rejected payload. + process.env.SystemRoot = unsafeRoot; + process.env.WINDIR = unsafeRoot; + spawnMock.mockClear(); + + const result = await runCommandWithTimeout(["pnpm", "--version"], { timeoutMs: 1000 }); + expect(result.code).toBe(0); + const captured = spawnMock.mock.calls[0] as SpawnCall | undefined; + expectCmdWrappedInvocation({ + captured, + expectedComSpec: path.win32.join("C:\\Windows", "System32", "cmd.exe"), + }); + } + } finally { + if (previousSystemRoot === undefined) { + delete process.env.SystemRoot; + } else { + process.env.SystemRoot = previousSystemRoot; + } + if (previousWindir === undefined) { + delete process.env.WINDIR; + } else { + process.env.WINDIR = previousWindir; + } + platformSpy.mockRestore(); + } + }); + it("wraps corepack.cmd via cmd.exe in runCommandWithTimeout", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - const expectedComSpec = process.env.ComSpec ?? "cmd.exe"; + const expectedComSpec = expectedTrustedCmdExe(); spawnMock.mockImplementation( (_command: string, _args: string[], _options: Record) => createMockChild(), @@ -243,7 +337,7 @@ describe("windows command wrapper behavior", () => { it("falls back to npm.cmd when npm-cli.js is unavailable", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); const existsSpy = vi.spyOn(fs, "existsSync").mockReturnValue(false); - const expectedComSpec = process.env.ComSpec ?? "cmd.exe"; + const expectedComSpec = expectedTrustedCmdExe(); spawnMock.mockImplementation( (_command: string, _args: string[], _options: Record) => createMockChild(), @@ -297,7 +391,7 @@ describe("windows command wrapper behavior", () => { it("uses cmd.exe wrapper with windowsVerbatimArguments in runExec for .cmd shims", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - const expectedComSpec = process.env.ComSpec ?? "cmd.exe"; + const expectedComSpec = expectedTrustedCmdExe(); execFileMock.mockImplementation( ( From 021373a4541e53cc5a29e7cd672e578a96fe3276 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 21:34:15 +0100 Subject: [PATCH 033/107] ci(release): recover Windows packaged update no-restart timeout --- scripts/openclaw-cross-os-release-checks.ts | 2 +- test/scripts/openclaw-cross-os-release-checks.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index 668291cdaf9..866b656f890 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -1397,7 +1397,7 @@ export function isRecoverableWindowsPackagedUpgradeTimeoutError( const message = error instanceof Error ? error.message : String(error); return ( /\bCommand timed out:/u.test(message) && - /[/\\]openclaw\.mjs update --tag http:\/\/127\.0\.0\.1:\d+\/openclaw[^/\s]*\.tgz --yes --json --timeout \d+/u.test( + /[/\\]openclaw\.mjs update --tag http:\/\/127\.0\.0\.1:\d+\/openclaw[^/\s]*\.tgz --yes --json(?: --no-restart)? --timeout \d+/u.test( message, ) ); diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index c469743dfca..a334d09bd06 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -747,7 +747,7 @@ describe("scripts/openclaw-cross-os-release-checks", () => { it("recognizes the shipped Windows updater packaged-upgrade timeout", () => { const error = new Error( - "Command timed out: C:\\hostedtoolcache\\windows\\node\\24.15.0\\x64\\node.exe C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\openclaw-upgrade-q9DsA7\\prefix\\node_modules\\openclaw\\openclaw.mjs update --tag http://127.0.0.1:49951/openclaw-2026.5.4-beta.1.tgz --yes --json --timeout 1500", + "Command timed out: C:\\hostedtoolcache\\windows\\node\\24.15.0\\x64\\node.exe C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\openclaw-upgrade-q9DsA7\\prefix\\node_modules\\openclaw\\openclaw.mjs update --tag http://127.0.0.1:49951/openclaw-2026.5.4-beta.1.tgz --yes --json --no-restart --timeout 1500", ); expect(isRecoverableWindowsPackagedUpgradeTimeoutError(error, "win32")).toBe(true); From 06056926a099dfcd9758205e30c536aa447ca887 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 21:39:23 +0100 Subject: [PATCH 034/107] fix(plugins): trust official diagnostics installs (#77516) --- CHANGELOG.md | 1 + src/plugins/loader-records.ts | 2 + src/plugins/loader.ts | 4 ++ src/plugins/manifest-registry.test.ts | 83 +++++++++++++++++++++++++++ src/plugins/manifest-registry.ts | 79 ++++++++++++++++++++++++- src/plugins/registry-types.ts | 2 + src/plugins/registry.ts | 1 + src/plugins/services.test.ts | 27 ++++++++- src/plugins/services.ts | 10 ++-- 9 files changed, 203 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5abe53398a0..fe67d116e21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. - Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob. - CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc. - Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc. diff --git a/src/plugins/loader-records.ts b/src/plugins/loader-records.ts index 373981ec8bd..26ea17c217e 100644 --- a/src/plugins/loader-records.ts +++ b/src/plugins/loader-records.ts @@ -19,6 +19,7 @@ export function createPluginRecord(params: { rootDir?: string; origin: PluginRecord["origin"]; workspaceDir?: string; + trustedOfficialInstall?: boolean; enabled: boolean; compat?: readonly PluginCompatCode[]; activationState?: PluginActivationState; @@ -41,6 +42,7 @@ export function createPluginRecord(params: { rootDir: params.rootDir, origin: params.origin, workspaceDir: params.workspaceDir, + trustedOfficialInstall: params.trustedOfficialInstall, enabled: params.enabled, compat: params.compat, explicitlyEnabled: params.activationState?.explicitlyEnabled, diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 714d837b8bc..d3c4efff640 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1739,6 +1739,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, + trustedOfficialInstall: manifestRecord.trustedOfficialInstall, enabled: false, compat: collectPluginManifestCompatCodes(manifestRecord), activationState, @@ -1777,6 +1778,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, + trustedOfficialInstall: manifestRecord.trustedOfficialInstall, enabled: enableState.enabled, compat: collectPluginManifestCompatCodes(manifestRecord), activationState, @@ -2563,6 +2565,7 @@ export async function loadOpenClawPluginCliRegistry( rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, + trustedOfficialInstall: manifestRecord.trustedOfficialInstall, enabled: false, compat: collectPluginManifestCompatCodes(manifestRecord), activationState, @@ -2601,6 +2604,7 @@ export async function loadOpenClawPluginCliRegistry( rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, + trustedOfficialInstall: manifestRecord.trustedOfficialInstall, enabled: enableState.enabled, compat: collectPluginManifestCompatCodes(manifestRecord), activationState, diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index a33fbe57eaf..e3d7d44ae3e 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -534,6 +534,89 @@ describe("loadPluginManifestRegistry", () => { expect(registry.plugins[0]?.origin).toBe("global"); }); + it("marks official installed npm globals as trusted official installs", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "diagnostics-prometheus", configSchema: { type: "object" } }); + + const registry = loadPluginManifestRegistry({ + installRecords: { + "diagnostics-prometheus": { + source: "npm", + installPath: dir, + resolvedName: "@openclaw/diagnostics-prometheus", + resolvedVersion: "2026.5.3", + }, + }, + candidates: [ + createPluginCandidate({ + idHint: "diagnostics-prometheus", + rootDir: dir, + packageName: "@openclaw/diagnostics-prometheus", + origin: "global", + }), + ], + }); + + expect(registry.plugins[0]?.trustedOfficialInstall).toBe(true); + }); + + it("preserves trusted official installs when a config path selects the installed package", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "diagnostics-prometheus", configSchema: { type: "object" } }); + + const registry = loadPluginManifestRegistry({ + installRecords: { + "diagnostics-prometheus": { + source: "npm", + installPath: dir, + resolvedName: "@openclaw/diagnostics-prometheus", + resolvedVersion: "2026.5.3", + }, + }, + candidates: [ + createPluginCandidate({ + idHint: "diagnostics-prometheus", + rootDir: dir, + packageName: "@openclaw/diagnostics-prometheus", + origin: "global", + }), + createPluginCandidate({ + idHint: "diagnostics-prometheus", + rootDir: dir, + packageName: "@openclaw/diagnostics-prometheus", + origin: "config", + }), + ], + }); + + expect(registry.plugins).toHaveLength(1); + expect(registry.plugins[0]).toEqual( + expect.objectContaining({ + origin: "config", + trustedOfficialInstall: true, + }), + ); + }); + + it("does not trust unrecorded globals that spoof official ids", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "diagnostics-prometheus", configSchema: { type: "object" } }); + + const registry = loadPluginManifestRegistry({ + installRecords: {}, + candidates: [ + createPluginCandidate({ + idHint: "diagnostics-prometheus", + rootDir: dir, + packageName: "@openclaw/diagnostics-prometheus", + origin: "global", + }), + ], + }); + + expect(registry.plugins[0]?.trustedOfficialInstall).toBeUndefined(); + }); + it("preserves provider auth env metadata from plugin manifests", () => { const dir = makeTempDir(); writeManifest(dir, { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 8cb00aabdab..08aadabc5ee 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -46,6 +46,8 @@ import { checkMinHostVersion } from "./min-host-version.js"; import { getOfficialExternalPluginCatalogEntryForPackage, getOfficialExternalPluginCatalogManifest, + resolveOfficialExternalPluginId, + resolveOfficialExternalPluginInstall, } from "./official-external-plugin-catalog.js"; import { isPathInside, safeRealpathSync } from "./path-safety.js"; import type { PluginKind } from "./plugin-kind.types.js"; @@ -140,6 +142,7 @@ export type PluginManifestRecord = { packageOptionalDependencies?: PluginDependencySpecMap; packageChannel?: PluginPackageChannel; packageInstall?: PluginPackageInstall; + trustedOfficialInstall?: boolean; qaRunners?: PluginManifestQaRunner[]; skills: string[]; settingsFiles?: string[]; @@ -365,6 +368,7 @@ function buildRecord(params: { schemaCacheKey?: string; configSchema?: Record; bundledChannelConfigCollector?: BundledChannelConfigCollector; + trustedOfficialInstall?: boolean; }): PluginManifestRecord { const manifestChannelConfigs = params.candidate.origin === "bundled" && params.bundledChannelConfigCollector @@ -434,6 +438,7 @@ function buildRecord(params: { packageOptionalDependencies: params.candidate.packageOptionalDependencies, packageChannel: params.candidate.packageManifest?.channel, packageInstall: params.candidate.packageManifest?.install, + trustedOfficialInstall: params.trustedOfficialInstall === true ? true : undefined, qaRunners: params.manifest.qaRunners, skills: params.manifest.skills ?? [], settingsFiles: [], @@ -634,7 +639,7 @@ function matchesInstalledPluginRecord(params: { env: NodeJS.ProcessEnv; installRecords: Record; }): boolean { - if (params.candidate.origin !== "global") { + if (params.candidate.origin !== "global" && params.candidate.origin !== "config") { return false; } const record = params.installRecords[params.pluginId]; @@ -653,6 +658,72 @@ function matchesInstalledPluginRecord(params: { }); } +function npmSpecMatchesPackage(value: string | undefined, packageName: string): boolean { + const normalized = value?.trim(); + if (!normalized) { + return false; + } + if (normalized === packageName) { + return true; + } + return normalized.startsWith(`${packageName}@`); +} + +function isTrustedOfficialPluginInstall(params: { + pluginId: string; + candidate: PluginCandidate; + env: NodeJS.ProcessEnv; + installRecords: Record; +}): boolean { + if ( + (params.candidate.origin !== "global" && params.candidate.origin !== "config") || + !matchesInstalledPluginRecord({ + pluginId: params.pluginId, + candidate: params.candidate, + env: params.env, + installRecords: params.installRecords, + }) + ) { + return false; + } + const packageName = params.candidate.packageName?.trim(); + if (!packageName) { + return false; + } + const catalogEntry = getOfficialExternalPluginCatalogEntryForPackage(packageName); + if (!catalogEntry || resolveOfficialExternalPluginId(catalogEntry) !== params.pluginId) { + return false; + } + const officialInstall = resolveOfficialExternalPluginInstall(catalogEntry); + const installRecord = params.installRecords[params.pluginId]; + if (!installRecord) { + return false; + } + if ( + installRecord.source === "npm" && + officialInstall?.npmSpec === packageName && + [ + installRecord.resolvedName, + installRecord.spec, + installRecord.resolvedSpec, + params.candidate.packageName, + ].some((value) => npmSpecMatchesPackage(value, packageName)) + ) { + return true; + } + if ( + installRecord.source === "clawhub" && + officialInstall?.clawhubSpec && + installRecord.clawhubChannel === "official" && + (installRecord.clawhubPackage === packageName || + installRecord.spec === officialInstall.clawhubSpec || + installRecord.resolvedSpec === officialInstall.clawhubSpec) + ) { + return true; + } + return false; +} + function resolveDuplicatePrecedenceRank(params: { pluginId: string; candidate: PluginCandidate; @@ -858,6 +929,12 @@ export function loadPluginManifestRegistry( manifestPath: manifestRes.manifestPath, schemaCacheKey, configSchema, + trustedOfficialInstall: isTrustedOfficialPluginInstall({ + pluginId: manifest.id, + candidate, + env, + installRecords: getInstallRecords(), + }), ...(params.bundledChannelConfigCollector ? { bundledChannelConfigCollector: params.bundledChannelConfigCollector } : {}), diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index b8820c72481..d4eff1a0176 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -205,6 +205,7 @@ export type PluginServiceRegistration = { service: OpenClawPluginService; source: string; origin: PluginOrigin; + trustedOfficialInstall?: boolean; rootDir?: string; }; @@ -337,6 +338,7 @@ export type PluginRecord = { rootDir?: string; origin: PluginOrigin; workspaceDir?: string; + trustedOfficialInstall?: boolean; enabled: boolean; explicitlyEnabled?: boolean; activated?: boolean; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 497e1d37661..fd734365a4d 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -1452,6 +1452,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { service, source: record.source, origin: record.origin, + trustedOfficialInstall: record.trustedOfficialInstall, rootDir: record.rootDir, }); }; diff --git a/src/plugins/services.test.ts b/src/plugins/services.test.ts index f6b8fbfc016..6615eae0acb 100644 --- a/src/plugins/services.test.ts +++ b/src/plugins/services.test.ts @@ -22,6 +22,7 @@ function createRegistry( services: OpenClawPluginService[], pluginId = "plugin:test", origin: PluginOrigin = "workspace", + trustedOfficialInstall = false, ) { const registry = createEmptyPluginRegistry(); registry.services = services.map((service) => ({ @@ -29,6 +30,7 @@ function createRegistry( service, source: "test", origin, + ...(trustedOfficialInstall ? { trustedOfficialInstall } : {}), rootDir: "/plugins/test-plugin", })) as typeof registry.services; return registry; @@ -181,7 +183,7 @@ describe("startPluginServices", () => { expect(stopThrows).toHaveBeenCalledOnce(); }); - it("grants internal diagnostics only to bundled diagnostics exporter services", async () => { + it("grants internal diagnostics only to trusted diagnostics exporter services", async () => { const contexts: OpenClawPluginServiceContext[] = []; const diagnosticsService = createTrackingService("diagnostics-otel", { contexts }); await startPluginServices({ @@ -204,6 +206,18 @@ describe("startPluginServices", () => { expect(prometheusContexts[0]?.internalDiagnostics?.onEvent).toBeTypeOf("function"); expect(prometheusContexts[0]?.internalDiagnostics?.emit).toBeTypeOf("function"); + const officialInstallContexts: OpenClawPluginServiceContext[] = []; + const officialInstallService = createTrackingService("diagnostics-prometheus", { + contexts: officialInstallContexts, + }); + await startPluginServices({ + registry: createRegistry([officialInstallService], "diagnostics-prometheus", "global", true), + config: createServiceConfig(), + }); + + expect(officialInstallContexts[0]?.internalDiagnostics?.onEvent).toBeTypeOf("function"); + expect(officialInstallContexts[0]?.internalDiagnostics?.emit).toBeTypeOf("function"); + const untrustedContexts: OpenClawPluginServiceContext[] = []; const untrustedService = createTrackingService("diagnostics-otel", { contexts: untrustedContexts, @@ -214,5 +228,16 @@ describe("startPluginServices", () => { }); expect(untrustedContexts[0]?.internalDiagnostics).toBeUndefined(); + + const spoofedContexts: OpenClawPluginServiceContext[] = []; + const spoofedService = createTrackingService("diagnostics-prometheus", { + contexts: spoofedContexts, + }); + await startPluginServices({ + registry: createRegistry([spoofedService], "not-diagnostics-prometheus", "global", true), + config: createServiceConfig(), + }); + + expect(spoofedContexts[0]?.internalDiagnostics).toBeUndefined(); }); }); diff --git a/src/plugins/services.ts b/src/plugins/services.ts index abbc0e9462d..a4179bc3806 100644 --- a/src/plugins/services.ts +++ b/src/plugins/services.ts @@ -24,11 +24,13 @@ function createServiceContext(params: { workspaceDir?: string; service?: PluginServiceRegistration; }): OpenClawPluginServiceContext { + const isDiagnosticsExporter = + params.service?.pluginId === params.service?.service.id && + (params.service?.service.id === "diagnostics-otel" || + params.service?.service.id === "diagnostics-prometheus"); const grantsInternalDiagnostics = - params.service?.origin === "bundled" && - params.service.pluginId === params.service.service.id && - (params.service.service.id === "diagnostics-otel" || - params.service.service.id === "diagnostics-prometheus"); + isDiagnosticsExporter && + (params.service?.origin === "bundled" || params.service?.trustedOfficialInstall === true); return { config: params.config, From 7b86481c9467003106893005ee85106ca0e1b7c8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 13:46:11 -0700 Subject: [PATCH 035/107] fix(plugins): trust chat catalog installs --- CHANGELOG.md | 1 + .../reply/commands-plugins.install.test.ts | 60 +++++++++++++++++++ src/auto-reply/reply/commands-plugins.ts | 42 +++++++++++++ 3 files changed, 103 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe67d116e21..7b0b6aa4315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai - Plugins/discovery: ignore managed npm plugin packages that only expose TypeScript source entries without compiled runtime output, so stale/broken installs cannot hide a working bundled or reinstallable channel plugin during setup. Thanks @vincentkoc. - CLI/update: treat OpenClaw stable correction versions like `2026.5.3-1` as newer than their base stable release, so package updates no longer ask for downgrade confirmation. Thanks @vincentkoc. - Plugins/install: suppress dangerous-pattern scanner warnings for trusted official OpenClaw npm installs, so installing `@openclaw/discord` no longer prints credential-harvesting warnings for the official package. Thanks @vincentkoc. +- Plugins/commands: suppress dangerous-pattern scanner warnings for trusted catalog npm installs from owner-gated `/plugins install` commands, so chat-driven installs match the CLI install trust path. Thanks @vincentkoc. - Plugins/release: make the published npm runtime verifier reject blank `openclaw.runtimeExtensions` entries instead of treating them as absent and passing via inferred outputs. Thanks @vincentkoc. - Plugins/security: ignore inline and block comments when matching source-rule context in plugin install scans, so comment-only `fetch`/`post` references near environment defaults do not block clean plugins. Thanks @vincentkoc. - Doctor/plugins: remove stale managed install records for bundled plugins even when the bundled plugin is not explicitly configured, so doctor cleanup cannot leave orphaned install metadata behind. Thanks @vincentkoc. diff --git a/src/auto-reply/reply/commands-plugins.install.test.ts b/src/auto-reply/reply/commands-plugins.install.test.ts index e35311586a2..e979176293e 100644 --- a/src/auto-reply/reply/commands-plugins.install.test.ts +++ b/src/auto-reply/reply/commands-plugins.install.test.ts @@ -7,11 +7,13 @@ import { handlePluginsCommand } from "./commands-plugins.js"; import { buildPluginsCommandParams } from "./commands.test-harness.js"; const { + installPluginFromNpmSpecMock, installPluginFromPathMock, installPluginFromClawHubMock, installPluginFromGitSpecMock, persistPluginInstallMock, } = vi.hoisted(() => ({ + installPluginFromNpmSpecMock: vi.fn(), installPluginFromPathMock: vi.fn(), installPluginFromClawHubMock: vi.fn(), installPluginFromGitSpecMock: vi.fn(), @@ -24,6 +26,7 @@ vi.mock("../../plugins/install.js", async () => { ); return { ...actual, + installPluginFromNpmSpec: installPluginFromNpmSpecMock, installPluginFromPath: installPluginFromPathMock, }; }); @@ -64,6 +67,7 @@ function buildPluginsParams(commandBodyNormalized: string, workspaceDir: string) describe("handleCommands /plugins install", () => { afterEach(async () => { + installPluginFromNpmSpecMock.mockReset(); installPluginFromPathMock.mockReset(); installPluginFromClawHubMock.mockReset(); installPluginFromGitSpecMock.mockReset(); @@ -253,4 +257,60 @@ describe("handleCommands /plugins install", () => { ); }); }); + + it("trusts catalog npm package installs with alternate selectors", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "wecom-openclaw-plugin", + targetDir: "/tmp/wecom-openclaw-plugin", + version: "2026.4.23", + extensions: ["index.js"], + npmResolution: { + name: "@wecom/wecom-openclaw-plugin", + version: "2026.4.23", + resolvedSpec: "@wecom/wecom-openclaw-plugin@2026.4.23", + integrity: "sha512-wecom", + resolvedAt: "2026-05-04T20:00:00.000Z", + }, + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams( + "/plugins install @wecom/wecom-openclaw-plugin@latest", + workspaceDir, + ); + const result = await handlePluginsCommand(params, true); + if (result === null) { + throw new Error("expected plugin install result"); + } + expect(result.reply?.text).toContain('Installed plugin "wecom-openclaw-plugin"'); + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@wecom/wecom-openclaw-plugin@latest", + expectedPluginId: "wecom-openclaw-plugin", + trustedSourceLinkedOfficialInstall: true, + }), + ); + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.not.objectContaining({ + expectedIntegrity: expect.any(String), + }), + ); + expect(persistPluginInstallMock).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "wecom-openclaw-plugin", + install: expect.objectContaining({ + source: "npm", + spec: "@wecom/wecom-openclaw-plugin@latest", + installPath: "/tmp/wecom-openclaw-plugin", + version: "2026.4.23", + resolvedName: "@wecom/wecom-openclaw-plugin", + resolvedVersion: "2026.4.23", + }), + }), + ); + }); + }); }); diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index d850f37bffd..05036afe148 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import { buildNpmInstallRecordFields } from "../../cli/npm-resolution.js"; +import { resolveOfficialExternalNpmPackageTrust } from "../../cli/plugin-install-plan.js"; import { createPluginInstallLogger, resolveFileNpmSpecToLocalPath, @@ -20,6 +21,11 @@ import { installPluginFromClawHub } from "../../plugins/clawhub.js"; import { installPluginFromGitSpec, parseGitPluginSpec } from "../../plugins/git-install.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../../plugins/install.js"; import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js"; +import { + getOfficialExternalPluginCatalogEntryForPackage, + resolveOfficialExternalPluginId, + resolveOfficialExternalPluginInstall, +} from "../../plugins/official-external-plugin-catalog.js"; import type { PluginRecord } from "../../plugins/registry.js"; import { buildAllPluginInspectReports, @@ -159,6 +165,29 @@ function looksLikeLocalPluginInstallSpec(raw: string): boolean { ); } +function findTrustedCatalogPackageInstall(packageName: string): + | { + pluginId: string; + npmSpec?: string; + expectedIntegrity?: string; + } + | undefined { + const entry = getOfficialExternalPluginCatalogEntryForPackage(packageName); + if (!entry) { + return undefined; + } + const pluginId = resolveOfficialExternalPluginId(entry); + if (!pluginId) { + return undefined; + } + const install = resolveOfficialExternalPluginInstall(entry); + return { + pluginId, + ...(install?.npmSpec ? { npmSpec: install.npmSpec } : {}), + ...(install?.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}), + }; +} + async function installPluginFromPluginsCommand(params: { raw: string; snapshot: ConfigSnapshotForInstallPersist; @@ -254,8 +283,21 @@ async function installPluginFromPluginsCommand(params: { return { ok: true, pluginId: result.pluginId }; } + const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({ + npmSpec: params.raw, + findOfficialExternalPackage: findTrustedCatalogPackageInstall, + }); const result = await installPluginFromNpmSpec({ spec: params.raw, + ...(officialNpmTrust + ? { + expectedPluginId: officialNpmTrust.pluginId, + ...(officialNpmTrust.expectedIntegrity + ? { expectedIntegrity: officialNpmTrust.expectedIntegrity } + : {}), + trustedSourceLinkedOfficialInstall: true, + } + : {}), logger: createPluginInstallLogger(), }); if (!result.ok) { From be8b4dc845146a93e47b64ad9abe962bb3216366 Mon Sep 17 00:00:00 2001 From: Brad Date: Mon, 4 May 2026 13:48:40 -0700 Subject: [PATCH 036/107] fix(agents): honor hook bootstrap content (#77501) * Problem: `agent:bootstrap` hooks can inject `BOOTSTRAP.md` content, but embedded-runner bootstrap routing decided whether bootstrap was pending before hook-adjusted files were considered. * Fix: preload hook-adjusted bootstrap files before routing, treat non-empty hook-provided `BOOTSTRAP.md` as pending and accessible bootstrap content, and reuse the preloaded files when building Project Context. * Tests: added routing + context-engine regression coverage for hook-injected bootstrap content. Co-authored-by: ificator <8387253+ificator@users.noreply.github.com> Co-authored-by: galiniliev --- CHANGELOG.md | 1 + src/agents/bootstrap-files.ts | 13 +++- .../run/attempt-bootstrap-routing.ts | 18 +++++- ....spawn-workspace.bootstrap-routing.test.ts | 62 +++++++++++++++++++ ...mpt.spawn-workspace.context-engine.test.ts | 48 ++++++++++++++ .../attempt.spawn-workspace.test-support.ts | 15 +++++ src/agents/pi-embedded-runner/run/attempt.ts | 51 +++++++++++---- 7 files changed, 193 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b0b6aa4315..a0fdb59160e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -218,6 +218,7 @@ Docs: https://docs.openclaw.ai - Security/Windows: block `LOCALAPPDATA` from workspace `.env` and resolve Windows update-flow portable Git path prepends from the trusted process-local `LOCALAPPDATA` only, so workspace-supplied values cannot redirect `git` discovery during `openclaw update`. (#77470) Thanks @drobison00. - Browser/SSRF: enforce the existing current-tab URL navigation policy before tab-scoped debug, export, and read routes (console, page errors, network requests, trace start/stop, response body, screenshot, snapshot, storage, etc.) collect from an already-selected tab, so blocked tabs return a policy error instead of being read first and redacted only at response time. (#75731) Thanks @eleqtrizit. - Security/Windows: route the `.cmd`/`.bat` process wrapper through the shared Windows install-root resolver instead of `process.env.ComSpec`, so workspace dotenv-blocked `SystemRoot`/`WINDIR` overrides and unsafe values like UNC paths or path-lists cannot redirect `cmd.exe` selection on Windows. (#77472) Thanks @drobison00. +- Agents/bootstrap: honor `BOOTSTRAP.md` content injected by `agent:bootstrap` hooks when deciding whether bootstrap is pending, so hook-provided required setup instructions are included in the system prompt. (#77501) Thanks @ificator. ## 2026.5.3-1 diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index c1353d01e66..281e1593373 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -279,12 +279,23 @@ export async function resolveBootstrapContextForRun(params: { contextFiles: EmbeddedContextFile[]; }> { const bootstrapFiles = await resolveBootstrapFilesForRun(params); + const contextFiles = buildBootstrapContextForFiles(bootstrapFiles, params); + return { bootstrapFiles, contextFiles }; +} + +export function buildBootstrapContextForFiles( + bootstrapFiles: WorkspaceBootstrapFile[], + params: { + config?: OpenClawConfig; + warn?: (message: string) => void; + }, +): EmbeddedContextFile[] { const contextFiles = buildBootstrapContextFiles(bootstrapFiles, { maxChars: resolveBootstrapMaxChars(params.config), totalMaxChars: resolveBootstrapTotalMaxChars(params.config), warn: params.warn, }); - return { bootstrapFiles, contextFiles }; + return contextFiles; } export { isWorkspaceBootstrapPending }; diff --git a/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts b/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts index d73b4983b89..22adc253425 100644 --- a/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts +++ b/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts @@ -1,5 +1,6 @@ import type { BootstrapMode } from "../../bootstrap-mode.js"; import { resolveBootstrapMode } from "../../bootstrap-mode.js"; +import { DEFAULT_BOOTSTRAP_FILENAME, type WorkspaceBootstrapFile } from "../../workspace.js"; export type AttemptBootstrapRoutingInput = { workspaceBootstrapPending: boolean; @@ -24,6 +25,7 @@ export type AttemptWorkspaceBootstrapRoutingInput = Omit< "workspaceBootstrapPending" > & { isWorkspaceBootstrapPending: (workspaceDir: string) => Promise; + bootstrapFiles?: readonly WorkspaceBootstrapFile[]; }; export function resolveBootstrapContextTargets(params: { @@ -58,14 +60,28 @@ function resolveAttemptBootstrapRouting( }; } +export function hasBootstrapFileContent(files?: readonly WorkspaceBootstrapFile[]): boolean { + return ( + files?.some( + (file) => + file.name === DEFAULT_BOOTSTRAP_FILENAME && + !file.missing && + typeof file.content === "string" && + file.content.trim().length > 0, + ) ?? false + ); +} + export async function resolveAttemptWorkspaceBootstrapRouting( params: AttemptWorkspaceBootstrapRoutingInput, ): Promise { const workspaceBootstrapPending = await params.isWorkspaceBootstrapPending( params.resolvedWorkspace, ); + const hasHookBootstrapContent = hasBootstrapFileContent(params.bootstrapFiles); return resolveAttemptBootstrapRouting({ ...params, - workspaceBootstrapPending, + workspaceBootstrapPending: workspaceBootstrapPending || hasHookBootstrapContent, + hasBootstrapFileAccess: params.hasBootstrapFileAccess || hasHookBootstrapContent, }); } diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts index b5ddf161238..4ebb5be5fc6 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + hasBootstrapFileContent, resolveBootstrapContextTargets, resolveAttemptWorkspaceBootstrapRouting, } from "./attempt-bootstrap-routing.js"; @@ -46,6 +47,67 @@ describe("runEmbeddedAttempt bootstrap routing", () => { expect(routing.includeBootstrapInRuntimeContext).toBe(false); }); + it("treats hook-provided BOOTSTRAP.md content as pending bootstrap context", async () => { + const routing = await resolveAttemptWorkspaceBootstrapRouting({ + isWorkspaceBootstrapPending: vi.fn(async () => false), + bootstrapFiles: [ + { + name: "BOOTSTRAP.md", + path: "/tmp/openclaw-workspace/BOOTSTRAP.md", + content: "Ask who I am before continuing.", + missing: false, + }, + ], + trigger: "user", + isPrimaryRun: true, + isCanonicalWorkspace: true, + effectiveWorkspace: "/tmp/openclaw-workspace", + resolvedWorkspace: "/tmp/openclaw-workspace", + hasBootstrapFileAccess: true, + }); + + expect(routing.bootstrapMode).toBe("full"); + expect(routing.includeBootstrapInSystemContext).toBe(true); + expect(routing.includeBootstrapInRuntimeContext).toBe(false); + }); + + it("uses hook-provided BOOTSTRAP.md content even when normal file reads are unavailable", async () => { + const routing = await resolveAttemptWorkspaceBootstrapRouting({ + isWorkspaceBootstrapPending: vi.fn(async () => false), + bootstrapFiles: [ + { + name: "BOOTSTRAP.md", + path: "/tmp/openclaw-workspace/BOOTSTRAP.md", + content: "Ask who I am before continuing.", + missing: false, + }, + ], + trigger: "user", + isPrimaryRun: true, + isCanonicalWorkspace: true, + effectiveWorkspace: "/tmp/openclaw-workspace", + resolvedWorkspace: "/tmp/openclaw-workspace", + hasBootstrapFileAccess: false, + }); + + expect(routing.bootstrapMode).toBe("full"); + expect(routing.includeBootstrapInSystemContext).toBe(true); + expect(routing.includeBootstrapInRuntimeContext).toBe(false); + }); + + it("does not treat empty hook-provided BOOTSTRAP.md as pending bootstrap context", () => { + expect( + hasBootstrapFileContent([ + { + name: "BOOTSTRAP.md", + path: "/tmp/openclaw-workspace/BOOTSTRAP.md", + content: " ", + missing: false, + }, + ]), + ).toBe(false); + }); + it("keeps BOOTSTRAP.md in Project Context for full bootstrap turns", () => { expect(resolveBootstrapContextTargets({ bootstrapMode: "full" })).toEqual({ includeBootstrapInSystemContext: true, diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts index 3ce99a1e8bb..93aecfdc561 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -316,6 +316,54 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(systemPrompt).toContain("Ask who I am."); }); + it("includes hook-adjusted bootstrap files preloaded before routing", async () => { + const workspaceDir = "/tmp/openclaw-hook-workspace"; + hoisted.resolveBootstrapFilesForRunMock.mockResolvedValueOnce([ + { + name: "BOOTSTRAP.md", + path: `${workspaceDir}/BOOTSTRAP.md`, + content: "Ask who I am before continuing.", + missing: false, + }, + ]); + + await createContextEngineAttemptRunner({ + contextEngine: createContextEngineBootstrapAndAssemble(), + sessionKey, + tempPaths, + attemptOverrides: { + config: { + agents: { + defaults: { + systemPromptOverride: "Custom override prompt.", + }, + }, + } as OpenClawConfig, + prompt: "visible ask", + transcriptPrompt: "visible ask", + trigger: "user", + workspaceDir, + }, + sessionPrompt: async (session) => { + session.messages = [ + ...session.messages, + { role: "assistant", content: "done", timestamp: 2 }, + ]; + }, + }); + + expect(hoisted.resolveBootstrapFilesForRunMock).toHaveBeenCalledOnce(); + expect(hoisted.resolveBootstrapContextForRunMock).not.toHaveBeenCalled(); + const systemPrompt = + hoisted.systemPromptOverrideTexts.find((text) => text.includes("Custom override prompt.")) ?? + ""; + + expect(systemPrompt).toContain("## Bootstrap Pending"); + expect(systemPrompt).toContain("BOOTSTRAP.md is included below in Project Context"); + expect(systemPrompt).toContain(`## ${workspaceDir}/BOOTSTRAP.md`); + expect(systemPrompt).toContain("Ask who I am before continuing."); + }); + it("adds explicit reply context to the current model input without exposing generic runtime context", async () => { let seenPrompt: string | undefined; diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts index 8b05abcecf0..ab5605680cc 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts @@ -69,6 +69,9 @@ type AttemptSpawnWorkspaceHoisted = { installContextEngineLoopHookMock: UnknownMock; flushPendingToolResultsAfterIdleMock: AsyncUnknownMock; releaseWsSessionMock: UnknownMock; + resolveBootstrapFilesForRunMock: Mock< + (...args: unknown[]) => Promise + >; resolveBootstrapContextForRunMock: Mock<() => Promise>; isWorkspaceBootstrapPendingMock: Mock<(workspaceDir: string) => Promise>; resolveContextInjectionModeMock: Mock<() => "always" | "continuation-skip">; @@ -139,6 +142,12 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { bootstrapFiles: [], contextFiles: [], })); + const resolveBootstrapFilesForRunMock = vi.fn< + (...args: unknown[]) => Promise + >(async () => { + const context = await resolveBootstrapContextForRunMock(); + return context.bootstrapFiles; + }); const isWorkspaceBootstrapPendingMock = vi.fn<(workspaceDir: string) => Promise>( async () => false, ); @@ -188,6 +197,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { installContextEngineLoopHookMock, flushPendingToolResultsAfterIdleMock, releaseWsSessionMock, + resolveBootstrapFilesForRunMock, resolveBootstrapContextForRunMock, isWorkspaceBootstrapPendingMock, resolveContextInjectionModeMock, @@ -286,6 +296,7 @@ vi.mock("../../bootstrap-files.js", async () => { ...actual, makeBootstrapWarn: () => () => {}, isWorkspaceBootstrapPending: hoisted.isWorkspaceBootstrapPendingMock, + resolveBootstrapFilesForRun: hoisted.resolveBootstrapFilesForRunMock, resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, resolveContextInjectionMode: hoisted.resolveContextInjectionModeMock, hasCompletedBootstrapTurn: hoisted.hasCompletedBootstrapTurnMock, @@ -821,6 +832,10 @@ export function resetEmbeddedAttemptHarness( bootstrapFiles: [], contextFiles: [], }); + hoisted.resolveBootstrapFilesForRunMock.mockReset().mockImplementation(async () => { + const context = await hoisted.resolveBootstrapContextForRunMock(); + return context.bootstrapFiles; + }); hoisted.isWorkspaceBootstrapPendingMock.mockReset().mockResolvedValue(false); hoisted.resolveContextInjectionModeMock.mockReset().mockReturnValue("always"); hoisted.hasCompletedBootstrapTurnMock.mockReset().mockResolvedValue(false); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 374a4d50710..9ae369f227d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -62,10 +62,11 @@ import { } from "../../bootstrap-budget.js"; import { FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, + buildBootstrapContextForFiles, hasCompletedBootstrapTurn, isWorkspaceBootstrapPending, makeBootstrapWarn, - resolveBootstrapContextForRun, + resolveBootstrapFilesForRun, resolveContextInjectionMode, } from "../../bootstrap-files.js"; import { createCacheTrace } from "../../cache-trace.js"; @@ -945,8 +946,26 @@ export async function runEmbeddedAttempt( emitCorePluginToolStageSummary("core-plugin-tools", corePluginToolStages.snapshot()); const toolsEnabled = supportsModelTools(params.model); const bootstrapHasFileAccess = toolsEnabled && toolsRaw.some((tool) => tool.name === "read"); + const bootstrapWarn = makeBootstrapWarn({ + sessionLabel, + workspaceDir: resolvedWorkspace, + warn: (message) => log.warn(message), + }); + const preloadedBootstrapFiles = + isRawModelRun || contextInjectionMode === "never" + ? undefined + : await resolveBootstrapFilesForRun({ + workspaceDir: resolvedWorkspace, + config: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + warn: bootstrapWarn, + contextMode: params.bootstrapContextMode, + runKind: params.bootstrapContextRunKind, + }); const bootstrapRouting = await resolveAttemptWorkspaceBootstrapRouting({ isWorkspaceBootstrapPending, + bootstrapFiles: preloadedBootstrapFiles, bootstrapContextRunKind: params.bootstrapContextRunKind, trigger: params.trigger, sessionKey: params.sessionKey, @@ -970,20 +989,26 @@ export async function runEmbeddedAttempt( bootstrapMode, sessionFile: params.sessionFile, hasCompletedBootstrapTurn, - resolveBootstrapContextForRun: async () => - await resolveBootstrapContextForRun({ - workspaceDir: resolvedWorkspace, - config: params.config, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - warn: makeBootstrapWarn({ - sessionLabel, + resolveBootstrapContextForRun: async () => { + const bootstrapFiles = + preloadedBootstrapFiles ?? + (await resolveBootstrapFilesForRun({ workspaceDir: resolvedWorkspace, - warn: (message) => log.warn(message), + config: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + warn: bootstrapWarn, + contextMode: params.bootstrapContextMode, + runKind: params.bootstrapContextRunKind, + })); + return { + bootstrapFiles, + contextFiles: buildBootstrapContextForFiles(bootstrapFiles, { + config: params.config, + warn: bootstrapWarn, }), - contextMode: params.bootstrapContextMode, - runKind: params.bootstrapContextRunKind, - }), + }; + }, }); prepStages.mark("bootstrap-context"); const remappedContextFiles = remapInjectedContextFilesToWorkspace({ From b63336186ae1cc368a08926c574b026f9fac275e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 21:23:53 +0100 Subject: [PATCH 037/107] fix(update): stage npm-prefix package updates cleanly Co-authored-by: Josh Lehman --- src/infra/package-update-steps.test.ts | 102 +++++++++++++++++++++++++ src/infra/package-update-steps.ts | 50 ++++++++++-- src/infra/update-runner.test.ts | 36 +++++++++ 3 files changed, 182 insertions(+), 6 deletions(-) diff --git a/src/infra/package-update-steps.test.ts b/src/infra/package-update-steps.test.ts index f74a0952e4e..91583dbc4ec 100644 --- a/src/infra/package-update-steps.test.ts +++ b/src/infra/package-update-steps.test.ts @@ -29,6 +29,15 @@ function createNpmTarget(globalRoot: string): ResolvedGlobalInstallTarget { }; } +function createPnpmTarget(globalRoot: string): ResolvedGlobalInstallTarget { + return { + manager: "pnpm", + command: "pnpm", + globalRoot, + packageRoot: path.join(globalRoot, "openclaw"), + }; +} + function createRootRunner(globalRoot: string): CommandRunner { return async (argv) => { if (argv.join(" ") === "npm root -g") { @@ -115,6 +124,99 @@ describe("runGlobalPackageUpdateSteps", () => { }); }); + it("stages pnpm-detected updates through npm when the global root has npm prefix layout", async () => { + await withTempDir({ prefix: "openclaw-package-update-pnpm-staged-" }, async (base) => { + const prefix = path.join(base, "prefix"); + const globalRoot = path.join(prefix, "lib", "node_modules"); + const packageRoot = path.join(globalRoot, "openclaw"); + const staleChunk = path.join(packageRoot, "dist", "install-C_GuuNz6.js"); + await writePackageRoot(packageRoot, "1.0.0"); + await fs.writeFile(staleChunk, 'import "./install.runtime-Xom5hOHq.js";\n', "utf8"); + + const runStep = vi.fn(async ({ name, argv, cwd }): Promise => { + if (name !== "global update") { + throw new Error(`unexpected step ${name}`); + } + expect(argv[0]).toBe("npm"); + expect(argv).toEqual(expect.arrayContaining(["i", "-g", "--prefix", "openclaw@2.0.0"])); + expect(argv).not.toContain("pnpm"); + const prefixIndex = argv.indexOf("--prefix"); + const stagePrefix = argv[prefixIndex + 1]; + if (!stagePrefix) { + throw new Error("missing staged prefix"); + } + await writePackageRoot(path.join(stagePrefix, "lib", "node_modules", "openclaw"), "2.0.0"); + return { + name, + command: argv.join(" "), + cwd: cwd ?? process.cwd(), + durationMs: 1, + exitCode: 0, + }; + }); + + const result = await runGlobalPackageUpdateSteps({ + installTarget: createPnpmTarget(globalRoot), + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + packageRoot, + runCommand: createRootRunner(globalRoot), + runStep, + timeoutMs: 1000, + }); + + expect(result.failedStep).toBeNull(); + expect(result.afterVersion).toBe("2.0.0"); + expect(result.steps.map((step) => step.name)).toEqual([ + "global update", + "global install swap", + ]); + await expect(fs.access(staleChunk)).rejects.toMatchObject({ code: "ENOENT" }); + }); + }); + + it("keeps Windows pnpm global roots on the pnpm update path", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + await withTempDir({ prefix: "openclaw-package-update-win32-pnpm-" }, async (base) => { + const globalRoot = path.join(base, "pnpm", "global", "5", "node_modules"); + const packageRoot = path.join(globalRoot, "openclaw"); + await writePackageRoot(packageRoot, "1.0.0"); + + const runStep = vi.fn(async ({ name, argv, cwd }): Promise => { + if (name !== "global update") { + throw new Error(`unexpected step ${name}`); + } + expect(argv).toEqual(["pnpm", "add", "-g", "openclaw@2.0.0"]); + await writePackageRoot(packageRoot, "2.0.0"); + return { + name, + command: argv.join(" "), + cwd: cwd ?? process.cwd(), + durationMs: 1, + exitCode: 0, + }; + }); + + const result = await runGlobalPackageUpdateSteps({ + installTarget: createPnpmTarget(globalRoot), + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + packageRoot, + runCommand: createRootRunner(globalRoot), + runStep, + timeoutMs: 1000, + }); + + expect(result.failedStep).toBeNull(); + expect(result.afterVersion).toBe("2.0.0"); + expect(result.steps.map((step) => step.name)).toEqual(["global update"]); + }); + } finally { + platformSpy.mockRestore(); + } + }); + it("keeps a successful staged swap when old package cleanup hits a transient Windows native module error", async () => { await withTempDir({ prefix: "openclaw-package-update-staged-cleanup-" }, async (base) => { const prefix = path.join(base, "prefix"); diff --git a/src/infra/package-update-steps.ts b/src/infra/package-update-steps.ts index 18bcb372568..3a3f15e7cd9 100644 --- a/src/infra/package-update-steps.ts +++ b/src/infra/package-update-steps.ts @@ -36,6 +36,7 @@ type StagedNpmInstall = { prefix: string; layout: NpmGlobalPrefixLayout; packageRoot: string; + installTarget: ResolvedGlobalInstallTarget; }; type NpmBinShimBackup = { @@ -82,24 +83,60 @@ async function readPackageVersionIfPresent(packageRoot: string | null): Promise< } } +function isUnambiguousNpmPrefixGlobalRoot(globalRoot: string | null): boolean { + const trimmed = globalRoot?.trim(); + if (!trimmed) { + return false; + } + const normalized = path.resolve(trimmed); + if (path.basename(normalized) !== "node_modules") { + return false; + } + const parentDir = path.dirname(normalized); + if (path.basename(parentDir) === "lib") { + return true; + } + return process.platform === "win32" && path.basename(parentDir).toLowerCase() === "npm"; +} + +function resolveStagedNpmTargetLayout( + installTarget: ResolvedGlobalInstallTarget, +): NpmGlobalPrefixLayout | null { + const targetLayout = resolveNpmGlobalPrefixLayoutFromGlobalRoot(installTarget.globalRoot); + if (!targetLayout) { + return null; + } + if ( + installTarget.manager === "npm" || + isUnambiguousNpmPrefixGlobalRoot(installTarget.globalRoot) + ) { + return targetLayout; + } + return null; +} + async function createStagedNpmInstall( installTarget: ResolvedGlobalInstallTarget, packageName: string, ): Promise { - if (installTarget.manager !== "npm") { - return null; - } - const targetLayout = resolveNpmGlobalPrefixLayoutFromGlobalRoot(installTarget.globalRoot); + const targetLayout = resolveStagedNpmTargetLayout(installTarget); if (!targetLayout) { return null; } await fs.mkdir(targetLayout.globalRoot, { recursive: true }); const prefix = await fs.mkdtemp(path.join(targetLayout.globalRoot, ".openclaw-update-stage-")); const layout = resolveNpmGlobalPrefixLayoutFromPrefix(prefix); + const command = installTarget.manager === "npm" ? installTarget.command : "npm"; return { prefix, layout, packageRoot: path.join(layout.globalRoot, packageName), + installTarget: { + manager: "npm", + command, + globalRoot: layout.globalRoot, + packageRoot: path.join(layout.globalRoot, packageName), + }, }; } @@ -329,10 +366,11 @@ export async function runGlobalPackageUpdateSteps(params: { }; } + const installCommandTarget = stagedInstall?.installTarget ?? params.installTarget; const updateStep = await params.runStep({ name: "global update", argv: globalInstallArgs( - params.installTarget, + installCommandTarget, params.installSpec, undefined, stagedInstall?.prefix, @@ -363,7 +401,7 @@ export async function runGlobalPackageUpdateSteps(params: { } const fallbackArgv = globalInstallFallbackArgs( - params.installTarget, + stagedInstall?.installTarget ?? params.installTarget, params.installSpec, undefined, stagedInstall?.prefix, diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index edaf8b6147e..c54094981fb 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -1365,6 +1365,7 @@ describe("runGatewayUpdate", () => { const createGlobalInstallHarness = (params: { pkgRoot: string; npmRootOutput?: string; + pnpmRootOutput?: string; installCommand: string; gitRootMode?: "not-git" | "missing"; onInstall?: (options?: { @@ -1390,6 +1391,9 @@ describe("runGatewayUpdate", () => { return { stdout: "", stderr: "", code: 1 }; } if (key === "pnpm root -g") { + if (params.pnpmRootOutput) { + return { stdout: params.pnpmRootOutput, stderr: "", code: 0 }; + } return { stdout: "", stderr: "", code: 1 }; } if (key === params.installCommand) { @@ -1747,6 +1751,38 @@ describe("runGatewayUpdate", () => { ); }); + it("uses clean staged npm swaps for pnpm installs that resolve to an npm global root", async () => { + const prefix = path.join(tempDir, "npm-prefix"); + const nodeModules = path.join(prefix, "lib", "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + const staleInstallChunk = path.join(pkgRoot, "dist", "install-C_GuuNz6.js"); + await seedGlobalPackageRoot(pkgRoot); + await fs.writeFile( + staleInstallChunk, + 'const pluginRuntime = () => import("./install.runtime-Xom5hOHq.js");\n', + "utf-8", + ); + + const { calls, runCommand } = createGlobalInstallHarness({ + pkgRoot, + pnpmRootOutput: nodeModules, + installCommand: "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error", + onInstall: async (options) => { + await writeGlobalPackageVersion(options?.packageRoot ?? pkgRoot); + }, + }); + + const result = await runWithCommand(runCommand, { cwd: pkgRoot }); + + expect(result.status).toBe("ok"); + expect(result.mode).toBe("pnpm"); + expect(result.after?.version).toBe("2.0.0"); + expect(calls.some((call) => call.startsWith("npm i -g --prefix "))).toBe(true); + expect(calls.some((call) => call.startsWith("pnpm add -g"))).toBe(false); + expect(result.steps.map((step) => step.name)).toEqual(["global update", "global install swap"]); + await expect(fs.access(staleInstallChunk)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("uses OPENCLAW_UPDATE_PACKAGE_SPEC for global package updates", async () => { const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir); const expectedInstallCommand = From 112924b11394991bba8db447dcd91a9ac478c7e7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 21:24:00 +0100 Subject: [PATCH 038/107] fix(update): keep plugin install runtime aliases stable --- scripts/runtime-postbuild.mjs | 173 ++++++++++++++++++++++++- test/scripts/runtime-postbuild.test.ts | 112 ++++++++++++++++ 2 files changed, 278 insertions(+), 7 deletions(-) diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index 0d1d1e40437..fae1cf9b920 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -19,6 +19,7 @@ const ROOT_RUNTIME_ALIAS_PATTERN = /^(?.+\.(?:runtime|contract))-[A-Za-z0- const ROOT_STABLE_RUNTIME_ALIAS_PATTERN = /^.+\.(?:runtime|contract)\.js$/u; const ROOT_RUNTIME_IMPORT_SPECIFIER_PATTERN = /(["'])\.\/([^"']+\.(?:runtime|contract)-[A-Za-z0-9_-]+\.js)\1/gu; +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); const LEGACY_ROOT_RUNTIME_COMPAT_ALIASES = [ // v2026.4.29 dispatch lazy chunks. Package updates used to replace the // dist tree before the live gateway had restarted, so an already-loaded old @@ -45,6 +46,77 @@ const LEGACY_ROOT_RUNTIME_COMPAT_ALIASES = [ ["provider-dispatcher-BpL2E92x.js", "provider-dispatcher.runtime.js"], ["provider-dispatcher-JG96SkLX.js", "provider-dispatcher.runtime.js"], ]; +const LEGACY_PLUGIN_INSTALL_RUNTIME_MARKERS = [ + "scanPackageInstallSource", + "scanFileInstallSource", + "scanInstalledPackageDependencyTree", + "scanBundleInstallSource", +]; +const PLUGIN_INSTALL_RUNTIME_ALIAS = { + aliasFileName: "install.runtime.js", + sourceIncludes: LEGACY_PLUGIN_INSTALL_RUNTIME_MARKERS, +}; +const LEGACY_PLUGIN_INSTALL_RUNTIME_COMPAT_ALIASES = [ + // Published releases from v2026.3.22 onward. Older updaters could + // overlay package dist instead of swapping it, leaving old install chunks + // that still import these hashed plugin install runtime files. + "install.runtime-D7SL02B2.js", + "install.runtime-Deq6Beal.js", + "install.runtime-Eoq8y3HE.js", + "install.runtime-DDmlaKdG.js", + "install.runtime-ADTafpVD.js", + "install.runtime-v8X-j3Tm.js", + "install.runtime-BLcZ-44g.js", + "install.runtime-vS4aFJvO.js", + "install.runtime-Dm_c092A.js", + "install.runtime-D_7OUvuY.js", + "install.runtime-BLEE0OIk.js", + "install.runtime-3LpjZbr8.js", + "install.runtime-BrsB9OnV.js", + "install.runtime-BEOb-kNW.js", + "install.runtime-Cx_xphd1.js", + "install.runtime-B-MtEMSR.js", + "install.runtime-C-Y4HAqX.js", + "install.runtime-j1SedTZh.js", + "install.runtime-4zsL_8wt.js", + "install.runtime-BhCKlLSJ.js", + "install.runtime-tGJ0KhMF.js", + "install.runtime-DtmATpak.js", + "install.runtime-BzZ38ePb.js", + "install.runtime-DwQr7nEE.js", + "install.runtime-CEIURnUz.js", + "install.runtime-D3EPlM0r.js", + "install.runtime-DIlN5H3O.js", + "install.runtime-DjcOwVH_.js", + "install.runtime-B13jZink.js", + "install.runtime-O8MXNrwm.js", + "install.runtime-Bkf_VMnk.js", + "install.runtime-QOfEzAcZ.js", + "install.runtime-BRVACueI.js", + "install.runtime-DX8jy7tN.js", + "install.runtime-BdfsTamp.js", + "install.runtime-B6OA2_P8.js", + "install.runtime-D9cTH-C0.js", + "install.runtime-OCJULXQo.js", + "install.runtime-9ZXBhZSk.js", + "install.runtime-DlL3C3t_.js", + "install.runtime-TU-jP-TN.js", + "install.runtime-a2FlfOSp.js", + "install.runtime-BwuRABU1.js", + "install.runtime-B3mZL_R2.js", + "install.runtime-CWUzypNQ.js", + "install.runtime-D6FSd9v2.js", + "install.runtime-DQ-ui3nL.js", + "install.runtime-CNHwKOIb.js", + "install.runtime-Dzuj9tSw.js", + "install.runtime-BuF-YAfQ.js", + "install.runtime-Xom5hOHq.js", + "install.runtime-tnhNR9WW.js", +].map((legacyFileName) => ({ + legacyFileName, + aliasFileName: PLUGIN_INSTALL_RUNTIME_ALIAS.aliasFileName, + sourceIncludes: LEGACY_PLUGIN_INSTALL_RUNTIME_MARKERS, +})); const LEGACY_CLI_EXIT_COMPAT_CHUNKS = [ { dest: "dist/memory-state-CcqRgDZU.js", @@ -82,10 +154,18 @@ export function writeStableRootRuntimeAliases(params = {}) { candidatesByAlias.set(aliasFileName, candidates); } - const resolveAliasCandidate = (candidates) => { + const resolveAliasCandidate = (aliasFileName, candidates) => { if (candidates.length === 1) { return candidates[0]; } + if (aliasFileName === PLUGIN_INSTALL_RUNTIME_ALIAS.aliasFileName) { + return resolveRootRuntimeCandidateByMarkers({ + distDir, + fsImpl, + aliasFileName, + sourceIncludes: PLUGIN_INSTALL_RUNTIME_ALIAS.sourceIncludes, + }); + } const candidateSet = new Set(candidates); const wrappers = candidates.filter((candidate) => { const filePath = path.join(distDir, candidate); @@ -108,7 +188,7 @@ export function writeStableRootRuntimeAliases(params = {}) { for (const [aliasFileName, candidates] of candidatesByAlias) { const aliasPath = path.join(distDir, aliasFileName); - const candidate = resolveAliasCandidate(candidates); + const candidate = resolveAliasCandidate(aliasFileName, candidates); if (!candidate) { fsImpl.rmSync?.(aliasPath, { force: true }); continue; @@ -143,10 +223,21 @@ export function rewriteRootRuntimeImportsToStableAliases(params = {}) { } const runtimeAliasFiles = new Map(); for (const [aliasFileName, candidates] of candidatesByAlias) { - if (candidates.length !== 1) { + if (candidates.length === 1) { + runtimeAliasFiles.set(candidates[0], aliasFileName); continue; } - runtimeAliasFiles.set(candidates[0], aliasFileName); + if (aliasFileName === PLUGIN_INSTALL_RUNTIME_ALIAS.aliasFileName) { + const candidate = resolveRootRuntimeCandidateByMarkers({ + distDir, + fsImpl, + aliasFileName, + sourceIncludes: PLUGIN_INSTALL_RUNTIME_ALIAS.sourceIncludes, + }); + if (candidate) { + runtimeAliasFiles.set(candidate, aliasFileName); + } + } } if (runtimeAliasFiles.size === 0) { return; @@ -179,19 +270,87 @@ export function rewriteRootRuntimeImportsToStableAliases(params = {}) { } } +function resolveRootRuntimeCandidateByMarkers(params) { + if (!params.sourceIncludes?.length) { + return null; + } + const match = params.aliasFileName.match(ROOT_STABLE_RUNTIME_ALIAS_PATTERN); + if (!match) { + return null; + } + const aliasBaseFileName = params.aliasFileName.replace(/\.js$/u, ""); + const hashedPattern = new RegExp(`^${escapeRegExp(aliasBaseFileName)}-[A-Za-z0-9_-]+\\.js$`, "u"); + let entries = []; + try { + entries = params.fsImpl.readdirSync(params.distDir, { withFileTypes: true }); + } catch { + return null; + } + const candidates = []; + for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) { + if (!entry.isFile() || !hashedPattern.test(entry.name)) { + continue; + } + const candidatePath = path.join(params.distDir, entry.name); + let source; + try { + source = params.fsImpl.readFileSync(candidatePath, "utf8"); + } catch { + continue; + } + if (params.sourceIncludes.every((marker) => source.includes(marker))) { + candidates.push(entry.name); + } + } + return candidates.length === 1 ? candidates[0] : null; +} + +function resolveLegacyRootRuntimeCompatTarget(params) { + if ( + params.aliasFileName && + params.fsImpl.existsSync(path.join(params.distDir, params.aliasFileName)) + ) { + return params.aliasFileName; + } + const match = params.legacyFileName.match(ROOT_RUNTIME_ALIAS_PATTERN); + if (!match?.groups?.base) { + return null; + } + return resolveRootRuntimeCandidateByMarkers({ + distDir: params.distDir, + fsImpl: params.fsImpl, + aliasFileName: `${match.groups.base}.js`, + sourceIncludes: params.sourceIncludes, + }); +} + export function writeLegacyRootRuntimeCompatAliases(params = {}) { const rootDir = params.rootDir ?? ROOT; const distDir = path.join(rootDir, "dist"); const fsImpl = params.fs ?? fs; - for (const [legacyFileName, aliasFileName] of LEGACY_ROOT_RUNTIME_COMPAT_ALIASES) { + for (const entry of [ + ...LEGACY_ROOT_RUNTIME_COMPAT_ALIASES.map(([legacyFileName, aliasFileName]) => ({ + legacyFileName, + aliasFileName, + })), + ...LEGACY_PLUGIN_INSTALL_RUNTIME_COMPAT_ALIASES, + ]) { + const { legacyFileName } = entry; const legacyPath = path.join(distDir, legacyFileName); if (fsImpl.existsSync(legacyPath)) { continue; } - if (!fsImpl.existsSync(path.join(distDir, aliasFileName))) { + const targetFileName = resolveLegacyRootRuntimeCompatTarget({ + distDir, + fsImpl, + legacyFileName, + aliasFileName: entry.aliasFileName, + sourceIncludes: entry.sourceIncludes, + }); + if (!targetFileName) { continue; } - writeTextFileIfChanged(legacyPath, `export * from "./${aliasFileName}";\n`); + writeTextFileIfChanged(legacyPath, `export * from "./${targetFileName}";\n`); } } diff --git a/test/scripts/runtime-postbuild.test.ts b/test/scripts/runtime-postbuild.test.ts index 707b73e3de7..2d09d7527ed 100644 --- a/test/scripts/runtime-postbuild.test.ts +++ b/test/scripts/runtime-postbuild.test.ts @@ -145,6 +145,34 @@ describe("runtime postbuild static assets", () => { await expect(fs.stat(path.join(distDir, "install.runtime.js"))).rejects.toThrow(); }); + it("writes a stable plugin install runtime alias when install runtimes collide", async () => { + const rootDir = createTempDir("openclaw-runtime-postbuild-"); + const distDir = path.join(rootDir, "dist"); + await fs.mkdir(distDir, { recursive: true }); + await fs.writeFile( + path.join(distDir, "install.runtime-Aaa111.js"), + [ + "export const scanPackageInstallSource = true;", + "export const scanFileInstallSource = true;", + "export const scanInstalledPackageDependencyTree = true;", + "export const scanBundleInstallSource = true;", + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(distDir, "install.runtime-Bbb222.js"), + "export const daemonInstall = true;\n", + "utf8", + ); + + writeStableRootRuntimeAliases({ rootDir }); + + expect(await fs.readFile(path.join(distDir, "install.runtime.js"), "utf8")).toBe( + 'export * from "./install.runtime-Aaa111.js";\n', + ); + }); + it("keeps stable aliases when one colliding root runtime chunk re-exports the implementation", async () => { const rootDir = createTempDir("openclaw-runtime-postbuild-"); const distDir = path.join(rootDir, "dist"); @@ -294,6 +322,47 @@ describe("runtime postbuild static assets", () => { ); }); + it("rewrites plugin install runtime imports to stable aliases when install runtimes collide", async () => { + const rootDir = createTempDir("openclaw-runtime-postbuild-"); + const distDir = path.join(rootDir, "dist"); + await fs.mkdir(distDir, { recursive: true }); + await fs.writeFile( + path.join(distDir, "install.runtime-Aaa111.js"), + [ + "export const scanPackageInstallSource = true;", + "export const scanFileInstallSource = true;", + "export const scanInstalledPackageDependencyTree = true;", + "export const scanBundleInstallSource = true;", + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(distDir, "install.runtime-Bbb222.js"), + "export const daemonInstall = true;\n", + "utf8", + ); + await fs.writeFile( + path.join(distDir, "install-OldHash.js"), + [ + 'const pluginRuntime = () => import("./install.runtime-Aaa111.js");', + 'const daemonRuntime = () => import("./install.runtime-Bbb222.js");', + "", + ].join("\n"), + "utf8", + ); + + rewriteRootRuntimeImportsToStableAliases({ rootDir }); + + expect(await fs.readFile(path.join(distDir, "install-OldHash.js"), "utf8")).toBe( + [ + 'const pluginRuntime = () => import("./install.runtime.js");', + 'const daemonRuntime = () => import("./install.runtime-Bbb222.js");', + "", + ].join("\n"), + ); + }); + it("leaves stable alias files pointing at their hashed runtime chunks", async () => { const rootDir = createTempDir("openclaw-runtime-postbuild-"); const distDir = path.join(rootDir, "dist"); @@ -330,6 +399,22 @@ describe("runtime postbuild static assets", () => { 'export * from "./provider-dispatcher.runtime-NewHash.js";\n', "utf8", ); + await fs.writeFile( + path.join(distDir, "install.runtime-NewPluginHash.js"), + [ + "export const scanPackageInstallSource = true;", + "export const scanFileInstallSource = true;", + "export const scanInstalledPackageDependencyTree = true;", + "export const scanBundleInstallSource = true;", + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(distDir, "install.runtime-OtherHash.js"), + "export const installFromValidatedNpmSpecArchive = true;\n", + "utf8", + ); writeLegacyRootRuntimeCompatAliases({ rootDir }); @@ -342,6 +427,33 @@ describe("runtime postbuild static assets", () => { expect(await fs.readFile(path.join(distDir, "provider-dispatcher-6EQEtc-t.js"), "utf8")).toBe( 'export * from "./provider-dispatcher.runtime.js";\n', ); + expect(await fs.readFile(path.join(distDir, "install.runtime-D7SL02B2.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-Deq6Beal.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-BRVACueI.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-DX8jy7tN.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-D6FSd9v2.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-DQ-ui3nL.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-Xom5hOHq.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-tnhNR9WW.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "install.runtime-CNHwKOIb.js"), "utf8")).toBe( + 'export * from "./install.runtime-NewPluginHash.js";\n', + ); }); it("writes compatibility aliases for previous gateway shutdown chunk names", async () => { From 0fc8afeac9a373df59c0dab731d8751e91d14033 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Mon, 4 May 2026 21:24:04 +0100 Subject: [PATCH 039/107] test(package): cover stale source plugin shadows Co-authored-by: Vincent Koc --- docs/help/testing-updates-plugins.md | 4 +- docs/reference/test.md | 2 +- .../e2e/lib/upgrade-survivor/assertions.mjs | 8 ++++ scripts/e2e/lib/upgrade-survivor/run.sh | 42 +++++++++++++++++++ scripts/lib/docker-e2e-plan.mjs | 1 + test/scripts/docker-e2e-plan.test.ts | 3 ++ .../package-acceptance-workflow.test.ts | 5 +++ 7 files changed, 62 insertions(+), 3 deletions(-) diff --git a/docs/help/testing-updates-plugins.md b/docs/help/testing-updates-plugins.md index fcaa7fa028f..45ab2115d8e 100644 --- a/docs/help/testing-updates-plugins.md +++ b/docs/help/testing-updates-plugins.md @@ -123,8 +123,8 @@ pnpm test:docker:published-upgrade-survivor ``` Available scenarios are `base`, `feishu-channel`, `bootstrap-persona`, -`plugin-deps-cleanup`, `configured-plugin-installs`, `tilde-log-path`, and -`versioned-runtime-deps`. In aggregate runs, +`plugin-deps-cleanup`, `configured-plugin-installs`, +`stale-source-plugin-shadow`, `tilde-log-path`, and `versioned-runtime-deps`. In aggregate runs, `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS=reported-issues` expands to all reported issue-shaped scenarios, including the configured-plugin install migration. diff --git a/docs/reference/test.md b/docs/reference/test.md index 13d3cbb68ee..f20d60f73e3 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -44,7 +44,7 @@ title: "Tests" - `pnpm test:docker:openwebui`: Starts Dockerized OpenClaw + Open WebUI, signs in through Open WebUI, checks `/api/models`, then runs a real proxied chat through `/api/chat/completions`. Requires a usable live model key (for example OpenAI in `~/.profile`), pulls an external Open WebUI image, and is not expected to be CI-stable like the normal unit/e2e suites. - `pnpm test:docker:mcp-channels`: Starts a seeded Gateway container and a second client container that spawns `openclaw mcp serve`, then verifies routed conversation discovery, transcript reads, attachment metadata, live event queue behavior, outbound send routing, and Claude-style channel + permission notifications over the real stdio bridge. The Claude notification assertion reads the raw stdio MCP frames directly so the smoke reflects what the bridge actually emits. - `pnpm test:docker:upgrade-survivor`: Installs the packed OpenClaw tarball over a dirty old-user fixture, runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks that agents, channel config, plugin allowlists, workspace/session files, stale legacy plugin dependency state, startup, and RPC status survive. -- `pnpm test:docker:published-upgrade-survivor`: Installs `openclaw@latest` by default, seeds realistic existing-user files without live provider or channel keys, configures that baseline with a baked `openclaw config set` command recipe, updates that published install to the packed OpenClaw tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks that configured intents, workspace/session files, stale plugin config and legacy dependency state, startup, `/healthz`, `/readyz`, and RPC status survive or repair cleanly. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, expand an exact matrix with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS` such as `all-since-2026.4.23`, or add scenario fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS=reported-issues`; the reported-issues set includes `configured-plugin-installs` to verify configured external OpenClaw plugins install automatically during upgrade. Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. +- `pnpm test:docker:published-upgrade-survivor`: Installs `openclaw@latest` by default, seeds realistic existing-user files without live provider or channel keys, configures that baseline with a baked `openclaw config set` command recipe, updates that published install to the packed OpenClaw tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks that configured intents, workspace/session files, stale plugin config and legacy dependency state, startup, `/healthz`, `/readyz`, and RPC status survive or repair cleanly. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, expand an exact matrix with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS` such as `all-since-2026.4.23`, or add scenario fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS=reported-issues`; the reported-issues set includes `configured-plugin-installs` to verify configured external OpenClaw plugins install automatically during upgrade and `stale-source-plugin-shadow` to keep source-only plugin shadows from breaking startup. Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. - `pnpm test:docker:update-migration`: Runs the published-upgrade survivor harness in the cleanup-heavy `plugin-deps-cleanup` scenario, starting at `openclaw@2026.4.23` by default. The separate `Update Migration` workflow expands this lane with `baselines=all-since-2026.4.23` so every stable published package from `.23` onward updates to the candidate and proves configured-plugin dependency cleanup outside Full Release CI. - `pnpm test:docker:plugins`: Runs install/update smoke for local path, `file:`, npm registry packages with hoisted dependencies, git moving refs, ClawHub fixtures, marketplace updates, and Claude-bundle enable/inspect. diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs index fd008e1cf72..2e2c1d7fa3d 100644 --- a/scripts/e2e/lib/upgrade-survivor/assertions.mjs +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -8,6 +8,7 @@ const SCENARIOS = new Set([ "bootstrap-persona", "plugin-deps-cleanup", "configured-plugin-installs", + "stale-source-plugin-shadow", "tilde-log-path", "versioned-runtime-deps", ]); @@ -355,6 +356,13 @@ function assertStateSurvived() { assert(actual === contents, `${fileName} was changed during update/doctor`); } } + if (scenario === "stale-source-plugin-shadow") { + const staleRoot = path.join(stateDir, "extensions", "opik-openclaw"); + assert( + fs.existsSync(path.join(staleRoot, "src", "index.ts")), + "source-only plugin shadow fixture missing", + ); + } if (scenario === "versioned-runtime-deps") { if (stage === "baseline") { return; diff --git a/scripts/e2e/lib/upgrade-survivor/run.sh b/scripts/e2e/lib/upgrade-survivor/run.sh index 61a1734f0fc..3f64479651f 100644 --- a/scripts/e2e/lib/upgrade-survivor/run.sh +++ b/scripts/e2e/lib/upgrade-survivor/run.sh @@ -286,6 +286,47 @@ configured_plugin_installs_enabled() { [ "$SCENARIO" = "configured-plugin-installs" ] } +source_only_plugin_shadow_enabled() { + [ "$SCENARIO" = "stale-source-plugin-shadow" ] +} + +seed_source_only_plugin_shadow() { + source_only_plugin_shadow_enabled || return 0 + + local shadow_root="$OPENCLAW_STATE_DIR/extensions/opik-openclaw" + mkdir -p "$shadow_root/src" + cat >"$shadow_root/package.json" <<'JSON' +{ + "name": "@opik/opik-openclaw", + "version": "0.0.0-upgrade-survivor", + "openclaw": { + "extensions": ["./src/index.ts"] + } +} +JSON + cat >"$shadow_root/openclaw.plugin.json" <<'JSON' +{ + "id": "opik-openclaw", + "activation": { + "onStartup": false + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} +JSON + cat >"$shadow_root/src/index.ts" <<'TS' +export default { + id: "opik-openclaw", + name: "Source-only Opik shadow", + register() {}, +}; +TS + echo "Seeded source-only plugin shadow: $shadow_root" +} + configure_configured_plugin_install_fixture_registry() { configured_plugin_installs_enabled || return 0 @@ -785,6 +826,7 @@ phase validate-baseline-config validate_baseline_config phase install-baseline-plugin-dependencies install_baseline_plugin_dependencies phase seed-legacy-plugin-dependency-debris seed_legacy_plugin_dependency_debris phase assert-legacy-plugin-dependency-debris assert_legacy_plugin_dependency_debris_present +phase seed-source-only-plugin-shadow seed_source_only_plugin_shadow phase assert-baseline assert_baseline_state phase seed-legacy-runtime-deps-symlink seed_legacy_runtime_deps_symlink phase resolve-candidate resolve_candidate_version diff --git a/scripts/lib/docker-e2e-plan.mjs b/scripts/lib/docker-e2e-plan.mjs index b8de69eb664..7d5db1dbf70 100644 --- a/scripts/lib/docker-e2e-plan.mjs +++ b/scripts/lib/docker-e2e-plan.mjs @@ -75,6 +75,7 @@ const UPGRADE_SURVIVOR_SCENARIOS = [ "bootstrap-persona", "plugin-deps-cleanup", "configured-plugin-installs", + "stale-source-plugin-shadow", "tilde-log-path", "versioned-runtime-deps", ]; diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index d4160b16557..c5b5cabf24a 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -382,6 +382,7 @@ describe("scripts/lib/docker-e2e-plan", () => { "published-upgrade-survivor-2026.4.29-bootstrap-persona", "published-upgrade-survivor-2026.4.29-plugin-deps-cleanup", "published-upgrade-survivor-2026.4.29-configured-plugin-installs", + "published-upgrade-survivor-2026.4.29-stale-source-plugin-shadow", "published-upgrade-survivor-2026.4.29-tilde-log-path", "published-upgrade-survivor-2026.4.29-versioned-runtime-deps", ]); @@ -400,12 +401,14 @@ describe("scripts/lib/docker-e2e-plan", () => { "published-upgrade-survivor-2026.4.29-bootstrap-persona", "published-upgrade-survivor-2026.4.29-plugin-deps-cleanup", "published-upgrade-survivor-2026.4.29-configured-plugin-installs", + "published-upgrade-survivor-2026.4.29-stale-source-plugin-shadow", "published-upgrade-survivor-2026.4.29-tilde-log-path", "published-upgrade-survivor-2026.4.29-versioned-runtime-deps", "published-upgrade-survivor-2026.3.13", "published-upgrade-survivor-2026.3.13-feishu-channel", "published-upgrade-survivor-2026.3.13-bootstrap-persona", "published-upgrade-survivor-2026.3.13-configured-plugin-installs", + "published-upgrade-survivor-2026.3.13-stale-source-plugin-shadow", "published-upgrade-survivor-2026.3.13-tilde-log-path", "published-upgrade-survivor-2026.3.13-versioned-runtime-deps", ]); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 81b9ca8edfb..666a3d05339 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -253,6 +253,11 @@ describe("package artifact reuse", () => { expect(publishedUpgradeSurvivor).toContain( "assert_legacy_plugin_dependency_debris_before_doctor", ); + expect(publishedUpgradeSurvivor.indexOf("phase seed-source-only-plugin-shadow")).toBeLessThan( + publishedUpgradeSurvivor.indexOf("phase assert-baseline"), + ); + expect(publishedUpgradeSurvivor).toContain('"id": "opik-openclaw"'); + expect(publishedUpgradeSurvivor).toContain('"configSchema": {'); expect(publishedUpgradeSurvivor).toContain( "Legacy plugin dependency debris was already removed before doctor", ); From 4820b701a597cedf646d6283579d14c7d9547ec0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 21:24:14 +0100 Subject: [PATCH 040/107] fix(plugins): fall back from invalid beta npm updates --- CHANGELOG.md | 6 +++ docs/cli/update.md | 5 ++- docs/plugins/manage-plugins.md | 4 +- src/plugins/update.test.ts | 81 ++++++++++++++++++++++++++++++++++ src/plugins/update.ts | 74 ++++++++++++++++++++++--------- 5 files changed, 145 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0fdb59160e..d95a6336389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,11 @@ Docs: https://docs.openclaw.ai - Discord/status: add degraded Discord transport and gateway event-loop starvation signals to `openclaw channels status`, `openclaw status --deep`, and fetch-timeout logs so intermittent socket resets do not look like a healthy running channel. (#76327) Thanks @joshavant. - Providers/OpenRouter: add opt-in response caching params that send OpenRouter's `X-OpenRouter-Cache`, `X-OpenRouter-Cache-TTL`, and cache-clear headers only on verified OpenRouter routes. Thanks @vincentkoc. - Providers/OpenRouter: expand app-attribution categories so OpenClaw advertises coding, programming, writing, chat, and personal-agent usage on verified OpenRouter routes. Thanks @vincentkoc. +- Plugins/update: make package upgrades swap pnpm/npm-prefix installs cleanly, keep legacy plugin install runtime chunks working, and on the beta channel fall back default-line npm plugins to default/latest when plugin beta releases are missing or fail install validation. Thanks @vincentkoc and @joshavant. +- Channels/WhatsApp: support explicit WhatsApp Channel/Newsletter `@newsletter` outbound message targets with channel session metadata instead of DM routing. Fixes #13417; carries forward the narrow outbound target idea from #13424. Thanks @vincentkoc and @agentz-manfred. +- Exec approvals: add a tree-sitter-backed shell command explainer for future approval and command-review surfaces. (#75004) Thanks @jesse-merhi. +- Agents/sandbox: store sandbox container and browser registry entries as per-runtime shard files, reducing unrelated session lock contention while `openclaw doctor --fix` migrates legacy monolithic registry files. (#74831) Thanks @luckylhb90. +- Plugins/ClawHub: annotate 429 errors from ClawHub with the reset window from `RateLimit-Reset`/`Retry-After` and append a `Sign in for higher rate limits.` hint when the request was unauthenticated, so users can see when downloads will recover and how to lift the cap. Thanks @romneyda. - Plugins/runtime state: add `registerIfAbsent` for atomic keyed-store dedupe claims that return whether a plugin successfully claimed a key without overwriting an existing live value. Thanks @amknight. - Plugin SDK: add plugin-owned `SessionEntry` slot projection and scoped trusted-policy session extension reads. (#75609; replaces part of #73384/#74483) Thanks @100yenadmin. @@ -67,6 +72,7 @@ Docs: https://docs.openclaw.ai - Gateway/status: label Linux managed gateway services as `systemd user`, making status output explicit about the user-service scope instead of implying a system-level unit. Thanks @vincentkoc. - Plugins/install: remove the previous managed plugin directory when a reinstall switches sources, so stale ClawHub and npm copies no longer keep duplicate plugin ids in discovery after the new install wins. Thanks @vincentkoc. - Plugins/install: let official plugin reinstall recovery repair source-only installed runtime shadows, so `openclaw plugins install npm:@openclaw/discord --force` can replace the bad package instead of stopping at stale config validation. Thanks @vincentkoc. +- CLI/update: stage pnpm-detected npm-layout global package updates through a clean npm prefix swap, keep plugin install runtime imports behind a stable alias, and ship legacy install-runtime aliases back to `2026.3.22`, preventing stale overlay chunks from breaking plugin post-update sync. Thanks @vincentkoc. - Plugins/commands: allow the official ClawHub Codex plugin package to keep reserved `/codex` command ownership, matching the existing npm-managed Codex package behavior. Thanks @vincentkoc. - Auth/OpenAI Codex: rewrite invalidated per-agent Codex auth-order and session profile overrides toward a healthy relogin profile, so revoked OAuth accounts do not stay pinned after signing in again. Thanks @BunsDev. - Plugins/commands: scope QQBot framework slash commands to the QQBot channel so `/bot-*` command handlers and native specs do not leak onto unrelated chat surfaces. Thanks @vincentkoc. diff --git a/docs/cli/update.md b/docs/cli/update.md index 60a1269b1fb..f5cd9467ee7 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -168,8 +168,9 @@ manually. On the beta update channel, tracked npm and ClawHub plugin installs that follow the default/latest line try a plugin `@beta` release first. If the plugin has no -beta release, OpenClaw falls back to the recorded default/latest spec. Exact -versions and explicit tags are not rewritten. +beta release, OpenClaw falls back to the recorded default/latest spec. For npm +plugins, OpenClaw also falls back when the beta package exists but fails install +validation. Exact versions and explicit tags are not rewritten. If an exact pinned npm plugin update resolves to an artifact whose integrity differs from the stored install record, `openclaw update` aborts that plugin artifact update instead of installing it. Reinstall or update the plugin explicitly only after verifying that you trust the new artifact. diff --git a/docs/plugins/manage-plugins.md b/docs/plugins/manage-plugins.md index dbb1061527b..1384983e2af 100644 --- a/docs/plugins/manage-plugins.md +++ b/docs/plugins/manage-plugins.md @@ -92,7 +92,9 @@ when it was previously pinned to an exact version or tag. When `openclaw update` runs on the beta channel, default-line npm and ClawHub plugin records try the matching plugin `@beta` release first. If that beta release does not exist, OpenClaw falls back to the recorded default/latest spec. -Exact versions and explicit tags such as `@rc` or `@beta` are preserved. +For npm plugins, OpenClaw also falls back when the beta package exists but fails +install validation. Exact versions and explicit tags such as `@rc` or `@beta` +are preserved. ## Uninstall plugins diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 1f44d43c48f..91f9028448a 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -1293,6 +1293,87 @@ describe("updateNpmInstalledPlugins", () => { }); }); + it("falls back to the default npm spec when the beta package exists but is invalid", async () => { + installPluginFromNpmSpecMock + .mockResolvedValueOnce({ + ok: false, + error: "Installed plugin package uses a TypeScript entry without compiled runtime output.", + }) + .mockResolvedValueOnce( + createSuccessfulNpmUpdateResult({ + pluginId: "openclaw-codex-app-server", + targetDir: "/tmp/openclaw-codex-app-server", + version: "0.2.6", + npmResolution: { + name: "openclaw-codex-app-server", + version: "0.2.6", + resolvedSpec: "openclaw-codex-app-server@0.2.6", + }, + }), + ); + + const warnMessages: string[] = []; + const result = await updateNpmInstalledPlugins({ + config: createCodexAppServerInstallConfig({ + spec: "openclaw-codex-app-server", + }), + pluginIds: ["openclaw-codex-app-server"], + updateChannel: "beta", + logger: { warn: (msg) => warnMessages.push(msg) }, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + spec: "openclaw-codex-app-server@beta", + }), + ); + expect(installPluginFromNpmSpecMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + spec: "openclaw-codex-app-server", + }), + ); + expect(warnMessages).toEqual([expect.stringContaining("failed beta npm update")]); + expectCodexAppServerInstallState({ + result, + spec: "openclaw-codex-app-server", + version: "0.2.6", + resolvedSpec: "openclaw-codex-app-server@0.2.6", + }); + }); + + it("reports the fallback npm spec when beta fallback also fails", async () => { + installPluginFromNpmSpecMock + .mockResolvedValueOnce({ + ok: false, + error: "Installed plugin package uses a TypeScript entry without compiled runtime output.", + }) + .mockResolvedValueOnce({ + ok: false, + code: "npm_package_not_found", + error: "npm package not found", + }); + + const result = await updateNpmInstalledPlugins({ + config: createCodexAppServerInstallConfig({ + spec: "openclaw-codex-app-server", + }), + pluginIds: ["openclaw-codex-app-server"], + updateChannel: "beta", + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(2); + expect(result.outcomes).toEqual([ + { + pluginId: "openclaw-codex-app-server", + status: "error", + message: + "Failed to update openclaw-codex-app-server: npm package not found for openclaw-codex-app-server.", + }, + ]); + }); + it("preserves explicit npm tags when updating on the beta channel", async () => { installPluginFromNpmSpecMock.mockResolvedValue( createSuccessfulNpmUpdateResult({ diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 4d7f098185c..2ca5508ab84 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -431,13 +431,31 @@ function shouldFallbackBetaClawHubUpdate(result: { ok: false; code?: string }): return shouldFallbackClawHubBridgeToNpm(result); } -function shouldFallbackBetaNpmUpdate(result: { ok: false; code?: string; error: string }): boolean { - if (result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND) { - return true; +function describeBetaNpmFallback(params: { + pluginId: string; + betaSpec: string | undefined; + fallbackSpec: string; + result: { ok: false; code?: string; error: string }; +}): string { + const betaSpec = params.betaSpec ?? "the beta npm release"; + const missingBeta = + params.result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND || + /\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test( + params.result.error, + ); + const reason = missingBeta ? "has no beta npm release" : "failed beta npm update"; + return `Plugin "${params.pluginId}" ${reason} for ${betaSpec}; falling back to ${params.fallbackSpec}.`; +} + +function npmUpdateFailureSpec(params: { + effectiveSpec: string | undefined; + fallbackSpec: string | undefined; + usedFallback: boolean; +}): string { + if (params.usedFallback && params.fallbackSpec) { + return params.fallbackSpec; } - return /\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test( - result.error, - ); + return params.effectiveSpec ?? params.fallbackSpec ?? "unknown"; } function isDefaultNpmSpecForBetaUpdate(spec: string): { name: string } | null { @@ -1026,15 +1044,17 @@ export async function updateNpmInstalledPlugins(params: { }); continue; } - if ( - !probe.ok && - record.source === "npm" && - npmSpecs?.fallbackSpec && - shouldFallbackBetaNpmUpdate(probe) - ) { + let usedNpmFallback = false; + if (!probe.ok && record.source === "npm" && npmSpecs?.fallbackSpec) { logger.warn?.( - `Plugin "${pluginId}" has no beta npm release for ${npmSpecs.fallbackLabel ?? effectiveSpec}; falling back to ${npmSpecs.fallbackSpec}.`, + describeBetaNpmFallback({ + pluginId, + betaSpec: npmSpecs.fallbackLabel ?? effectiveSpec, + fallbackSpec: npmSpecs.fallbackSpec, + result: probe, + }), ); + usedNpmFallback = true; probe = await installPluginFromNpmSpec({ spec: npmSpecs.fallbackSpec, mode: "update", @@ -1083,7 +1103,11 @@ export async function updateNpmInstalledPlugins(params: { record.source === "npm" ? formatNpmInstallFailure({ pluginId, - spec: effectiveSpec!, + spec: npmUpdateFailureSpec({ + effectiveSpec, + fallbackSpec: npmSpecs?.fallbackSpec, + usedFallback: usedNpmFallback, + }), phase: "check", result: probe, }) @@ -1207,15 +1231,17 @@ export async function updateNpmInstalledPlugins(params: { }); continue; } - if ( - !result.ok && - record.source === "npm" && - npmSpecs?.fallbackSpec && - shouldFallbackBetaNpmUpdate(result) - ) { + let usedNpmFallback = false; + if (!result.ok && record.source === "npm" && npmSpecs?.fallbackSpec) { logger.warn?.( - `Plugin "${pluginId}" has no beta npm release for ${npmSpecs.fallbackLabel ?? effectiveSpec}; falling back to ${npmSpecs.fallbackSpec}.`, + describeBetaNpmFallback({ + pluginId, + betaSpec: npmSpecs.fallbackLabel ?? effectiveSpec, + fallbackSpec: npmSpecs.fallbackSpec, + result, + }), ); + usedNpmFallback = true; result = await installPluginFromNpmSpec({ spec: npmSpecs.fallbackSpec, mode: "update", @@ -1262,7 +1288,11 @@ export async function updateNpmInstalledPlugins(params: { record.source === "npm" ? formatNpmInstallFailure({ pluginId, - spec: effectiveSpec!, + spec: npmUpdateFailureSpec({ + effectiveSpec, + fallbackSpec: npmSpecs?.fallbackSpec, + usedFallback: usedNpmFallback, + }), phase: "update", result: result, }) From 59b5058cdb404e1583d1955c30df6f52bc90ee5c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 13:31:34 -0700 Subject: [PATCH 041/107] fix(active-memory): stabilize timeout partial recovery --- CHANGELOG.md | 1 + extensions/active-memory/index.test.ts | 2 +- extensions/active-memory/index.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d95a6336389..2b004aa6731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. +- Active Memory: give timeout partial transcript recovery enough abort-settle headroom so temporary recall summaries are returned before cleanup. Thanks @vincentkoc. - Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob. - CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc. - Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc. diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index ff6b452c36a..d1e56d193fb 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -3283,7 +3283,6 @@ describe("active-memory plugin", () => { `^${escapeRegExp(expectedDir)}${escapeRegExp(path.sep)}active-memory-[a-z0-9]+-[a-f0-9]{8}\\.jsonl$`, ), ); - expect(rmSpy).not.toHaveBeenCalled(); expect( vi .mocked(api.logger.info) @@ -3291,6 +3290,7 @@ describe("active-memory plugin", () => { String(call[0]).includes(`transcript=${expectedDir}${path.sep}`), ), ).toBe(true); + expect(rmSpy.mock.calls.some(([target]) => String(target).startsWith(expectedDir))).toBe(false); }); it("falls back to the default transcript directory when transcriptDir is unsafe", async () => { diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 918e4c04d81..b6f80208ae5 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -45,7 +45,7 @@ const TOGGLE_STATE_FILE = "session-toggles.json"; const DEFAULT_PARTIAL_TRANSCRIPT_MAX_CHARS = 32_000; const DEFAULT_TRANSCRIPT_READ_MAX_LINES = 2_000; const DEFAULT_TRANSCRIPT_READ_MAX_BYTES = 50 * 1024 * 1024; -const TIMEOUT_PARTIAL_DATA_GRACE_MS = 50; +const TIMEOUT_PARTIAL_DATA_GRACE_MS = 500; const MAX_ACTIVE_MEMORY_SEARCH_QUERY_CHARS = 480; const TERMINAL_MEMORY_SEARCH_POLL_INTERVAL_MS = 25; From a71f90683766e92f6fdb76d65b322923d3bffb75 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 13:31:51 -0700 Subject: [PATCH 042/107] fix(browser): guard existing-session screenshots --- CHANGELOG.md | 1 + extensions/browser/src/browser/routes/agent.snapshot.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b004aa6731..0f9c67ad290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. +- Browser: enforce strict SSRF current-URL checks before existing-session screenshots, matching existing-session snapshot handling. Thanks @vincentkoc. - Active Memory: give timeout partial transcript recovery enough abort-settle headroom so temporary recall summaries are returned before cleanup. Thanks @vincentkoc. - Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob. - CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc. diff --git a/extensions/browser/src/browser/routes/agent.snapshot.ts b/extensions/browser/src/browser/routes/agent.snapshot.ts index 37446db2267..5c8a576bcc8 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.ts @@ -365,6 +365,13 @@ export function registerBrowserAgentSnapshotRoutes( enforceCurrentUrlAllowed: true, run: async ({ profileCtx, tab, cdpUrl }) => { if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { + const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx); + if (ssrfPolicyOpts.ssrfPolicy) { + await assertBrowserNavigationResultAllowed({ + url: tab.url, + ...ssrfPolicyOpts, + }); + } if (element) { return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement); } From e2eb8e3cfe6f746e72356ddc9fc3611767cb8220 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 14:01:33 -0700 Subject: [PATCH 043/107] test(plugins): harden kitchen sink live gauntlet --- docs/help/testing.md | 6 ++ extensions/qa-lab/src/gateway-child.test.ts | 38 +++++++ extensions/qa-lab/src/gateway-child.ts | 7 ++ .../src/providers/live-frontier/auth.ts | 62 +++++++++++ .../qa-lab/src/providers/shared/auth-store.ts | 9 +- .../qa-lab/src/scenario-catalog.test.ts | 14 +++ .../src/suite-runtime-agent-tools.test.ts | 38 ++++++- .../qa-lab/src/suite-runtime-agent-tools.ts | 20 +++- package.json | 1 + .../plugins/kitchen-sink-live-openai.md | 102 +++++++++++++++++- 10 files changed, 287 insertions(+), 10 deletions(-) diff --git a/docs/help/testing.md b/docs/help/testing.md index e1e4fec4a6f..423d06b249b 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -144,6 +144,12 @@ inside every shard. `aimock` starts a local AIMock-backed provider server for experimental fixture and protocol-mock coverage without replacing the scenario-aware `mock-openai` lane. +- `pnpm test:plugins:kitchen-sink-live` + - Runs the live OpenAI Kitchen Sink plugin gauntlet through QA Lab. It + installs the external Kitchen Sink package, verifies the plugin SDK surface + inventory, probes `/healthz` and `/readyz`, records gateway CPU/RSS + evidence, runs a live OpenAI turn, and checks adversarial diagnostics. + Requires live OpenAI auth such as `OPENAI_API_KEY`. - `pnpm test:gateway:cpu-scenarios` - Runs the gateway startup bench plus a small mock QA Lab scenario pack (`channel-chat-baseline`, `memory-failure-fallback`, diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index 3261dde31f7..944bbb670b8 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -407,6 +407,44 @@ describe("buildQaRuntimeEnv", () => { }); }); + it("stages live env API-key profiles for isolated QA workers", async () => { + const stateDir = await mkdtemp(path.join(os.tmpdir(), "qa-live-api-key-state-")); + cleanups.push(async () => { + await rm(stateDir, { recursive: true, force: true }); + }); + + const cfg = await __testing.stageQaLiveApiKeyProfiles({ + cfg: {}, + stateDir, + providerIds: ["openai"], + env: { + OPENAI_API_KEY: "qa-live-not-a-real-key", + }, + }); + + expect(cfg.auth?.profiles?.["qa-live-openai-env"]).toMatchObject({ + provider: "openai", + mode: "api_key", + displayName: "QA live openai env credential", + }); + + for (const agentId of ["main", "qa"]) { + const storeRaw = await readFile( + path.join(stateDir, "agents", agentId, "agent", "auth-profiles.json"), + "utf8", + ); + expect(JSON.parse(storeRaw)).toMatchObject({ + profiles: { + "qa-live-openai-env": { + type: "api_key", + provider: "openai", + key: "qa-live-not-a-real-key", + }, + }, + }); + } + }); + it("stages placeholder mock auth profiles per agent dir so mock-openai runs can resolve credentials", async () => { const stateDir = await mkdtemp(path.join(os.tmpdir(), "qa-mock-auth-")); cleanups.push(async () => { diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts index 62fdae18243..ba706677cb1 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -34,6 +34,7 @@ import { DEFAULT_QA_PROVIDER_MODE, getQaProvider } from "./providers/index.js"; import { QA_LIVE_ANTHROPIC_SETUP_TOKEN_ENV, QA_LIVE_SETUP_TOKEN_VALUE_ENV, + stageQaLiveApiKeyProfiles, stageQaLiveAnthropicSetupToken, } from "./providers/live-frontier/auth.js"; import { stageQaMockAuthProfiles } from "./providers/shared/mock-auth.js"; @@ -314,6 +315,7 @@ export const __testing = { redactQaGatewayDebugText, readQaLiveProviderConfigOverrides, resolveQaGatewayChildProviderMode, + stageQaLiveApiKeyProfiles, stageQaLiveAnthropicSetupToken, stageQaMockAuthProfiles, resolveQaLiveCliAuthEnv, @@ -573,6 +575,11 @@ export async function startQaGatewayChild(params: { }); const buildStagedGatewayConfig = async (gatewayPort: number) => { let cfg = buildGatewayConfig(gatewayPort); + cfg = await stageQaLiveApiKeyProfiles({ + cfg, + stateDir, + providerIds: liveProviderIds, + }); cfg = await stageQaLiveAnthropicSetupToken({ cfg, stateDir, diff --git a/extensions/qa-lab/src/providers/live-frontier/auth.ts b/extensions/qa-lab/src/providers/live-frontier/auth.ts index 797582b942f..bfd73bd37cf 100644 --- a/extensions/qa-lab/src/providers/live-frontier/auth.ts +++ b/extensions/qa-lab/src/providers/live-frontier/auth.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { applyAuthProfileConfig, + resolveEnvApiKey, validateAnthropicSetupToken, } from "openclaw/plugin-sdk/provider-auth"; import { resolveQaAgentAuthDir, writeQaAuthProfiles } from "../shared/auth-store.js"; @@ -9,6 +10,11 @@ export const QA_LIVE_ANTHROPIC_SETUP_TOKEN_ENV = "OPENCLAW_QA_LIVE_ANTHROPIC_SET export const QA_LIVE_SETUP_TOKEN_VALUE_ENV = "OPENCLAW_LIVE_SETUP_TOKEN_VALUE"; const QA_LIVE_ANTHROPIC_SETUP_TOKEN_PROFILE_ENV = "OPENCLAW_QA_LIVE_ANTHROPIC_SETUP_TOKEN_PROFILE"; const QA_LIVE_ANTHROPIC_SETUP_TOKEN_PROFILE_ID = "anthropic:qa-setup-token"; +const QA_LIVE_API_KEY_AGENT_IDS = Object.freeze(["main", "qa"] as const); + +function buildQaLiveApiKeyProfileId(provider: string): string { + return `qa-live-${provider.replaceAll(/[^a-z0-9_-]/giu, "-")}-env`; +} function resolveQaLiveAnthropicSetupToken(env: NodeJS.ProcessEnv = process.env) { const token = ( @@ -55,3 +61,59 @@ export async function stageQaLiveAnthropicSetupToken(params: { displayName: "QA setup-token", }); } + +export async function stageQaLiveApiKeyProfiles(params: { + cfg: OpenClawConfig; + stateDir: string; + providerIds: readonly string[]; + env?: NodeJS.ProcessEnv; + agentIds?: readonly string[]; +}): Promise { + const env = params.env ?? process.env; + const providerIds = [...new Set(params.providerIds.map((providerId) => providerId.trim()))] + .filter((providerId) => providerId.length > 0) + .toSorted(); + const profiles: Record< + string, + { + type: "api_key"; + provider: string; + key: string; + displayName: string; + } + > = {}; + let next = params.cfg; + for (const providerId of providerIds) { + const resolved = resolveEnvApiKey(providerId, env, { config: next }); + if (!resolved?.apiKey) { + continue; + } + const profileId = buildQaLiveApiKeyProfileId(providerId); + const displayName = `QA live ${providerId} env credential`; + profiles[profileId] = { + type: "api_key", + provider: providerId, + key: resolved.apiKey, + displayName, + }; + next = applyAuthProfileConfig(next, { + profileId, + provider: providerId, + mode: "api_key", + displayName, + }); + } + if (Object.keys(profiles).length === 0) { + return next; + } + const agentIds = [...new Set(params.agentIds ?? QA_LIVE_API_KEY_AGENT_IDS)]; + await Promise.all( + agentIds.map((agentId) => + writeQaAuthProfiles({ + agentDir: resolveQaAgentAuthDir({ stateDir: params.stateDir, agentId }), + profiles, + }), + ), + ); + return next; +} diff --git a/extensions/qa-lab/src/providers/shared/auth-store.ts b/extensions/qa-lab/src/providers/shared/auth-store.ts index d18f7420031..29195750873 100644 --- a/extensions/qa-lab/src/providers/shared/auth-store.ts +++ b/extensions/qa-lab/src/providers/shared/auth-store.ts @@ -22,10 +22,15 @@ export async function writeQaAuthProfiles(params: { agentDir: string; profiles: Record; }): Promise { + const authPath = path.join(params.agentDir, "auth-profiles.json"); + const existing = await fs + .readFile(authPath, "utf8") + .then((raw) => JSON.parse(raw) as { profiles?: Record }) + .catch(() => ({ profiles: {} })); await fs.mkdir(params.agentDir, { recursive: true }); await fs.writeFile( - path.join(params.agentDir, "auth-profiles.json"), - `${JSON.stringify({ version: 1, profiles: params.profiles }, null, 2)}\n`, + authPath, + `${JSON.stringify({ version: 1, profiles: { ...existing.profiles, ...params.profiles } }, null, 2)}\n`, "utf8", ); } diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index 8481275998c..a4c64f631f9 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -187,6 +187,7 @@ describe("qa scenario catalog", () => { pluginId?: string; pluginPersonality?: string; adversarialPersonality?: string; + expectedSurfaceIds?: Record; expectedAdversarialDiagnostics?: string[]; } | undefined; @@ -198,9 +199,22 @@ describe("qa scenario catalog", () => { expect(config?.pluginId).toBe("openclaw-kitchen-sink-fixture"); expect(config?.pluginPersonality).toBe("conformance"); expect(config?.adversarialPersonality).toBe("adversarial"); + expect(config?.expectedSurfaceIds?.webSearchProviderIds).toContain( + "kitchen-sink-web-search-provider", + ); + expect(config?.expectedSurfaceIds?.realtimeVoiceProviderIds).toContain( + "kitchen-sink-realtime-voice-provider", + ); expect(config?.expectedAdversarialDiagnostics).toContain( "only bundled plugins can register agent tool result middleware", ); + expect(config?.expectedAdversarialDiagnostics).toContain( + "control UI descriptor registration requires id, surface, label, and valid optional fields", + ); + expect( + config?.expectedAdversarialDiagnostics?.every((entry) => typeof entry === "string"), + ).toBe(true); + expect(JSON.stringify(scenario.execution.flow)).toContain("--runtime"); expect(scenario.execution.flow?.steps.map((step) => step.name)).toEqual([ "installs and inspects the Kitchen Sink plugin", "restarts gateway with Kitchen Sink configured", diff --git a/extensions/qa-lab/src/suite-runtime-agent-tools.test.ts b/extensions/qa-lab/src/suite-runtime-agent-tools.test.ts index efb969ce383..74c727d1e73 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-tools.test.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-tools.test.ts @@ -51,6 +51,8 @@ import { import { createTempDirHarness } from "./temp-dir.test-helper.js"; const { cleanup, makeTempDir } = createTempDirHarness(); +const repoRoot = "/repo/openclaw"; +const gatewayTempRoot = "/tmp/openclaw-qa-runtime"; afterEach(cleanup); @@ -111,12 +113,14 @@ describe("qa suite runtime agent tools helpers", () => { callPluginToolsMcp({ env: { gateway: { + tempRoot: gatewayTempRoot, runtimeEnv: { PATH: "/usr/bin", OPENCLAW_KEY: "1", EMPTY: undefined, }, }, + repoRoot, } as never, toolName: "plugin.echo", args: { text: "hello" }, @@ -127,8 +131,13 @@ describe("qa suite runtime agent tools helpers", () => { expect(stdioTransportMock).toHaveBeenCalledWith({ command: "/usr/bin/node", - args: ["--import", "tsx", "src/mcp/plugin-tools-serve.ts"], + args: [ + "--import", + expect.stringContaining(path.join("node_modules", "tsx")), + path.join(repoRoot, "src", "mcp", "plugin-tools-serve.ts"), + ], stderr: "pipe", + cwd: gatewayTempRoot, env: { PATH: "/usr/bin", OPENCLAW_KEY: "1", @@ -140,4 +149,31 @@ describe("qa suite runtime agent tools helpers", () => { }); expect(closeMock).toHaveBeenCalled(); }); + + it("reports available plugin-tools MCP names when the requested tool is missing", async () => { + listToolsMock.mockResolvedValueOnce({ + tools: [{ name: "plugin.beta" }, { name: "plugin.alpha" }] as never[], + }); + + await expect( + callPluginToolsMcp({ + env: { + gateway: { + tempRoot: gatewayTempRoot, + runtimeEnv: { + PATH: "/usr/bin", + }, + }, + repoRoot, + } as never, + toolName: "plugin.missing", + args: {}, + }), + ).rejects.toThrow( + "MCP tool missing: plugin.missing; available tools: plugin.alpha, plugin.beta", + ); + + expect(callToolMock).not.toHaveBeenCalled(); + expect(closeMock).toHaveBeenCalled(); + }); }); diff --git a/extensions/qa-lab/src/suite-runtime-agent-tools.ts b/extensions/qa-lab/src/suite-runtime-agent-tools.ts index c57fdf1a193..977bb26c243 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-tools.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-tools.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { createRequire } from "node:module"; import path from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; @@ -11,6 +12,8 @@ import type { QaTransportActionName, } from "./suite-runtime-types.js"; +const requireFromHere = createRequire(import.meta.url); + function findSkill(skills: QaSkillStatusEntry[], name: string) { return skills.find((skill) => skill.name === name); } @@ -28,7 +31,7 @@ async function writeWorkspaceSkill(params: { } async function callPluginToolsMcp(params: { - env: Pick; + env: Pick; toolName: string; args: Record; }) { @@ -40,8 +43,13 @@ async function callPluginToolsMcp(params: { const nodeExecPath = await resolveQaNodeExecPath(); const transport = new StdioClientTransport({ command: nodeExecPath, - args: ["--import", "tsx", "src/mcp/plugin-tools-serve.ts"], + args: [ + "--import", + requireFromHere.resolve("tsx"), + path.join(params.env.repoRoot, "src/mcp/plugin-tools-serve.ts"), + ], stderr: "pipe", + cwd: params.env.gateway.tempRoot, env: transportEnv, }); const client = new Client({ name: "openclaw-qa-suite", version: "0.0.0" }, {}); @@ -50,7 +58,13 @@ async function callPluginToolsMcp(params: { const listed = await client.listTools(); const tool = listed.tools.find((entry) => entry.name === params.toolName); if (!tool) { - throw new Error(`MCP tool missing: ${params.toolName}`); + const availableTools = listed.tools + .map((entry) => entry.name) + .filter((name): name is string => typeof name === "string" && name.length > 0) + .toSorted(); + throw new Error( + `MCP tool missing: ${params.toolName}; available tools: ${availableTools.join(", ") || ""}`, + ); } return await client.callTool({ name: params.toolName, diff --git a/package.json b/package.json index 5551e9b955b..bc8ecbb19c2 100644 --- a/package.json +++ b/package.json @@ -1614,6 +1614,7 @@ "test:perf:profile:main": "node scripts/run-vitest-profile.mjs main", "test:perf:profile:runner": "node scripts/run-vitest-profile.mjs runner", "test:plugins:gateway-gauntlet": "node scripts/check-plugin-gateway-gauntlet.mjs", + "test:plugins:kitchen-sink-live": "pnpm openclaw qa suite --provider-mode live-frontier --scenario kitchen-sink-live-openai", "test:sectriage": "OPENCLAW_GATEWAY_PROJECT_SHARDS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts && node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts", "test:serial": "OPENCLAW_TEST_PROJECTS_SERIAL=1 OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/test-projects.mjs", "test:stability:gateway": "OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts src/gateway/gateway-stability.test.ts && OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.logging.config.ts src/logging/diagnostic-stability-bundle.test.ts && OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.infra.config.ts src/infra/fatal-error-hooks.test.ts", diff --git a/qa/scenarios/plugins/kitchen-sink-live-openai.md b/qa/scenarios/plugins/kitchen-sink-live-openai.md index ecf62e53ccb..01de4d97914 100644 --- a/qa/scenarios/plugins/kitchen-sink-live-openai.md +++ b/qa/scenarios/plugins/kitchen-sink-live-openai.md @@ -49,12 +49,44 @@ execution: - kitchen_sink_text - kitchen_sink_search - kitchen_sink_image_job + expectedSurfaceIds: + speechProviderIds: + - kitchen-sink-speech + - kitchen-sink-speech-provider + realtimeTranscriptionProviderIds: + - kitchen-sink-realtime-transcription + - kitchen-sink-realtime-transcription-provider + realtimeVoiceProviderIds: + - kitchen-sink-realtime-voice + - kitchen-sink-realtime-voice-provider + mediaUnderstandingProviderIds: + - kitchen-sink-media + - kitchen-sink-media-understanding-provider + imageGenerationProviderIds: + - kitchen-sink-image + - kitchen-sink-image-generation-provider + videoGenerationProviderIds: + - kitchen-sink-video + - kitchen-sink-video-generation-provider + musicGenerationProviderIds: + - kitchen-sink-music + - kitchen-sink-music-generation-provider + webFetchProviderIds: + - kitchen-sink-fetch + - kitchen-sink-web-fetch-provider + webSearchProviderIds: + - kitchen-sink-search + - kitchen-sink-web-search-provider + migrationProviderIds: + - kitchen-sink-migration-providers + - kitchen-sink-migration-provider maxGatewayCpuCoreRatio: 1.5 maxGatewayRssMiB: 2048 agentTurnTimeoutMs: 120000 outboundTimeoutMs: 60000 livePrompt: "Kitchen Sink OpenAI marker. Reply exactly: KITCHEN-SINK-OPENAI-OK" expectedAdversarialDiagnostics: + - agent event subscription registration requires id and handle - only bundled plugins can register agent tool result middleware - agent harness "kitchen-sink-agent-harness" registration missing required runtime methods - channel "kitchen-sink-channel-probe" registration missing required config helpers @@ -62,9 +94,16 @@ execution: - only bundled plugins can register Codex app-server extension factories - compaction provider "kitchen-sink-compaction-provider" registration missing summarize - context engine registration missing id - - http route registration missing or invalid auth: /kitchen-sink/http-route + - control UI descriptor registration requires id, surface, label, and valid optional fields + - "http route registration missing or invalid auth: /kitchen-sink/http-route" - "plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: kitchen-sink-memory-embedding-provider" - memory prompt supplement registration missing builder + - node invoke policy registration missing commands + - session extension registration requires namespace and description + - session scheduler job registration requires unique id, sessionKey, and kind + - "plugin must declare contracts.tools for: kitchen-sink-tool" + - tool metadata registration missing toolName + - only bundled plugins can register trusted tool policies ``` ```yaml qa-flow @@ -110,6 +149,10 @@ steps: ...(cfg.channels || {}), [config.channelId]: { enabled: true, token: "kitchen-sink-qa" }, }; + cfg.tools = { + ...(cfg.tools || {}), + alsoAllow: [...new Set([...(cfg.tools?.alsoAllow || []), ...config.expectedToolAny])], + }; await fs.writeFile(env.gateway.configPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8"); return env.gateway.configPath; })() @@ -129,6 +172,7 @@ steps: - - plugins - inspect - expr: config.pluginId + - --runtime - --json - json: true timeoutMs: 60000 @@ -148,9 +192,22 @@ steps: channels: [...new Set([...(plugin.channelIds ?? []), ...(plugin.channels ?? [])])], providers: [...new Set([...(plugin.providerIds ?? []), ...(plugin.providers ?? [])])], tools: [...new Set([...namesFromTools, ...(contracts.tools ?? [])])], + commands: inspect.commands ?? [], + services: inspect.services ?? [], + typedHookCount: Array.isArray(inspect.typedHooks) ? inspect.typedHooks.length : 0, + hookCount: plugin.hookCount ?? 0, + surfaceIds: Object.fromEntries( + Object.keys(config.expectedSurfaceIds ?? {}) + .map((field) => [field, Array.isArray(plugin[field]) ? plugin[field] : []]) + ), + agentHarnessIds: plugin.agentHarnessIds ?? [], diagnostics: [...(pluginList.diagnostics ?? []), ...(inspect.diagnostics ?? [])] .filter((entry) => entry?.level === "error") .map((entry) => String(entry.message ?? "")), + unexpectedDiagnostics: [...new Set([...(pluginList.diagnostics ?? []), ...(inspect.diagnostics ?? [])] + .filter((entry) => entry?.level === "error") + .map((entry) => String(entry.message ?? "")) + .filter((message) => !config.expectedAdversarialDiagnostics.includes(message)))], }; })() - assert: @@ -170,9 +227,25 @@ steps: message: expr: "`Kitchen Sink tools missing from inspect output: ${JSON.stringify(inspectFacts.tools)}`" - assert: - expr: "inspectFacts.diagnostics.length === 0" + expr: "Object.entries(config.expectedSurfaceIds).every(([field, expected]) => expected.some((id) => (inspectFacts.surfaceIds[field] ?? []).includes(id)))" message: - expr: "`Kitchen Sink conformance personality emitted diagnostics: ${JSON.stringify(inspectFacts.diagnostics)}`" + expr: "`Kitchen Sink SDK provider surface missing from inspect output: ${JSON.stringify(inspectFacts.surfaceIds)}`" + - assert: + expr: "inspectFacts.commands.includes('kitchen') && inspectFacts.services.includes('kitchen-sink-service')" + message: + expr: "`Kitchen Sink command/service surfaces missing: ${JSON.stringify({ commands: inspectFacts.commands, services: inspectFacts.services })}`" + - assert: + expr: "inspectFacts.hookCount >= 30 && inspectFacts.typedHookCount >= 30" + message: + expr: "`Kitchen Sink hook surfaces missing: ${JSON.stringify({ hookCount: inspectFacts.hookCount, typedHookCount: inspectFacts.typedHookCount })}`" + - assert: + expr: "!inspectFacts.agentHarnessIds.includes('kitchen-sink-agent-harness')" + message: + expr: "`External Kitchen Sink plugin unexpectedly registered bundled-only agent harness: ${JSON.stringify(inspectFacts.agentHarnessIds)}`" + - assert: + expr: "inspectFacts.unexpectedDiagnostics.length === 0" + message: + expr: "`Kitchen Sink conformance personality emitted unexpected diagnostics: ${JSON.stringify(inspectFacts.unexpectedDiagnostics)}`" detailsExpr: inspectFacts - name: restarts gateway with Kitchen Sink configured @@ -208,12 +281,32 @@ steps: ...(cfg.channels || {}), [config.channelId]: { enabled: true, token: "kitchen-sink-qa" }, }; + cfg.tools = { + ...(cfg.tools || {}), + alsoAllow: [...new Set([...(cfg.tools?.alsoAllow || []), ...config.expectedToolAny])], + }; await fs.writeFile(ctx.configPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8"); })() - call: waitForGatewayHealthy args: - ref: env - 120000 + - call: fetchJson + saveAs: healthz + args: + - expr: "`${env.gateway.baseUrl}/healthz`" + - call: fetchJson + saveAs: readyz + args: + - expr: "`${env.gateway.baseUrl}/readyz`" + - assert: + expr: "healthz?.ok === true && healthz?.status === 'live'" + message: + expr: "`/healthz did not report live: ${JSON.stringify(healthz)}`" + - assert: + expr: "readyz?.ready === true" + message: + expr: "`/readyz did not report ready: ${JSON.stringify(readyz)}`" - call: waitForQaChannelReady args: - ref: env @@ -241,7 +334,7 @@ steps: expr: "kitchenChannelAccount?.running === true && kitchenChannelAccount?.configured === true" message: expr: "`Kitchen Sink channel did not report running+configured: ${JSON.stringify(kitchenChannelAccount)}`" - detailsExpr: kitchenChannelAccount + detailsExpr: "{ healthz, readyz, kitchenChannelAccount }" - name: exercises command inventory and MCP tool surfaces actions: @@ -390,6 +483,7 @@ steps: - - plugins - inspect - expr: config.pluginId + - --runtime - --json - json: true timeoutMs: 60000 From a9817a5f97bd68680b009f40bb63401859fb00b9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 22:00:59 +0100 Subject: [PATCH 044/107] fix(gateway): clear reply run before followup drain --- CHANGELOG.md | 1 + .../reply/agent-runner-helpers.test.ts | 20 +------ src/auto-reply/reply/agent-runner-helpers.ts | 10 ---- .../agent-runner.misc.runreplyagent.test.ts | 54 ++++++++++++++++++- src/auto-reply/reply/agent-runner.ts | 52 +++++++++--------- 5 files changed, 82 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f9c67ad290..e88dc969c0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. - Browser: enforce strict SSRF current-URL checks before existing-session screenshots, matching existing-session snapshot handling. Thanks @vincentkoc. - Active Memory: give timeout partial transcript recovery enough abort-settle headroom so temporary recall summaries are returned before cleanup. Thanks @vincentkoc. +- Gateway/chat: clear the active reply-run guard before draining queued same-session follow-up turns, so sequential `chat.send` calls no longer trip `ReplyRunAlreadyActiveError` every other request. Fixes #77485. Thanks @bws14email. - Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob. - CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc. - Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc. diff --git a/src/auto-reply/reply/agent-runner-helpers.test.ts b/src/auto-reply/reply/agent-runner-helpers.test.ts index 1d7ceaf61ff..d478af66985 100644 --- a/src/auto-reply/reply/agent-runner-helpers.test.ts +++ b/src/auto-reply/reply/agent-runner-helpers.test.ts @@ -4,8 +4,7 @@ import type { TypingSignaler } from "./typing-mode.js"; const hoisted = vi.hoisted(() => { const loadSessionStoreMock = vi.fn(); - const scheduleFollowupDrainMock = vi.fn(); - return { loadSessionStoreMock, scheduleFollowupDrainMock }; + return { loadSessionStoreMock }; }); vi.mock("../../config/sessions.js", async () => { @@ -18,18 +17,9 @@ vi.mock("../../config/sessions.js", async () => { }; }); -vi.mock("./queue.js", async () => { - const actual = await vi.importActual("./queue.js"); - return { - ...actual, - scheduleFollowupDrain: (...args: unknown[]) => hoisted.scheduleFollowupDrainMock(...args), - }; -}); - const { createShouldEmitToolOutput, createShouldEmitToolResult, - finalizeWithFollowup, isAudioPayload, signalTypingIfNeeded, } = await import("./agent-runner-helpers.js"); @@ -38,7 +28,6 @@ describe("agent runner helpers", () => { beforeEach(() => { vi.useRealTimers(); hoisted.loadSessionStoreMock.mockReset(); - hoisted.scheduleFollowupDrainMock.mockReset(); }); it("detects audio payloads from mediaUrl/mediaUrls", () => { @@ -119,13 +108,6 @@ describe("agent runner helpers", () => { expect(fallbackFull()).toBe(true); }); - it("schedules followup drain and returns the original value", () => { - const runFollowupTurn = vi.fn(); - const value = { ok: true }; - expect(finalizeWithFollowup(value, "queue-key", runFollowupTurn)).toBe(value); - expect(hoisted.scheduleFollowupDrainMock).toHaveBeenCalledWith("queue-key", runFollowupTurn); - }); - it("signals typing only when any payload has text or media", async () => { const signalRunStart = vi.fn().mockResolvedValue(undefined); const typingSignals = { signalRunStart } as unknown as TypingSignaler; diff --git a/src/auto-reply/reply/agent-runner-helpers.ts b/src/auto-reply/reply/agent-runner-helpers.ts index 00cd20ce43c..12a35381278 100644 --- a/src/auto-reply/reply/agent-runner-helpers.ts +++ b/src/auto-reply/reply/agent-runner-helpers.ts @@ -6,7 +6,6 @@ import { loadSessionStore } from "../../config/sessions.js"; import { isAudioFileName } from "../../media/mime.js"; import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; -import { scheduleFollowupDrain } from "./queue.js"; import type { TypingSignaler } from "./typing-mode.js"; const hasAudioMedia = (urls?: string[]): boolean => @@ -78,15 +77,6 @@ export const createShouldEmitToolOutput = (params: VerboseGateParams): (() => bo return createVerboseGate(params, (level) => level === "full"); }; -export const finalizeWithFollowup = ( - value: T, - queueKey: string, - runFollowupTurn: Parameters[1], -): T => { - scheduleFollowupDrain(queueKey, runFollowupTurn); - return value; -}; - export const signalTypingIfNeeded = async ( payloads: ReplyPayload[], typingSignals: TypingSignaler, diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 3fce0ad29a7..c1d0ba7cfd6 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -22,7 +22,8 @@ import { } from "../../plugins/memory-state.js"; import type { TemplateContext } from "../templating.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; -import { __testing as replyRunRegistryTesting } from "./reply-run-registry.js"; +import { scheduleFollowupDrain } from "./queue.js"; +import { __testing as replyRunRegistryTesting, replyRunRegistry } from "./reply-run-registry.js"; import { createMockTypingController } from "./test-helpers.js"; function createCliBackendTestConfig() { @@ -165,6 +166,7 @@ beforeEach(() => { clearSessionQueuesMock.mockReturnValue({ followupCleared: 0, laneCleared: 0, keys: [] }); refreshQueuedFollowupSessionMock.mockReset(); refreshQueuedFollowupSessionMock.mockResolvedValue(undefined); + vi.mocked(scheduleFollowupDrain).mockReset(); loadCronStoreMock.mockClear(); // Default: no cron jobs in store. loadCronStoreMock.mockResolvedValue({ version: 1, jobs: [] }); @@ -326,6 +328,56 @@ describe("runReplyAgent auto-compaction token update", () => { expect(stored[sessionKey].totalTokens).toBe(55_000); }); + it("starts queued followup drain only after clearing the active reply operation", async () => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 50_000, + }; + runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { agentMeta: {} }, + }); + + vi.mocked(scheduleFollowupDrain).mockImplementation((key) => { + expect(key).toBe(sessionKey); + expect(replyRunRegistry.get(sessionKey)).toBeUndefined(); + }); + + const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({ + storePath: "", + sessionEntry, + }); + + const result = await runReplyAgent({ + commandBody: "hello", + followupRun, + queueKey: sessionKey, + resolvedQueue, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + typing, + sessionCtx, + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + defaultModel: "anthropic/claude-opus-4-6", + agentCfgContextTokens: 200_000, + resolvedVerboseLevel: "off", + isNewSession: false, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: "instant", + }); + + expect(result).toMatchObject({ text: "ok" }); + expect(scheduleFollowupDrain).toHaveBeenCalledTimes(1); + }); + it("reports live diagnostic context from promptTokens, not provider usage totals", async () => { const { usageEvent } = await runBaseReplyWithAgentMeta({ tmpPrefix: "openclaw-usage-diagnostic-", diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 2485061d593..022c13106b7 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -46,7 +46,6 @@ import { runAgentTurnWithFallback } from "./agent-runner-execution.js"; import { createShouldEmitToolOutput, createShouldEmitToolResult, - finalizeWithFollowup, isAudioPayload, signalTypingIfNeeded, } from "./agent-runner-helpers.js"; @@ -71,6 +70,7 @@ import { enqueueFollowupRun, refreshQueuedFollowupSession, resolvePiSteeringModeForQueueMode, + scheduleFollowupDrain, type FollowupRun, type QueueSettings, } from "./queue.js"; @@ -1064,7 +1064,7 @@ export async function runReplyAgent(params: { // the followup queue idle if the original run already finished. const queuedBehindActiveRun = isRunActive?.() === true; if (!queuedBehindActiveRun) { - finalizeWithFollowup(undefined, queueKey, queuedRunFollowupTurn); + scheduleFollowupDrain(queueKey, queuedRunFollowupTurn); } await touchActiveSessionEntry(); if (queuedBehindActiveRun) { @@ -1148,6 +1148,11 @@ export async function runReplyAgent(params: { throw error; } let runFollowupTurn = queuedRunFollowupTurn; + let shouldDrainFollowupsAfterReplyOperationClears = false; + const returnAfterReplyOperationClearsThenDrainFollowups = (value: T): T => { + shouldDrainFollowupsAfterReplyOperationClears = true; + return value; + }; const prePreflightCompactionCount = activeSessionEntry?.compactionCount ?? 0; let preflightCompactionApplied = false; @@ -1283,7 +1288,7 @@ export async function runReplyAgent(params: { if (!replyOperation.result) { replyOperation.fail("run_failed", new Error("reply operation exited with final payload")); } - return finalizeWithFollowup(runOutcome.payload, queueKey, runFollowupTurn); + return returnAfterReplyOperationClearsThenDrainFollowups(runOutcome.payload); } const { @@ -1416,7 +1421,7 @@ export async function runReplyAgent(params: { // Otherwise, a late typing trigger (e.g. from a tool callback) can outlive the run and // keep the typing indicator stuck. if (payloadArray.length === 0) { - return finalizeWithFollowup(undefined, queueKey, runFollowupTurn); + return returnAfterReplyOperationClearsThenDrainFollowups(undefined); } const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid; @@ -1448,7 +1453,7 @@ export async function runReplyAgent(params: { didLogHeartbeatStrip = payloadResult.didLogHeartbeatStrip; if (replyPayloads.length === 0) { - return finalizeWithFollowup(undefined, queueKey, runFollowupTurn); + return returnAfterReplyOperationClearsThenDrainFollowups(undefined); } const successfulCronAdds = runResult.successfulCronAdds ?? 0; @@ -1865,10 +1870,8 @@ export async function runReplyAgent(params: { } } - const result = finalizeWithFollowup( + const result = returnAfterReplyOperationClearsThenDrainFollowups( finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads, - queueKey, - runFollowupTurn, ); return result; @@ -1877,38 +1880,37 @@ export async function runReplyAgent(params: { replyOperation.result?.kind === "aborted" && replyOperation.result.code === "aborted_for_restart" ) { - return finalizeWithFollowup( - { text: "⚠️ Gateway is restarting. Please wait a few seconds and try again." }, - queueKey, - runFollowupTurn, - ); + return returnAfterReplyOperationClearsThenDrainFollowups({ + text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.", + }); } if (replyOperation.result?.kind === "aborted") { - return finalizeWithFollowup({ text: SILENT_REPLY_TOKEN }, queueKey, runFollowupTurn); + return returnAfterReplyOperationClearsThenDrainFollowups({ text: SILENT_REPLY_TOKEN }); } if (error instanceof GatewayDrainingError) { replyOperation.fail("gateway_draining", error); - return finalizeWithFollowup( - { text: "⚠️ Gateway is restarting. Please wait a few seconds and try again." }, - queueKey, - runFollowupTurn, - ); + return returnAfterReplyOperationClearsThenDrainFollowups({ + text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.", + }); } if (error instanceof CommandLaneClearedError) { replyOperation.fail("command_lane_cleared", error); - return finalizeWithFollowup( - { text: "⚠️ Gateway is restarting. Please wait a few seconds and try again." }, - queueKey, - runFollowupTurn, - ); + return returnAfterReplyOperationClearsThenDrainFollowups({ + text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.", + }); } replyOperation.fail("run_failed", error); // Keep the followup queue moving even when an unexpected exception escapes // the run path; the caller still receives the original error. - finalizeWithFollowup(undefined, queueKey, runFollowupTurn); + returnAfterReplyOperationClearsThenDrainFollowups(undefined); throw error; } finally { replyOperation.complete(); + if (shouldDrainFollowupsAfterReplyOperationClears) { + // Same-session follow-up turns create their own ReplyOperation; start them + // only after this run clears the active-run guard. + scheduleFollowupDrain(queueKey, runFollowupTurn); + } blockReplyPipeline?.stop(); typing.markRunComplete(); // Safety net: the dispatcher's onIdle callback normally fires From 02ac7dc5a62e1e7726a7436b3e0a79ecd65bd696 Mon Sep 17 00:00:00 2001 From: Sally O'Malley Date: Mon, 4 May 2026 17:05:05 -0400 Subject: [PATCH 045/107] fix(openrouter): keep DeepSeek V4 reasoning effort valid (#77423) Summary: - The PR removes `max` from OpenRouter DeepSeek V4 thinking profiles, maps stale OpenRouter `max` overrides to `xhigh`, preserves direct DeepSeek behavior, and updates docs, tests, and changelog. - Reproducibility: yes. Source inspection on current main shows OpenRouter DeepSeek V4 advertises `max` and se ... ffort: "max"`, matching the linked 400 logs; I did not need a live OpenRouter request for this assist pass. Automerge notes: - Ran the ClawSweeper repair loop before final review. - Addressed earlier ClawSweeper review findings before merge. - Included post-review commit in the final squash: docs(changelog): credit OpenRouter duplicate fix - Included post-review commit in the final squash: fix(openrouter): keep DeepSeek V4 reasoning effort valid Validation: - ClawSweeper review passed for head becdea4223be0cbb6806d92d11ca4307d19bfc3f. - Required merge gates passed before the squash merge. Prepared head SHA: becdea4223be0cbb6806d92d11ca4307d19bfc3f Review: https://github.com/openclaw/openclaw/pull/77423#issuecomment-4372880583 Co-authored-by: sallyom Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/providers/openrouter.md | 4 +- docs/tools/thinking.md | 3 +- extensions/openrouter/index.test.ts | 48 +++++++++++++++++++++++- extensions/openrouter/stream.ts | 33 +++++++++++----- extensions/openrouter/thinking-policy.ts | 1 - src/plugin-sdk/provider-stream-shared.ts | 9 ++++- 7 files changed, 83 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e88dc969c0f..655136a6c79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. - Active Memory: send a bounded latest-message search query to the recall worker so channel/runtime metadata does not become the memory search string. Fixes #65309. Thanks @joeykrug, @westley3601, @pimenov, and @tasi333. - fix(device-pair): require pairing scope for pair command [AI]. (#76377) Thanks @pgondhi987. +- Providers/OpenRouter: keep DeepSeek V4 `reasoning_effort` on OpenRouter-supported values, mapping stale `max` thinking overrides to `xhigh` so `openrouter/deepseek/deepseek-v4-pro` no longer fails with OpenRouter's invalid-effort 400. Fixes #77350. (#77423) Thanks @krllagent, @mushuiyu886, and @sallyom. - fix(qqbot): keep private commands off framework surface [AI]. (#77212) Thanks @pgondhi987. - Claude CLI: honor non-off `/think` levels by passing Claude Code's session-scoped `--effort` flag through the CLI backend seam, so chat bridges no longer show an inert thinking control. Fixes #77303. Thanks @Petr1t. - Agents/subagents: refresh deferred final-delivery payloads when same-session completion output changes, so retried parent notifications use the final child summary instead of stale progress text. Thanks @vincentkoc. diff --git a/docs/providers/openrouter.md b/docs/providers/openrouter.md index cf1861f7f0f..f0064bc1c4b 100644 --- a/docs/providers/openrouter.md +++ b/docs/providers/openrouter.md @@ -211,7 +211,9 @@ does **not** inject those OpenRouter-specific headers or Anthropic cache markers On verified OpenRouter routes, `openrouter/deepseek/deepseek-v4-flash` and `openrouter/deepseek/deepseek-v4-pro` fill missing `reasoning_content` on replayed assistant turns so thinking/tool conversations keep DeepSeek V4's - required follow-up shape. + required follow-up shape. OpenClaw sends OpenRouter-supported + `reasoning_effort` values for these routes; `xhigh` is the highest advertised + level, and stale `max` overrides are mapped to `xhigh`. diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index 659cd27ce43..f7e97be51f2 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -26,7 +26,8 @@ title: "Thinking levels" - Anthropic Claude Opus 4.7 does not default to adaptive thinking. Its API effort default remains provider-owned unless you explicitly set a thinking level. - Anthropic Claude Opus 4.7 maps `/think xhigh` to adaptive thinking plus `output_config.effort: "xhigh"`, because `/think` is a thinking directive and `xhigh` is the Opus 4.7 effort setting. - Anthropic Claude Opus 4.7 also exposes `/think max`; it maps to the same provider-owned max effort path. - - DeepSeek V4 models expose `/think xhigh|max`; both map to DeepSeek `reasoning_effort: "max"` while lower non-off levels map to `high`. + - Direct DeepSeek V4 models expose `/think xhigh|max`; both map to DeepSeek `reasoning_effort: "max"` while lower non-off levels map to `high`. + - OpenRouter-routed DeepSeek V4 models expose `/think xhigh` and send OpenRouter-supported `reasoning_effort` values. Stored `max` overrides fall back to `xhigh`. - Ollama thinking-capable models expose `/think low|medium|high|max`; `max` maps to native `think: "high"` because Ollama's native API accepts `low`, `medium`, and `high` effort strings. - OpenAI GPT models map `/think` through model-specific Responses API effort support. `/think off` sends `reasoning.effort: "none"` only when the target model supports it; otherwise OpenClaw omits the disabled reasoning payload instead of sending an unsupported value. - Custom OpenAI-compatible catalog entries can opt into `/think xhigh` by setting `models.providers..models[].compat.supportedReasoningEfforts` to include `"xhigh"`. This uses the same compat metadata that maps outbound OpenAI reasoning effort payloads, so menus, session validation, agent CLI, and `llm-task` agree with transport behavior. diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index 777abb8f55b..08925d7015f 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -73,7 +73,7 @@ describe("openrouter provider hooks", () => { it("advertises xhigh thinking for OpenRouter-routed DeepSeek V4 models", async () => { const provider = await registerSingleProviderPlugin(openrouterPlugin); - const expectedV4Levels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; + const expectedV4Levels = ["off", "minimal", "low", "medium", "high", "xhigh"]; expect( provider @@ -309,7 +309,7 @@ describe("openrouter provider hooks", () => { expect(capturedPayload).toMatchObject({ thinking: { type: "enabled" }, - reasoning_effort: "max", + reasoning_effort: "xhigh", messages: [ { role: "user", content: "read file" }, { @@ -324,6 +324,50 @@ describe("openrouter provider hooks", () => { expect(baseStreamFn).toHaveBeenCalledOnce(); }); + it("keeps OpenRouter DeepSeek V4 reasoning_effort within OpenRouter values", async () => { + const provider = await registerSingleProviderPlugin(openrouterPlugin); + const payloads: Array> = []; + const baseStreamFn = vi.fn( + ( + ...args: Parameters + ): ReturnType => { + const payload = { messages: [] }; + void args[2]?.onPayload?.(payload, args[0]); + payloads.push(payload); + return { async *[Symbol.asyncIterator]() {} } as never; + }, + ); + + for (const thinkingLevel of ["minimal", "low", "medium", "high", "xhigh", "max"] as const) { + const wrapped = provider.wrapStreamFn?.({ + provider: "openrouter", + modelId: "openrouter/deepseek/deepseek-v4-pro", + streamFn: baseStreamFn, + thinkingLevel, + } as never); + void wrapped?.( + { + provider: "openrouter", + api: "openai-completions", + id: "openrouter/deepseek/deepseek-v4-pro", + baseUrl: "https://openrouter.ai/api/v1", + compat: {}, + } as never, + { messages: [] } as never, + {}, + ); + } + + expect(payloads.map((payload) => payload.reasoning_effort)).toEqual([ + "minimal", + "low", + "medium", + "high", + "xhigh", + "xhigh", + ]); + }); + it("recognizes full OpenRouter DeepSeek V4 refs but skips custom proxy routes", async () => { const provider = await registerSingleProviderPlugin(openrouterPlugin); const payloads: Array> = []; diff --git a/extensions/openrouter/stream.ts b/extensions/openrouter/stream.ts index 9f2e733d3a8..83d68c8bbf7 100644 --- a/extensions/openrouter/stream.ts +++ b/extensions/openrouter/stream.ts @@ -3,6 +3,8 @@ import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-ent import { OPENROUTER_THINKING_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family"; import { createDeepSeekV4OpenAICompatibleThinkingWrapper, + type DeepSeekV4ReasoningEffort, + type DeepSeekV4ThinkingLevel, createPayloadPatchStreamWrapper, stripTrailingAssistantPrefillMessages, } from "openclaw/plugin-sdk/provider-stream-shared"; @@ -55,6 +57,27 @@ function shouldPatchDeepSeekV4OpenRouterPayload(model: Parameters[0]): ); } +function resolveOpenRouterDeepSeekV4ReasoningEffort( + thinkingLevel: DeepSeekV4ThinkingLevel, +): DeepSeekV4ReasoningEffort { + switch (thinkingLevel) { + case "minimal": + case "low": + case "medium": + case "high": + case "xhigh": + return thinkingLevel; + case "max": + return "xhigh"; + case "adaptive": + return "medium"; + case "off": + case undefined: + return "high"; + } + return "high"; +} + function isEnabledReasoningValue(value: unknown): boolean { if (value === undefined || value === null || value === false) { return false; @@ -125,6 +148,7 @@ function createOpenRouterDeepSeekV4ThinkingWrapper( baseStreamFn, thinkingLevel, shouldPatchModel: shouldPatchDeepSeekV4OpenRouterPayload, + resolveReasoningEffort: resolveOpenRouterDeepSeekV4ReasoningEffort, }); } @@ -156,12 +180,3 @@ export function wrapOpenRouterProviderStream( createOpenRouterDeepSeekV4ThinkingWrapper(wrappedStreamFn, ctx.thinkingLevel), ); } - -export const __testing = { - isOpenRouterDeepSeekV4ModelId, - isOpenRouterAnthropicModelId, - isOpenRouterReasoningPayloadEnabled, - isVerifiedOpenRouterRoute, - shouldPatchDeepSeekV4OpenRouterPayload, - shouldPatchAnthropicOpenRouterPayload, -}; diff --git a/extensions/openrouter/thinking-policy.ts b/extensions/openrouter/thinking-policy.ts index 18695fa7428..6c16c77b726 100644 --- a/extensions/openrouter/thinking-policy.ts +++ b/extensions/openrouter/thinking-policy.ts @@ -8,7 +8,6 @@ const OPENROUTER_DEEPSEEK_V4_THINKING_LEVEL_IDS = [ "medium", "high", "xhigh", - "max", ] as const; function buildOpenRouterDeepSeekV4ThinkingLevel( diff --git a/src/plugin-sdk/provider-stream-shared.ts b/src/plugin-sdk/provider-stream-shared.ts index a1ee1428bb7..962b38b0bc5 100644 --- a/src/plugin-sdk/provider-stream-shared.ts +++ b/src/plugin-sdk/provider-stream-shared.ts @@ -244,13 +244,16 @@ export function isOpenAICompatibleThinkingEnabled(params: { } export type DeepSeekV4ThinkingLevel = ProviderWrapStreamFnContext["thinkingLevel"]; +export type DeepSeekV4ReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; function isDisabledDeepSeekV4ThinkingLevel(thinkingLevel: DeepSeekV4ThinkingLevel): boolean { const normalized = typeof thinkingLevel === "string" ? thinkingLevel.toLowerCase() : ""; return normalized === "off" || normalized === "none"; } -function resolveDeepSeekV4ReasoningEffort(thinkingLevel: DeepSeekV4ThinkingLevel): "high" | "max" { +function resolveDeepSeekV4ReasoningEffort( + thinkingLevel: DeepSeekV4ThinkingLevel, +): DeepSeekV4ReasoningEffort { return thinkingLevel === "xhigh" || thinkingLevel === "max" ? "max" : "high"; } @@ -288,11 +291,13 @@ export function createDeepSeekV4OpenAICompatibleThinkingWrapper(params: { baseStreamFn: StreamFn | undefined; thinkingLevel: DeepSeekV4ThinkingLevel; shouldPatchModel: (model: Parameters[0]) => boolean; + resolveReasoningEffort?: (thinkingLevel: DeepSeekV4ThinkingLevel) => DeepSeekV4ReasoningEffort; }): StreamFn | undefined { if (!params.baseStreamFn) { return undefined; } const underlying = params.baseStreamFn; + const resolveReasoningEffort = params.resolveReasoningEffort ?? resolveDeepSeekV4ReasoningEffort; return (model, context, options) => { if (!params.shouldPatchModel(model)) { return underlying(model, context, options); @@ -308,7 +313,7 @@ export function createDeepSeekV4OpenAICompatibleThinkingWrapper(params: { } payload.thinking = { type: "enabled" }; - payload.reasoning_effort = resolveDeepSeekV4ReasoningEffort(params.thinkingLevel); + payload.reasoning_effort = resolveReasoningEffort(params.thinkingLevel); ensureDeepSeekV4AssistantReasoningContent(payload); }); }; From fdaa5a0c3da124920fa0234f76bf82bde946f61e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 14:03:16 -0700 Subject: [PATCH 046/107] fix(update): exit post-core resume without result path --- src/cli/update-cli.test.ts | 1 + src/cli/update-cli/update-command.ts | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 480b1b64466..90590a5475a 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -777,6 +777,7 @@ describe("update-cli", () => { ["npm", "i", "-g", expect.any(String)], expect.anything(), ); + expect(defaultRuntime.exit).toHaveBeenCalledWith(0); expect(syncPluginsForUpdateChannel).toHaveBeenCalledTimes(1); expect(updateNpmInstalledPlugins).toHaveBeenCalledTimes(1); expect(spawn).not.toHaveBeenCalled(); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 774eab7bf61..6839f0d7895 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1831,10 +1831,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { defaultRuntime.exit(1); return; } - if (process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) { - defaultRuntime.exit(0); - return; - } + defaultRuntime.exit(0); return; } From 7c0f5463a56489a0afcfdd178bc85e8dcf9f4152 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 14:06:44 -0700 Subject: [PATCH 047/107] fix(update): isolate plugin sync failures Disable and skip plugins that fail package-update plugin sync so broken plugin packages do not fail an otherwise successful OpenClaw update. --- CHANGELOG.md | 1 + src/cli/update-cli/update-command.test.ts | 31 ++++ src/cli/update-cli/update-command.ts | 29 +++- src/plugins/update.test.ts | 55 +++++++ src/plugins/update.ts | 170 ++++++++++++---------- 5 files changed, 212 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 655136a6c79..e6c48d21ac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc. - Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. - Browser: enforce strict SSRF current-URL checks before existing-session screenshots, matching existing-session snapshot handling. Thanks @vincentkoc. - Active Memory: give timeout partial transcript recovery enough abort-settle headroom so temporary recall summaries are returned before cleanup. Thanks @vincentkoc. diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index f851bfa2c17..22d0a81333c 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -222,6 +222,37 @@ describe("collectMissingPluginInstallPayloads", () => { await fs.rm(tmpDir, { recursive: true, force: true }); } }); + + it("skips disabled tracked records when requested", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-plugin-payload-")); + const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "missing"); + try { + await expect( + collectMissingPluginInstallPayloads({ + env: { HOME: tmpDir } as NodeJS.ProcessEnv, + skipDisabledPlugins: true, + config: { + plugins: { + entries: { + missing: { + enabled: false, + }, + }, + }, + }, + records: { + missing: { + source: "npm", + spec: "@openclaw/missing@beta", + installPath: missingDir, + }, + }, + }), + ).resolves.toEqual([]); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); }); describe("shouldUseLegacyProcessRestartAfterUpdate", () => { diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 6839f0d7895..1a5d5678ffd 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -51,6 +51,7 @@ import { resolveGlobalInstallSpec, } from "../../infra/update-global.js"; import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js"; import { loadInstalledPluginIndexInstallRecords, withoutPluginInstallRecords, @@ -184,9 +185,15 @@ async function pathExists(filePath: string): Promise { export async function collectMissingPluginInstallPayloads(params: { records: Record; + config?: OpenClawConfig; + skipDisabledPlugins?: boolean; env?: NodeJS.ProcessEnv; }): Promise { const env = params.env ?? process.env; + const normalizedPluginConfig = + params.skipDisabledPlugins && params.config + ? normalizePluginsConfig(params.config.plugins) + : undefined; const missing: MissingPluginInstallPayload[] = []; for (const [pluginId, record] of Object.entries(params.records).toSorted(([left], [right]) => left.localeCompare(right), @@ -194,6 +201,17 @@ export async function collectMissingPluginInstallPayloads(params: { if (!isTrackedPackageInstallRecord(record)) { continue; } + if (normalizedPluginConfig && params.config) { + const enableState = resolveEffectiveEnableState({ + id: pluginId, + origin: "global", + config: normalizedPluginConfig, + rootConfig: params.config, + }); + if (!enableState.enabled) { + continue; + } + } const rawInstallPath = normalizeOptionalString(record.installPath); if (!rawInstallPath) { missing.push({ pluginId, reason: "missing-install-path" }); @@ -1091,7 +1109,11 @@ async function updatePluginsAfterCoreUpdate(params: { const repairMissingPayloads = async ( records: Record, ): Promise => { - const missing = await collectMissingPluginInstallPayloads({ records }); + const missing = await collectMissingPluginInstallPayloads({ + records, + config: pluginConfig, + skipDisabledPlugins: true, + }); if (missing.length === 0) { return []; } @@ -1110,6 +1132,8 @@ async function updatePluginsAfterCoreUpdate(params: { pluginIds: missingIds, timeoutMs: params.timeoutMs, updateChannel: params.channel, + skipDisabledPlugins: true, + disableOnFailure: true, logger: pluginLogger, onIntegrityDrift: onPluginIntegrityDrift, }); @@ -1130,6 +1154,7 @@ async function updatePluginsAfterCoreUpdate(params: { updateChannel: params.channel, skipIds: new Set([...syncResult.summary.switchedToNpm, ...repairedMissingPayloadIds]), skipDisabledPlugins: true, + disableOnFailure: true, logger: pluginLogger, onIntegrityDrift: onPluginIntegrityDrift, }); @@ -1140,6 +1165,8 @@ async function updatePluginsAfterCoreUpdate(params: { const remainingMissingPayloads = await collectMissingPluginInstallPayloads({ records: pluginConfig.plugins?.installs ?? {}, + config: pluginConfig, + skipDisabledPlugins: true, }); pluginUpdateOutcomes.push( ...remainingMissingPayloads.map( diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 91f9028448a..655d387e32b 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -1032,6 +1032,61 @@ describe("updateNpmInstalledPlugins", () => { ]); }); + it("disables enabled tracked plugin update failures when requested", async () => { + const warn = vi.fn(); + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: false, + error: "registry timeout", + }); + const config = { + plugins: { + entries: { + demo: { + enabled: true, + config: { preserved: true }, + }, + }, + installs: { + demo: { + source: "npm" as const, + spec: "@acme/demo", + installPath: "/tmp/demo", + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await updateNpmInstalledPlugins({ + config, + skipDisabledPlugins: true, + disableOnFailure: true, + logger: { warn }, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@acme/demo", + expectedPluginId: "demo", + }), + ); + const message = + 'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: registry timeout'; + expect(warn).toHaveBeenCalledWith(message); + expect(result.changed).toBe(true); + expect(result.config.plugins?.entries?.demo).toEqual({ + enabled: false, + config: { preserved: true }, + }); + expect(result.config.plugins?.installs?.demo).toEqual(config.plugins.installs.demo); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "skipped", + message, + }, + ]); + }); + it("aborts exact pinned npm plugin updates on integrity drift by default", async () => { const warn = vi.fn(); installPluginFromNpmSpecMock.mockImplementation( diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 2ca5508ab84..c3b97338c4f 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -747,12 +747,30 @@ function createPluginUpdateIntegrityDriftHandler(params: { }; } +function disablePluginConfigEntry(config: OpenClawConfig, pluginId: string): OpenClawConfig { + const existingEntry = config.plugins?.entries?.[pluginId]; + return { + ...config, + plugins: { + ...config.plugins, + entries: { + ...config.plugins?.entries, + [pluginId]: { + ...existingEntry, + enabled: false, + }, + }, + }, + }; +} + export async function updateNpmInstalledPlugins(params: { config: OpenClawConfig; logger?: PluginUpdateLogger; pluginIds?: string[]; skipIds?: Set; skipDisabledPlugins?: boolean; + disableOnFailure?: boolean; timeoutMs?: number; dryRun?: boolean; updateChannel?: UpdateChannel; @@ -771,6 +789,28 @@ export async function updateNpmInstalledPlugins(params: { let next = params.config; let changed = false; + const recordFailure = (pluginId: string, message: string) => { + if (params.disableOnFailure && !params.dryRun) { + const disabledMessage = + `Disabled "${pluginId}" after plugin update failure; OpenClaw will continue without it. ` + + message; + logger.warn?.(disabledMessage); + next = disablePluginConfigEntry(next, pluginId); + changed = true; + outcomes.push({ + pluginId, + status: "skipped", + message: disabledMessage, + }); + return; + } + outcomes.push({ + pluginId, + status: "error", + message, + }); + }; + for (const pluginId of targets) { if (params.skipIds?.has(pluginId)) { outcomes.push({ @@ -928,11 +968,7 @@ export async function updateNpmInstalledPlugins(params: { record.installPath?.trim() || resolvePluginInstallDir(pluginId), ); } catch (err) { - outcomes.push({ - pluginId, - status: "error", - message: `Invalid install path for "${pluginId}": ${String(err)}`, - }); + recordFailure(pluginId, `Invalid install path for "${pluginId}": ${String(err)}`); continue; } const currentVersion = await readInstalledPackageVersion(installPath); @@ -1037,11 +1073,7 @@ export async function updateNpmInstalledPlugins(params: { logger, }); } catch (err) { - outcomes.push({ - pluginId, - status: "error", - message: `Failed to check ${pluginId}: ${String(err)}`, - }); + recordFailure(pluginId, `Failed to check ${pluginId}: ${String(err)}`); continue; } let usedNpmFallback = false; @@ -1096,43 +1128,41 @@ export async function updateNpmInstalledPlugins(params: { }); } if (!probe.ok) { - outcomes.push({ + recordFailure( pluginId, - status: "error", - message: - record.source === "npm" - ? formatNpmInstallFailure({ + record.source === "npm" + ? formatNpmInstallFailure({ + pluginId, + spec: npmUpdateFailureSpec({ + effectiveSpec, + fallbackSpec: npmSpecs?.fallbackSpec, + usedFallback: usedNpmFallback, + }), + phase: "check", + result: probe, + }) + : record.source === "clawhub" + ? formatClawHubInstallFailure({ pluginId, - spec: npmUpdateFailureSpec({ - effectiveSpec, - fallbackSpec: npmSpecs?.fallbackSpec, - usedFallback: usedNpmFallback, - }), + spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`, phase: "check", - result: probe, + error: probe.error, }) - : record.source === "clawhub" - ? formatClawHubInstallFailure({ + : record.source === "git" + ? formatGitInstallFailure({ pluginId, - spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`, + spec: effectiveSpec!, phase: "check", error: probe.error, }) - : record.source === "git" - ? formatGitInstallFailure({ - pluginId, - spec: effectiveSpec!, - phase: "check", - error: probe.error, - }) - : formatMarketplaceInstallFailure({ - pluginId, - marketplaceSource: record.marketplaceSource!, - marketplacePlugin: record.marketplacePlugin!, - phase: "check", - error: probe.error, - }), - }); + : formatMarketplaceInstallFailure({ + pluginId, + marketplaceSource: record.marketplaceSource!, + marketplacePlugin: record.marketplacePlugin!, + phase: "check", + error: probe.error, + }), + ); continue; } @@ -1224,11 +1254,7 @@ export async function updateNpmInstalledPlugins(params: { logger, }); } catch (err) { - outcomes.push({ - pluginId, - status: "error", - message: `Failed to update ${pluginId}: ${String(err)}`, - }); + recordFailure(pluginId, `Failed to update ${pluginId}: ${String(err)}`); continue; } let usedNpmFallback = false; @@ -1281,43 +1307,41 @@ export async function updateNpmInstalledPlugins(params: { }); } if (!result.ok) { - outcomes.push({ + recordFailure( pluginId, - status: "error", - message: - record.source === "npm" - ? formatNpmInstallFailure({ + record.source === "npm" + ? formatNpmInstallFailure({ + pluginId, + spec: npmUpdateFailureSpec({ + effectiveSpec, + fallbackSpec: npmSpecs?.fallbackSpec, + usedFallback: usedNpmFallback, + }), + phase: "update", + result: result, + }) + : record.source === "clawhub" + ? formatClawHubInstallFailure({ pluginId, - spec: npmUpdateFailureSpec({ - effectiveSpec, - fallbackSpec: npmSpecs?.fallbackSpec, - usedFallback: usedNpmFallback, - }), + spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`, phase: "update", - result: result, + error: result.error, }) - : record.source === "clawhub" - ? formatClawHubInstallFailure({ + : record.source === "git" + ? formatGitInstallFailure({ pluginId, - spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`, + spec: effectiveSpec!, phase: "update", error: result.error, }) - : record.source === "git" - ? formatGitInstallFailure({ - pluginId, - spec: effectiveSpec!, - phase: "update", - error: result.error, - }) - : formatMarketplaceInstallFailure({ - pluginId, - marketplaceSource: record.marketplaceSource!, - marketplacePlugin: record.marketplacePlugin!, - phase: "update", - error: result.error, - }), - }); + : formatMarketplaceInstallFailure({ + pluginId, + marketplaceSource: record.marketplaceSource!, + marketplacePlugin: record.marketplacePlugin!, + phase: "update", + error: result.error, + }), + ); continue; } From ed1089f822e78b8122011af71af9049f18d6be9a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 14:06:53 -0700 Subject: [PATCH 048/107] test(plugins): source Testbox auth for kitchen sink live --- docs/help/testing.md | 4 +++- package.json | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/help/testing.md b/docs/help/testing.md index 423d06b249b..179d5b6fb4f 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -149,7 +149,9 @@ inside every shard. installs the external Kitchen Sink package, verifies the plugin SDK surface inventory, probes `/healthz` and `/readyz`, records gateway CPU/RSS evidence, runs a live OpenAI turn, and checks adversarial diagnostics. - Requires live OpenAI auth such as `OPENAI_API_KEY`. + Requires live OpenAI auth such as `OPENAI_API_KEY`. In hydrated Testbox + sessions it automatically sources the Testbox live-auth profile when the + `openclaw-testbox-env` helper is present. - `pnpm test:gateway:cpu-scenarios` - Runs the gateway startup bench plus a small mock QA Lab scenario pack (`channel-chat-baseline`, `memory-failure-fallback`, diff --git a/package.json b/package.json index bc8ecbb19c2..f6956d29d9b 100644 --- a/package.json +++ b/package.json @@ -1614,7 +1614,7 @@ "test:perf:profile:main": "node scripts/run-vitest-profile.mjs main", "test:perf:profile:runner": "node scripts/run-vitest-profile.mjs runner", "test:plugins:gateway-gauntlet": "node scripts/check-plugin-gateway-gauntlet.mjs", - "test:plugins:kitchen-sink-live": "pnpm openclaw qa suite --provider-mode live-frontier --scenario kitchen-sink-live-openai", + "test:plugins:kitchen-sink-live": "bash -lc 'if [ -x \"$HOME/.local/bin/openclaw-testbox-env\" ]; then exec \"$HOME/.local/bin/openclaw-testbox-env\" pnpm openclaw qa suite --provider-mode live-frontier --scenario kitchen-sink-live-openai; fi; exec pnpm openclaw qa suite --provider-mode live-frontier --scenario kitchen-sink-live-openai'", "test:sectriage": "OPENCLAW_GATEWAY_PROJECT_SHARDS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts && node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts", "test:serial": "OPENCLAW_TEST_PROJECTS_SERIAL=1 OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/test-projects.mjs", "test:stability:gateway": "OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts src/gateway/gateway-stability.test.ts && OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.logging.config.ts src/logging/diagnostic-stability-bundle.test.ts && OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.infra.config.ts src/infra/fatal-error-hooks.test.ts", From 14b5f73e2a5e57925e53465d6b0bee856697f6dd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 22:14:06 +0100 Subject: [PATCH 049/107] fix(agents): avoid duplicate generated media attachments --- CHANGELOG.md | 1 + ...session.subscribeembeddedpisession.test.ts | 175 ++++++++++++++++++ src/agents/pi-embedded-subscribe.ts | 10 +- 3 files changed, 184 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6c48d21ac4..6ffb47b09f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai - Browser: enforce strict SSRF current-URL checks before existing-session screenshots, matching existing-session snapshot handling. Thanks @vincentkoc. - Active Memory: give timeout partial transcript recovery enough abort-settle headroom so temporary recall summaries are returned before cleanup. Thanks @vincentkoc. - Gateway/chat: clear the active reply-run guard before draining queued same-session follow-up turns, so sequential `chat.send` calls no longer trip `ReplyRunAlreadyActiveError` every other request. Fixes #77485. Thanks @bws14email. +- Agents/media: avoid sending generated image, video, and music attachments twice when streamed reply text arrives before the final `MEDIA:` directive. - Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob. - CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc. - Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc. diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts index 8db3e5353e6..0586edd1055 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts @@ -424,6 +424,83 @@ describe("subscribeEmbeddedPiSession", () => { ); }); + it("does not attach generated image media to an early streamed chunk before explicit MEDIA", async () => { + const onToolResult = vi.fn(); + const onBlockReply = vi.fn(); + const { emit } = createSubscribedHarness({ + runId: "run", + onToolResult, + onBlockReply, + verboseLevel: "full", + blockReplyBreak: "text_end", + blockReplyChunking: { minChars: 5, maxChars: 200, breakPreference: "newline" }, + builtinToolNames: new Set(["image_generate"]), + }); + + emitToolRun({ + emit, + toolName: "image_generate", + toolCallId: "tool-1", + isError: false, + result: { + content: [ + { + type: "text", + text: "Generated 1 image with google/gemini-3.1-flash-image-preview.\nMEDIA:/tmp/generated.png", + }, + ], + details: { + media: { + mediaUrls: ["/tmp/generated.png"], + }, + }, + }, + }); + + await vi.waitFor(() => { + expect(onToolResult).toHaveBeenCalled(); + }); + + emit({ type: "message_start", message: { role: "assistant" } }); + emitAssistantTextDelta(emit, "Generated 1 image.\n"); + + expect(onBlockReply).toHaveBeenCalledWith( + expect.objectContaining({ + text: "Generated 1 image.", + }), + ); + expect(onBlockReply.mock.calls.some(([payload]) => payload.mediaUrls?.length)).toBe(false); + + emitAssistantTextDelta(emit, "MEDIA:/tmp/generated.png"); + emit({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { + type: "text_end", + content: "Generated 1 image.\nMEDIA:/tmp/generated.png", + }, + }); + emit({ + type: "message_end", + message: { + role: "assistant", + content: [ + { + type: "text", + text: "Generated 1 image.\nMEDIA:/tmp/generated.png", + }, + ], + }, + }); + emit({ type: "agent_end" }); + await flushBlockReplyCallbacks(); + + const mediaPayloads = onBlockReply.mock.calls + .map(([payload]) => payload) + .filter((payload) => payload.mediaUrls?.includes("/tmp/generated.png")); + expect(mediaPayloads).toHaveLength(1); + }); + it("attaches media from internal completion events even when assistant omits MEDIA lines", async () => { const onBlockReply = vi.fn(); const { emit } = createSubscribedHarness({ @@ -469,6 +546,104 @@ describe("subscribeEmbeddedPiSession", () => { ); }); + it.each([ + { + label: "music", + source: "music_generation" as const, + childSessionKey: "music_generate:task-123", + announceType: "music generation task", + taskLabel: "launch anthem", + result: "Generated 1 track.\nMEDIA:/tmp/launch-anthem.mp3", + mediaUrl: "/tmp/launch-anthem.mp3", + firstChunk: "Generated 1 track.\n", + finalText: "Generated 1 track.\nMEDIA:/tmp/launch-anthem.mp3", + }, + { + label: "video", + source: "video_generation" as const, + childSessionKey: "video_generate:task-123", + announceType: "video generation task", + taskLabel: "launch reel", + result: "Generated 1 video.\nMEDIA:/tmp/launch-reel.mp4", + mediaUrl: "/tmp/launch-reel.mp4", + firstChunk: "Generated 1 video.\n", + finalText: "Generated 1 video.\nMEDIA:/tmp/launch-reel.mp4", + }, + ])( + "does not attach $label internal completion media to an early streamed chunk before explicit MEDIA", + async ({ + source, + childSessionKey, + announceType, + taskLabel, + result, + mediaUrl, + firstChunk, + finalText, + }) => { + const onBlockReply = vi.fn(); + const { emit } = createSubscribedHarness({ + runId: "run", + onBlockReply, + blockReplyBreak: "text_end", + blockReplyChunking: { minChars: 5, maxChars: 200, breakPreference: "newline" }, + internalEvents: [ + { + type: "task_completion", + source, + childSessionKey, + announceType, + taskLabel, + status: "ok", + statusLabel: "completed successfully", + result, + mediaUrls: [mediaUrl], + replyInstruction: "Reply normally.", + }, + ], + }); + + emit({ type: "message_start", message: { role: "assistant" } }); + emitAssistantTextDelta(emit, firstChunk); + + expect(onBlockReply).toHaveBeenCalledWith( + expect.objectContaining({ + text: firstChunk.trim(), + }), + ); + expect(onBlockReply.mock.calls.some(([payload]) => payload.mediaUrls?.length)).toBe(false); + + emitAssistantTextDelta(emit, `MEDIA:${mediaUrl}`); + emit({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { + type: "text_end", + content: finalText, + }, + }); + emit({ + type: "message_end", + message: { + role: "assistant", + content: [ + { + type: "text", + text: finalText, + }, + ], + }, + }); + emit({ type: "agent_end" }); + await flushBlockReplyCallbacks(); + + const mediaPayloads = onBlockReply.mock.calls + .map(([payload]) => payload) + .filter((payload) => payload.mediaUrls?.includes(mediaUrl)); + expect(mediaPayloads).toHaveLength(1); + }, + ); + it("keeps orphaned tool media available for non-block final payload assembly", () => { const { emit, subscription } = createSubscribedSessionHarness({ runId: "run", diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 72d7384ee95..b824aa2a686 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -239,10 +239,14 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar }; const emitBlockReply = ( payload: BlockReplyPayload, - options?: { assistantMessageIndex?: number }, + options?: { assistantMessageIndex?: number; consumePendingToolMedia?: boolean }, ) => { const withAssistantDirectives = consumePendingAssistantReplyDirectivesIntoReply(state, payload); - emitBlockReplySafely(consumePendingToolMediaIntoReply(state, withAssistantDirectives), options); + const withToolMedia = + options?.consumePendingToolMedia === false + ? withAssistantDirectives + : consumePendingToolMediaIntoReply(state, withAssistantDirectives); + emitBlockReplySafely(withToolMedia, options); }; const resetAssistantMessageState = (nextAssistantTextBaseline: number) => { @@ -761,6 +765,8 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar }, { assistantMessageIndex: options?.assistantMessageIndex ?? state.assistantMessageIndex, + consumePendingToolMedia: + options?.final === true || Boolean(mediaUrls?.length || audioAsVoice), }, ); }; From 828b6be39d85b38a4d7b8ceba117cd7415742e1a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 21:38:39 +0100 Subject: [PATCH 050/107] fix(cli): bound sessions list output --- CHANGELOG.md | 1 + docs/cli/sessions.md | 16 ++- .../register.status-health-sessions.test.ts | 3 + .../register.status-health-sessions.ts | 3 + src/cli/program/route-args.test.ts | 4 + src/cli/program/route-args.ts | 5 + src/commands/sessions-table.ts | 56 +++++---- src/commands/sessions.test-helpers.ts | 4 +- src/commands/sessions.test.ts | 118 ++++++++++++++++++ src/commands/sessions.ts | 108 +++++++++++++--- 10 files changed, 267 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ffb47b09f1..af367887c91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai - Active Memory: give timeout partial transcript recovery enough abort-settle headroom so temporary recall summaries are returned before cleanup. Thanks @vincentkoc. - Gateway/chat: clear the active reply-run guard before draining queued same-session follow-up turns, so sequential `chat.send` calls no longer trip `ReplyRunAlreadyActiveError` every other request. Fixes #77485. Thanks @bws14email. - Agents/media: avoid sending generated image, video, and music attachments twice when streamed reply text arrives before the final `MEDIA:` directive. +- CLI/sessions: cap `openclaw sessions` output to the newest 100 rows by default and add `--limit ` plus JSON pagination metadata, so repeated machine polling of large session stores cannot fan out into unbounded per-row enrichment/output work. Fixes #77500. Thanks @Kaotic3. - Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob. - CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc. - Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc. diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index 9dfdefa1993..58bab6fdbd9 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -16,17 +16,19 @@ until a message is processed. Use `openclaw channels status --probe`, `openclaw status --deep`, or `openclaw health --verbose` when you need live channel connectivity. -Gateway `sessions.list` responses are bounded by default so large long-lived -stores cannot monopolize the Gateway event loop. Pass an explicit positive -`limit` from RPC clients when a different result window is needed; responses -include `totalCount`, `limitApplied`, and `hasMore` when callers need to show -that more rows exist. +`openclaw sessions` and Gateway `sessions.list` responses are bounded by +default so large long-lived stores cannot monopolize the CLI process or Gateway +event loop. The CLI returns the newest 100 sessions by default; pass +`--limit ` for a smaller/larger window or `--limit all` when you intentionally +need the full store. JSON responses include `totalCount`, `limitApplied`, and +`hasMore` when callers need to show that more rows exist. ```bash openclaw sessions openclaw sessions --agent work openclaw sessions --all-agents openclaw sessions --active 120 +openclaw sessions --limit 25 openclaw sessions --verbose openclaw sessions --json ``` @@ -38,6 +40,7 @@ Scope selection: - `--agent `: one configured agent store - `--all-agents`: aggregate all configured agent stores - `--store `: explicit store path (cannot be combined with `--agent` or `--all-agents`) +- `--limit `: max rows to output (default `100`; `all` restores full output) Export a trajectory bundle for a stored session: @@ -69,6 +72,9 @@ JSON examples: ], "allAgents": true, "count": 2, + "totalCount": 2, + "limitApplied": 100, + "hasMore": false, "activeMinutes": null, "sessions": [ { "agentId": "main", "key": "agent:main:main", "model": "gpt-5" }, diff --git a/src/cli/program/register.status-health-sessions.test.ts b/src/cli/program/register.status-health-sessions.test.ts index b4d62771182..a7f0764f8f9 100644 --- a/src/cli/program/register.status-health-sessions.test.ts +++ b/src/cli/program/register.status-health-sessions.test.ts @@ -191,6 +191,8 @@ describe("registerStatusHealthSessionsCommands", () => { "/tmp/sessions.json", "--active", "120", + "--limit", + "25", ]); expect(setVerbose).toHaveBeenCalledWith(true); @@ -199,6 +201,7 @@ describe("registerStatusHealthSessionsCommands", () => { json: true, store: "/tmp/sessions.json", active: "120", + limit: "25", }), runtime, ); diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index 1014626e73f..4d39987135e 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -132,6 +132,7 @@ export function registerStatusHealthSessionsCommands(program: Command) { .option("--agent ", "Agent id to inspect (default: configured default agent)") .option("--all-agents", "Aggregate sessions across all configured agents", false) .option("--active ", "Only show sessions updated within the past N minutes") + .option("--limit ", 'Max sessions to show (default: 100; use "all" for full output)') .addHelpText( "after", () => @@ -140,6 +141,7 @@ export function registerStatusHealthSessionsCommands(program: Command) { ["openclaw sessions --agent work", "List sessions for one agent."], ["openclaw sessions --all-agents", "Aggregate sessions across agents."], ["openclaw sessions --active 120", "Only last 2 hours."], + ["openclaw sessions --limit 25", "Show the newest 25 sessions."], ["openclaw sessions --json", "Machine-readable output."], ["openclaw sessions --store ./tmp/sessions.json", "Use a specific session store."], ])}\n\n${theme.muted( @@ -160,6 +162,7 @@ export function registerStatusHealthSessionsCommands(program: Command) { agent: opts.agent as string | undefined, allAgents: Boolean(opts.allAgents), active: opts.active as string | undefined, + limit: opts.limit as string | undefined, }, defaultRuntime, ); diff --git a/src/cli/program/route-args.test.ts b/src/cli/program/route-args.test.ts index e595d8f0001..85e4bbc511d 100644 --- a/src/cli/program/route-args.test.ts +++ b/src/cli/program/route-args.test.ts @@ -96,6 +96,8 @@ describe("route-args", () => { "sqlite", "--active", "true", + "--limit", + "25", ]), ).toEqual({ json: true, @@ -103,8 +105,10 @@ describe("route-args", () => { agent: "default", store: "sqlite", active: "true", + limit: "25", }); expect(parseSessionsRouteArgs(["node", "openclaw", "sessions", "--agent"])).toBeNull(); + expect(parseSessionsRouteArgs(["node", "openclaw", "sessions", "--limit"])).toBeNull(); expect( parseAgentsListRouteArgs(["node", "openclaw", "agents", "list", "--json", "--bindings"]), ).toEqual({ diff --git a/src/cli/program/route-args.ts b/src/cli/program/route-args.ts index a80af17964e..d8f259c5419 100644 --- a/src/cli/program/route-args.ts +++ b/src/cli/program/route-args.ts @@ -144,12 +144,17 @@ export function parseSessionsRouteArgs(argv: string[]) { if (!active.ok) { return null; } + const limit = parseOptionalFlagValue(argv, "--limit"); + if (!limit.ok) { + return null; + } return { json: hasFlag(argv, "--json"), allAgents: hasFlag(argv, "--all-agents"), agent: agent.value, store: store.value, active: active.value, + limit: limit.value, }; } diff --git a/src/commands/sessions-table.ts b/src/commands/sessions-table.ts index 8c365bef07a..c989152656b 100644 --- a/src/commands/sessions-table.ts +++ b/src/commands/sessions-table.ts @@ -31,35 +31,37 @@ export const SESSION_KEY_PAD = 26; export const SESSION_AGE_PAD = 9; export const SESSION_MODEL_PAD = 14; +export function toSessionDisplayRow(key: string, entry: SessionEntry): SessionDisplayRow { + const updatedAt = entry?.updatedAt ?? null; + return { + key, + updatedAt, + ageMs: updatedAt ? Date.now() - updatedAt : null, + sessionId: entry?.sessionId, + systemSent: entry?.systemSent, + abortedLastRun: entry?.abortedLastRun, + thinkingLevel: entry?.thinkingLevel, + verboseLevel: entry?.verboseLevel, + traceLevel: entry?.traceLevel, + reasoningLevel: entry?.reasoningLevel, + elevatedLevel: entry?.elevatedLevel, + responseUsage: entry?.responseUsage, + groupActivation: entry?.groupActivation, + inputTokens: entry?.inputTokens, + outputTokens: entry?.outputTokens, + totalTokens: entry?.totalTokens, + totalTokensFresh: entry?.totalTokensFresh, + model: entry?.model, + modelProvider: entry?.modelProvider, + providerOverride: entry?.providerOverride, + modelOverride: entry?.modelOverride, + contextTokens: entry?.contextTokens, + }; +} + export function toSessionDisplayRows(store: Record): SessionDisplayRow[] { return Object.entries(store) - .map(([key, entry]) => { - const updatedAt = entry?.updatedAt ?? null; - return { - key, - updatedAt, - ageMs: updatedAt ? Date.now() - updatedAt : null, - sessionId: entry?.sessionId, - systemSent: entry?.systemSent, - abortedLastRun: entry?.abortedLastRun, - thinkingLevel: entry?.thinkingLevel, - verboseLevel: entry?.verboseLevel, - traceLevel: entry?.traceLevel, - reasoningLevel: entry?.reasoningLevel, - elevatedLevel: entry?.elevatedLevel, - responseUsage: entry?.responseUsage, - groupActivation: entry?.groupActivation, - inputTokens: entry?.inputTokens, - outputTokens: entry?.outputTokens, - totalTokens: entry?.totalTokens, - totalTokensFresh: entry?.totalTokensFresh, - model: entry?.model, - modelProvider: entry?.modelProvider, - providerOverride: entry?.providerOverride, - modelOverride: entry?.modelOverride, - contextTokens: entry?.contextTokens, - } satisfies SessionDisplayRow; - }) + .map(([key, entry]) => toSessionDisplayRow(key, entry)) .toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); } diff --git a/src/commands/sessions.test-helpers.ts b/src/commands/sessions.test-helpers.ts index bfdd4e33a22..c5fe142835f 100644 --- a/src/commands/sessions.test-helpers.ts +++ b/src/commands/sessions.test-helpers.ts @@ -73,12 +73,13 @@ export function writeStore(data: unknown, prefix = "sessions"): string { export async function runSessionsJson( run: ( - opts: { json?: boolean; store?: string; active?: string }, + opts: { json?: boolean; store?: string; active?: string; limit?: string | number }, runtime: RuntimeEnv, ) => Promise, store: string, options?: { active?: string; + limit?: string | number; }, ): Promise { const { runtime, logs } = makeRuntime(); @@ -88,6 +89,7 @@ export async function runSessionsJson( store, json: true, active: options?.active, + limit: options?.limit, }, runtime, ); diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index 881e4c06283..52c6d287ff0 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -154,6 +154,106 @@ describe("sessionsCommand", () => { expect(payload.sessions?.map((row) => row.key)).toEqual(["recent"]); }); + it("limits JSON output to the newest 100 sessions by default", async () => { + const entries: Record = {}; + for (let i = 0; i < 105; i += 1) { + entries[`session-${String(i).padStart(3, "0")}`] = { + sessionId: `session-${i}`, + updatedAt: Date.now() - i * 60_000, + model: "pi:opus", + }; + } + const store = writeStore(entries, "sessions-default-limit"); + + const payload = await runSessionsJson<{ + count?: number; + totalCount?: number; + limitApplied?: number | null; + hasMore?: boolean; + sessions?: Array<{ key: string }>; + }>(sessionsCommand, store); + + expect(payload.count).toBe(100); + expect(payload.totalCount).toBe(105); + expect(payload.limitApplied).toBe(100); + expect(payload.hasMore).toBe(true); + expect(payload.sessions?.at(0)?.key).toBe("session-000"); + expect(payload.sessions?.some((row) => row.key === "session-104")).toBe(false); + }); + + it("honors explicit JSON output limits", async () => { + const store = writeStore( + { + newest: { sessionId: "newest", updatedAt: Date.now(), model: "pi:opus" }, + middle: { sessionId: "middle", updatedAt: Date.now() - 60_000, model: "pi:opus" }, + oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "pi:opus" }, + }, + "sessions-explicit-limit", + ); + + const payload = await runSessionsJson<{ + count?: number; + totalCount?: number; + limitApplied?: number | null; + hasMore?: boolean; + sessions?: Array<{ key: string }>; + }>(sessionsCommand, store, { limit: "2" }); + + expect(payload.count).toBe(2); + expect(payload.totalCount).toBe(3); + expect(payload.limitApplied).toBe(2); + expect(payload.hasMore).toBe(true); + expect(payload.sessions?.map((row) => row.key)).toEqual(["newest", "middle"]); + }); + + it("allows full JSON output with --limit all", async () => { + const store = writeStore( + { + newest: { sessionId: "newest", updatedAt: Date.now(), model: "pi:opus" }, + oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "pi:opus" }, + }, + "sessions-limit-all", + ); + + const payload = await runSessionsJson<{ + count?: number; + totalCount?: number; + limitApplied?: number | null; + hasMore?: boolean; + sessions?: Array<{ key: string }>; + }>(sessionsCommand, store, { limit: "all" }); + + expect(payload.count).toBe(2); + expect(payload.totalCount).toBe(2); + expect(payload.limitApplied).toBeNull(); + expect(payload.hasMore).toBe(false); + expect(payload.sessions?.map((row) => row.key)).toEqual(["newest", "oldest"]); + }); + + it("sorts and slices large explicit limits instead of using top-N insertion", async () => { + const store = writeStore( + { + newest: { sessionId: "newest", updatedAt: Date.now(), model: "pi:opus" }, + oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "pi:opus" }, + }, + "sessions-large-limit", + ); + + const payload = await runSessionsJson<{ + count?: number; + totalCount?: number; + limitApplied?: number | null; + hasMore?: boolean; + sessions?: Array<{ key: string }>; + }>(sessionsCommand, store, { limit: "100000" }); + + expect(payload.count).toBe(2); + expect(payload.totalCount).toBe(2); + expect(payload.limitApplied).toBe(100000); + expect(payload.hasMore).toBe(false); + expect(payload.sessions?.map((row) => row.key)).toEqual(["newest", "oldest"]); + }); + it("rejects invalid --active values", async () => { const store = writeStore( { @@ -171,4 +271,22 @@ describe("sessionsCommand", () => { fs.rmSync(store); }); + + it("rejects invalid --limit values", async () => { + const store = writeStore( + { + demo: { + sessionId: "demo", + updatedAt: Date.now() - 5 * 60_000, + }, + }, + "sessions-limit-invalid", + ); + const { runtime, errors } = makeRuntime(); + + await expect(sessionsCommand({ store, limit: "0" }, runtime)).rejects.toThrow("exit 1"); + expect(errors[0]).toContain('--limit must be a positive integer or "all"'); + + fs.rmSync(store); + }); }); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 2c405c27cbd..96c41d418e3 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -23,7 +23,7 @@ import { SESSION_KEY_PAD, SESSION_MODEL_PAD, type SessionDisplayRow, - toSessionDisplayRows, + toSessionDisplayRow, } from "./sessions-table.js"; type SessionRow = SessionDisplayRow & { @@ -35,10 +35,58 @@ type SessionRow = SessionDisplayRow & { const AGENT_PAD = 10; const KIND_PAD = 6; const TOKENS_PAD = 20; +const DEFAULT_SESSIONS_LIMIT = 100; +const TOP_N_SELECTION_LIMIT = 200; const contextLookupRuntimeLoader = createLazyImportLoader(() => import("../agents/context.js")); const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; +function compareSessionRowsByUpdatedAt(a: SessionRow, b: SessionRow): number { + return (b.updatedAt ?? 0) - (a.updatedAt ?? 0); +} + +function selectNewestSessionRows(rows: SessionRow[], limit: number | undefined): SessionRow[] { + if (limit === undefined) { + return rows.toSorted(compareSessionRowsByUpdatedAt); + } + if (limit > TOP_N_SELECTION_LIMIT) { + return rows.toSorted(compareSessionRowsByUpdatedAt).slice(0, limit); + } + const selected: SessionRow[] = []; + for (const row of rows) { + const insertAt = selected.findIndex( + (candidate) => compareSessionRowsByUpdatedAt(row, candidate) < 0, + ); + if (insertAt >= 0) { + selected.splice(insertAt, 0, row); + if (selected.length > limit) { + selected.pop(); + } + } else if (selected.length < limit) { + selected.push(row); + } + } + return selected; +} + +function parseSessionsLimit(value: string | number | undefined): number | undefined | null { + if (value === undefined) { + return DEFAULT_SESSIONS_LIMIT; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.toLowerCase() === "all") { + return undefined; + } + if (!/^\d+$/.test(trimmed)) { + return null; + } + const parsed = Number.parseInt(trimmed, 10); + return parsed > 0 ? parsed : null; + } + return Number.isInteger(value) && value > 0 ? value : null; +} + const colorByPct = (label: string, pct: number | null, rich: boolean) => { if (!rich || pct === null) { return label; @@ -115,7 +163,14 @@ const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => { }; export async function sessionsCommand( - opts: { json?: boolean; store?: string; active?: string; agent?: string; allAgents?: boolean }, + opts: { + json?: boolean; + store?: string; + active?: string; + agent?: string; + allAgents?: boolean; + limit?: string | number; + }, runtime: RuntimeEnv, ) { const aggregateAgents = opts.allAgents === true; @@ -150,10 +205,25 @@ export async function sessionsCommand( activeMinutes = parsed; } - const rows = targets - .flatMap((target) => { - const store = loadSessionStore(target.storePath); - return toSessionDisplayRows(store).map((row) => { + const limit = parseSessionsLimit(opts.limit); + if (limit === null) { + runtime.error('--limit must be a positive integer or "all"'); + runtime.exit(1); + return; + } + + const allRows = targets.flatMap((target) => { + const store = loadSessionStore(target.storePath); + return Object.entries(store) + .filter(([, entry]) => { + if (activeMinutes === undefined) { + return true; + } + const updatedAt = entry?.updatedAt; + return typeof updatedAt === "number" && Date.now() - updatedAt <= activeMinutes * 60_000; + }) + .map(([key, entry]) => { + const row = toSessionDisplayRow(key, entry); const agentId = parseAgentSessionKey(row.key)?.agentId ?? target.agentId; return Object.assign({}, row, { agentId, @@ -161,17 +231,10 @@ export async function sessionsCommand( kind: classifySessionKey(row.key, store[row.key]), }); }); - }) - .filter((row) => { - if (activeMinutes === undefined) { - return true; - } - if (!row.updatedAt) { - return false; - } - return Date.now() - row.updatedAt <= activeMinutes * 60_000; - }) - .toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + }); + const totalCount = allRows.length; + const rows = selectNewestSessionRows(allRows, limit); + const hasMore = rows.length < totalCount; if (opts.json) { const multi = targets.length > 1; @@ -186,6 +249,9 @@ export async function sessionsCommand( : undefined, allAgents: aggregateAgents ? true : undefined, count: rows.length, + totalCount, + limitApplied: limit ?? null, + hasMore, activeMinutes: activeMinutes ?? null, sessions: await Promise.all( rows.map(async (r) => { @@ -217,7 +283,13 @@ export async function sessionsCommand( info(`Session stores: ${targets.length} (${targets.map((t) => t.agentId).join(", ")})`), ); } - runtime.log(info(`Sessions listed: ${rows.length}`)); + runtime.log( + info( + hasMore && limit !== undefined + ? `Sessions listed: ${rows.length} of ${totalCount} (limit ${limit})` + : `Sessions listed: ${rows.length}`, + ), + ); if (activeMinutes) { runtime.log(info(`Filtered to last ${activeMinutes} minute(s)`)); } From 86385f72e98daa3c7c9cbf52069333eb0ca59496 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 22:19:17 +0100 Subject: [PATCH 051/107] fix(update): use absolute npm script shell --- src/infra/update-global.test.ts | 41 +++++++++++++++++++++++++++++++++ src/infra/update-global.ts | 34 ++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index fdd3b25f53d..20c401162f9 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -150,6 +150,47 @@ describe("update global helpers", () => { }); }); + it("uses an absolute POSIX script shell for npm lifecycle scripts during global installs", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + try { + await expect( + createGlobalInstallEnv({ + COREPACK_ENABLE_DOWNLOAD_PROMPT: "1", + PATH: "/home/peter/.npm-global/bin", + }), + ).resolves.toMatchObject({ + COREPACK_ENABLE_DOWNLOAD_PROMPT: "1", + NPM_CONFIG_SCRIPT_SHELL: "/bin/sh", + }); + } finally { + platformSpy.mockRestore(); + } + }); + + it("preserves explicit npm script shell config for global installs", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + try { + await expect( + createGlobalInstallEnv({ + COREPACK_ENABLE_DOWNLOAD_PROMPT: "1", + NPM_CONFIG_SCRIPT_SHELL: "/custom/sh", + }), + ).resolves.toMatchObject({ + NPM_CONFIG_SCRIPT_SHELL: "/custom/sh", + }); + await expect( + createGlobalInstallEnv({ + COREPACK_ENABLE_DOWNLOAD_PROMPT: "1", + npm_config_script_shell: "/custom/lower-sh", + }), + ).resolves.toMatchObject({ + npm_config_script_shell: "/custom/lower-sh", + }); + } finally { + platformSpy.mockRestore(); + } + }); + it("resolves portable Git paths from process-local app data only", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); try { diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index a80017f062e..e1628e64380 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -41,6 +41,7 @@ const NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS = [ "--omit=optional", ...NPM_GLOBAL_INSTALL_QUIET_FLAGS, ] as const; +const NPM_CONFIG_SCRIPT_SHELL_KEYS = ["NPM_CONFIG_SCRIPT_SHELL", "npm_config_script_shell"]; const FIRST_PACKAGED_DIST_INVENTORY_VERSION = { major: 2026, minor: 4, patch: 15 }; const OMITTED_PRIVATE_QA_BUNDLED_PLUGIN_ROOTS = new Set([ "dist/extensions/qa-channel", @@ -315,6 +316,31 @@ function applyCorepackDownloadPromptEnv(env: Record) { } } +function hasNpmScriptShellSetting(env: NodeJS.ProcessEnv): boolean { + return NPM_CONFIG_SCRIPT_SHELL_KEYS.some((key) => Boolean(env[key]?.trim())); +} + +function resolvePosixNpmScriptShell(env: NodeJS.ProcessEnv): string | null { + if (process.platform === "win32") { + return null; + } + if (fsSync.existsSync("/bin/sh")) { + return "/bin/sh"; + } + const shell = env.SHELL?.trim(); + return shell && path.isAbsolute(shell) && fsSync.existsSync(shell) ? shell : null; +} + +function applyPosixNpmScriptShellEnv(env: Record) { + if (hasNpmScriptShellSetting(env)) { + return; + } + const scriptShell = resolvePosixNpmScriptShell(env); + if (scriptShell) { + env.NPM_CONFIG_SCRIPT_SHELL = scriptShell; + } +} + export function resolveGlobalInstallSpec(params: { packageName: string; tag: string; @@ -344,8 +370,13 @@ export async function createGlobalInstallEnv( const hasCorepackDownloadPromptSetting = Boolean( sourceEnv.COREPACK_ENABLE_DOWNLOAD_PROMPT?.trim(), ); + const missingPosixScriptShell = + Boolean(resolvePosixNpmScriptShell(sourceEnv)) && !hasNpmScriptShellSetting(sourceEnv); const requiresMergedEnv = - pathPrepend.length > 0 || process.platform === "win32" || !hasCorepackDownloadPromptSetting; + pathPrepend.length > 0 || + process.platform === "win32" || + !hasCorepackDownloadPromptSetting || + missingPosixScriptShell; if (!requiresMergedEnv) { return env; } @@ -357,6 +388,7 @@ export async function createGlobalInstallEnv( applyPathPrepend(merged, pathPrepend); applyWindowsPackageInstallEnv(merged); applyCorepackDownloadPromptEnv(merged); + applyPosixNpmScriptShellEnv(merged); return merged; } From 0909df1a4f3d092dba9fc551e105a0564ff4cdd2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 22:25:12 +0100 Subject: [PATCH 052/107] refactor: centralize reply followup drain lifecycle --- .../reply/agent-runner-execution.test.ts | 1 + src/auto-reply/reply/agent-runner.ts | 36 +++--- .../reply/reply-run-registry.test.ts | 17 +++ src/auto-reply/reply/reply-run-registry.ts | 9 ++ .../server.chat.gateway-server-chat-b.test.ts | 103 ++++++++++++++++++ 5 files changed, 149 insertions(+), 17 deletions(-) diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index c855e21282d..0274293bab8 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -272,6 +272,7 @@ function createMockReplyOperation(): { attachBackend: vi.fn(), detachBackend: vi.fn(), complete: vi.fn(), + completeThen: vi.fn((afterClear: () => void) => afterClear()), fail: failMock, abortByUser: vi.fn(), abortForRestart: vi.fn(), diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 022c13106b7..eddefddae0a 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1148,11 +1148,14 @@ export async function runReplyAgent(params: { throw error; } let runFollowupTurn = queuedRunFollowupTurn; - let shouldDrainFollowupsAfterReplyOperationClears = false; - const returnAfterReplyOperationClearsThenDrainFollowups = (value: T): T => { - shouldDrainFollowupsAfterReplyOperationClears = true; + let shouldDrainQueuedFollowupsAfterClear = false; + const returnWithQueuedFollowupDrain = (value: T): T => { + shouldDrainQueuedFollowupsAfterClear = true; return value; }; + const drainQueuedFollowupsAfterClear = () => { + scheduleFollowupDrain(queueKey, runFollowupTurn); + }; const prePreflightCompactionCount = activeSessionEntry?.compactionCount ?? 0; let preflightCompactionApplied = false; @@ -1288,7 +1291,7 @@ export async function runReplyAgent(params: { if (!replyOperation.result) { replyOperation.fail("run_failed", new Error("reply operation exited with final payload")); } - return returnAfterReplyOperationClearsThenDrainFollowups(runOutcome.payload); + return returnWithQueuedFollowupDrain(runOutcome.payload); } const { @@ -1421,7 +1424,7 @@ export async function runReplyAgent(params: { // Otherwise, a late typing trigger (e.g. from a tool callback) can outlive the run and // keep the typing indicator stuck. if (payloadArray.length === 0) { - return returnAfterReplyOperationClearsThenDrainFollowups(undefined); + return returnWithQueuedFollowupDrain(undefined); } const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid; @@ -1453,7 +1456,7 @@ export async function runReplyAgent(params: { didLogHeartbeatStrip = payloadResult.didLogHeartbeatStrip; if (replyPayloads.length === 0) { - return returnAfterReplyOperationClearsThenDrainFollowups(undefined); + return returnWithQueuedFollowupDrain(undefined); } const successfulCronAdds = runResult.successfulCronAdds ?? 0; @@ -1870,7 +1873,7 @@ export async function runReplyAgent(params: { } } - const result = returnAfterReplyOperationClearsThenDrainFollowups( + const result = returnWithQueuedFollowupDrain( finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads, ); @@ -1880,36 +1883,35 @@ export async function runReplyAgent(params: { replyOperation.result?.kind === "aborted" && replyOperation.result.code === "aborted_for_restart" ) { - return returnAfterReplyOperationClearsThenDrainFollowups({ + return returnWithQueuedFollowupDrain({ text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.", }); } if (replyOperation.result?.kind === "aborted") { - return returnAfterReplyOperationClearsThenDrainFollowups({ text: SILENT_REPLY_TOKEN }); + return returnWithQueuedFollowupDrain({ text: SILENT_REPLY_TOKEN }); } if (error instanceof GatewayDrainingError) { replyOperation.fail("gateway_draining", error); - return returnAfterReplyOperationClearsThenDrainFollowups({ + return returnWithQueuedFollowupDrain({ text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.", }); } if (error instanceof CommandLaneClearedError) { replyOperation.fail("command_lane_cleared", error); - return returnAfterReplyOperationClearsThenDrainFollowups({ + return returnWithQueuedFollowupDrain({ text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.", }); } replyOperation.fail("run_failed", error); // Keep the followup queue moving even when an unexpected exception escapes // the run path; the caller still receives the original error. - returnAfterReplyOperationClearsThenDrainFollowups(undefined); + returnWithQueuedFollowupDrain(undefined); throw error; } finally { - replyOperation.complete(); - if (shouldDrainFollowupsAfterReplyOperationClears) { - // Same-session follow-up turns create their own ReplyOperation; start them - // only after this run clears the active-run guard. - scheduleFollowupDrain(queueKey, runFollowupTurn); + if (shouldDrainQueuedFollowupsAfterClear) { + replyOperation.completeThen(drainQueuedFollowupsAfterClear); + } else { + replyOperation.complete(); } blockReplyPipeline?.stop(); typing.markRunComplete(); diff --git a/src/auto-reply/reply/reply-run-registry.test.ts b/src/auto-reply/reply/reply-run-registry.test.ts index 848cb368f21..59f89c96b67 100644 --- a/src/auto-reply/reply/reply-run-registry.test.ts +++ b/src/auto-reply/reply/reply-run-registry.test.ts @@ -66,6 +66,23 @@ describe("reply run registry", () => { expect(replyRunRegistry.isActive("agent:main:main")).toBe(false); }); + it("runs completeThen callbacks after active state clears", () => { + const operation = createReplyOperation({ + sessionKey: "agent:main:main", + sessionId: "session-complete", + resetTriggered: false, + }); + const afterClear = vi.fn(() => { + expect(replyRunRegistry.isActive("agent:main:main")).toBe(false); + expect(isReplyRunActiveForSessionId("session-complete")).toBe(false); + }); + + operation.completeThen(afterClear); + + expect(operation.result).toEqual({ kind: "completed" }); + expect(afterClear).toHaveBeenCalledTimes(1); + }); + it("force-clears a running operation after abort without backend cleanup", async () => { vi.useFakeTimers(); try { diff --git a/src/auto-reply/reply/reply-run-registry.ts b/src/auto-reply/reply/reply-run-registry.ts index f0c0127c872..4a37f4b7025 100644 --- a/src/auto-reply/reply/reply-run-registry.ts +++ b/src/auto-reply/reply/reply-run-registry.ts @@ -54,6 +54,11 @@ export type ReplyOperation = { attachBackend(handle: ReplyBackendHandle): void; detachBackend(handle: ReplyBackendHandle): void; complete(): void; + /** + * Complete the operation, clear active-run state, then run follow-up work. + * Use when the follow-up can create another ReplyOperation for this session. + */ + completeThen(afterClear: () => void): void; fail(code: Exclude, cause?: unknown): void; abortByUser(): void; abortForRestart(): void; @@ -332,6 +337,10 @@ export function createReplyOperation(params: { } clearState(); }, + completeThen(afterClear) { + operation.complete(); + afterClear(); + }, fail(code, cause) { if (!result) { result = { kind: "failed", code, cause }; diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index eab2c1eda32..35af73e73c0 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -439,6 +439,109 @@ describe("gateway server chat", () => { } }); + test("chat.send starts the next WebChat turn after the prior internal run finishes", async () => { + const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); + try { + testState.sessionStorePath = path.join(sessionDir, "sessions.json"); + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }, + }); + + const responses: Array<{ id: string; ok: boolean; payload?: unknown; error?: unknown }> = []; + const context = { + loadGatewayModelCatalog: vi.fn(), + logGateway: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + agentRunSeq: new Map(), + chatAbortControllers: new Map(), + chatAbortedRuns: new Map(), + chatRunBuffers: new Map(), + chatDeltaSentAt: new Map(), + chatDeltaLastBroadcastLen: new Map(), + addChatRun: vi.fn(), + removeChatRun: vi.fn(), + broadcast: vi.fn(), + nodeSendToSession: vi.fn(), + registerToolEventRecipient: vi.fn(), + dedupe: new Map(), + } as unknown as GatewayRequestContext; + dispatchInboundMessageMock.mockResolvedValue(undefined); + + const { chatHandlers } = await import("./server-methods/chat.js"); + const callSend = (id: string, message: string, idempotencyKey: string) => + chatHandlers["chat.send"]({ + req: { + type: "req", + id, + method: "chat.send", + params: { + sessionKey: "main", + message, + idempotencyKey, + }, + }, + params: { + sessionKey: "main", + message, + idempotencyKey, + }, + client: { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + }, + scopes: ["operator.write"], + }, + } as never, + isWebchatConnect: () => true, + respond: ((ok, payload, error) => { + responses.push({ id, ok, payload, error }); + }) as RespondFn, + context, + }); + + await callSend("first", "first message", "idem-sequential-a"); + await vi.waitFor(() => { + expect(context.removeChatRun).toHaveBeenCalledTimes(1); + }, FAST_WAIT_OPTS); + + await callSend("second", "second message", "idem-sequential-b"); + await vi.waitFor(() => { + expect(context.removeChatRun).toHaveBeenCalledTimes(2); + }, FAST_WAIT_OPTS); + + expect(responses).toContainEqual({ + id: "first", + ok: true, + payload: { runId: "idem-sequential-a", status: "started" }, + error: undefined, + }); + expect(responses).toContainEqual({ + id: "second", + ok: true, + payload: { runId: "idem-sequential-b", status: "started" }, + error: undefined, + }); + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(2); + expect(context.addChatRun).toHaveBeenCalledTimes(2); + } finally { + dispatchInboundMessageMock.mockReset(); + testState.sessionStorePath = undefined; + clearConfigCache(); + await fs.rm(sessionDir, { recursive: true, force: true }); + } + }); + test("chat.history backfills claude-cli sessions from Claude project files", async () => { await withGatewayChatHarness(async ({ ws, createSessionDir }) => { await connectOk(ws); From 4556707cb7d844df26da2d7ab2d3697d0da946c6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 22:29:08 +0100 Subject: [PATCH 053/107] test(browser): mirror route URL guard in existing-session helper --- .../routes/existing-session.test-support.ts | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/extensions/browser/src/browser/routes/existing-session.test-support.ts b/extensions/browser/src/browser/routes/existing-session.test-support.ts index 4d1313fbc19..83049920409 100644 --- a/extensions/browser/src/browser/routes/existing-session.test-support.ts +++ b/extensions/browser/src/browser/routes/existing-session.test-support.ts @@ -1,4 +1,9 @@ import { vi } from "vitest"; +import { + assertBrowserNavigationResultAllowed, + withBrowserNavigationPolicy, +} from "../navigation-guard.js"; +import type { BrowserRouteContext } from "../server-context.js"; import type { BrowserRequest } from "./types.js"; export const existingSessionRouteState = { @@ -37,14 +42,33 @@ export function createExistingSessionAgentSharedModule() { typeof body.targetId === "string" ? body.targetId : undefined, ), withPlaywrightRouteContext: vi.fn(), - withRouteTabContext: vi.fn(async ({ run }: { run: (args: unknown) => Promise }) => { - await run({ - profileCtx: existingSessionRouteState.profileCtx, - cdpUrl: "http://127.0.0.1:18800", - tab: existingSessionRouteState.tab, - resolveTabUrl: vi.fn(async (fallbackUrl?: string) => fallbackUrl ?? routeStateUrl()), - }); - }), + withRouteTabContext: vi.fn( + async ({ + ctx, + enforceCurrentUrlAllowed, + run, + }: { + ctx: BrowserRouteContext; + enforceCurrentUrlAllowed?: boolean; + run: (args: unknown) => Promise; + }) => { + if (enforceCurrentUrlAllowed) { + const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy); + if (ssrfPolicyOpts.ssrfPolicy) { + await assertBrowserNavigationResultAllowed({ + url: existingSessionRouteState.tab.url, + ...ssrfPolicyOpts, + }); + } + } + await run({ + profileCtx: existingSessionRouteState.profileCtx, + cdpUrl: "http://127.0.0.1:18800", + tab: existingSessionRouteState.tab, + resolveTabUrl: vi.fn(async (fallbackUrl?: string) => fallbackUrl ?? routeStateUrl()), + }); + }, + ), }; } From 5005f5b22e10531d7d49dad4ae8205c7d8e2f551 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 22:34:30 +0100 Subject: [PATCH 054/107] docs(changelog): note npm script shell update fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af367887c91..ce57f29e3a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai ### Fixes - CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc. +- CLI/update: use an absolute POSIX npm script shell during package-manager updates, so restricted PATH environments can still run dependency lifecycle scripts while updating from `--tag main`. Fixes #77530. Thanks @PeterTremonti. - Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. - Browser: enforce strict SSRF current-URL checks before existing-session screenshots, matching existing-session snapshot handling. Thanks @vincentkoc. - Active Memory: give timeout partial transcript recovery enough abort-settle headroom so temporary recall summaries are returned before cleanup. Thanks @vincentkoc. From a4f2bf273a219eba218cd2e010ef7a17aa73e361 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 14:19:42 -0700 Subject: [PATCH 055/107] fix(openai): default direct responses to sse --- CHANGELOG.md | 1 + extensions/openai/openai-provider.test.ts | 4 ++-- extensions/openai/openai-provider.ts | 2 +- extensions/openai/shared.ts | 6 ++++-- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce57f29e3a7..dcce92716c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc. - CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc. - CLI/update: use an absolute POSIX npm script shell during package-manager updates, so restricted PATH environments can still run dependency lifecycle scripts while updating from `--tag main`. Fixes #77530. Thanks @PeterTremonti. - Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index 3cb2872f5f0..24b96524566 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -508,9 +508,9 @@ describe("buildOpenAIProvider", () => { }); expect(extraParams).toMatchObject({ - transport: "auto", - openaiWsWarmup: true, + transport: "sse", }); + expect(extraParams?.openaiWsWarmup).toBeUndefined(); expect(result.payload.store).toBe(true); expect(result.payload.context_management).toEqual([ { type: "compaction", compact_threshold: 140_000 }, diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 969b04ed5dc..76503bb293d 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -227,7 +227,7 @@ export function buildOpenAIProvider(): ProviderPlugin { shouldUseOpenAIResponsesTransport({ provider, api, baseUrl }) ? { api: "openai-responses", baseUrl } : undefined, - ...buildOpenAIResponsesProviderHooks({ openaiWsWarmup: true }), + ...buildOpenAIResponsesProviderHooks({ transport: "sse" }), matchesContextOverflowError: ({ errorMessage }) => /content_filter.*(?:prompt|input).*(?:too long|exceed)/i.test(errorMessage), resolveReasoningOutputMode: () => "native", diff --git a/extensions/openai/shared.ts b/extensions/openai/shared.ts index c2b43cbe9be..143737f3d9d 100644 --- a/extensions/openai/shared.ts +++ b/extensions/openai/shared.ts @@ -50,10 +50,11 @@ function hasSupportedOpenAIResponsesTransport( function defaultOpenAIResponsesExtraParams( extraParams: Record | undefined, - options?: { openaiWsWarmup?: boolean }, + options?: { openaiWsWarmup?: boolean; transport?: "auto" | "sse" | "websocket" }, ): Record | undefined { const hasSupportedTransport = hasSupportedOpenAIResponsesTransport(extraParams?.transport); const hasExplicitWarmup = typeof extraParams?.openaiWsWarmup === "boolean"; + const defaultTransport = options?.transport ?? "auto"; const shouldDefaultWarmup = options?.openaiWsWarmup === true; if (hasSupportedTransport && (!shouldDefaultWarmup || hasExplicitWarmup)) { return extraParams; @@ -61,7 +62,7 @@ function defaultOpenAIResponsesExtraParams( return { ...extraParams, - ...(hasSupportedTransport ? {} : { transport: "auto" }), + ...(hasSupportedTransport ? {} : { transport: defaultTransport }), ...(shouldDefaultWarmup && !hasExplicitWarmup ? { openaiWsWarmup: true } : {}), }; } @@ -93,6 +94,7 @@ const wrapOpenAIResponsesProviderStreamFn: NonNullable< export function buildOpenAIResponsesProviderHooks(options?: { openaiWsWarmup?: boolean; + transport?: "auto" | "sse" | "websocket"; }): OpenAIResponsesProviderHooks { return { buildReplayPolicy: buildOpenAIReplayPolicy, From 1c52447f0bd52fba7e4c7165e2d5f50b6309b20c Mon Sep 17 00:00:00 2001 From: Penchan <5032148+p3nchan@users.noreply.github.com> Date: Tue, 5 May 2026 05:46:29 +0800 Subject: [PATCH 056/107] fix(plugins): treat CalVer correction versions as compatible with plugin API ranges (#77450) * fix(plugins): accept CalVer correction plugin API hosts Fixes #77293 * docs(changelog): credit plugin api calver fix pr --------- Co-authored-by: pingu --- CHANGELOG.md | 1 + src/infra/clawhub.test.ts | 6 ++++++ src/infra/clawhub.ts | 12 +++++++++++- src/plugins/clawhub.test.ts | 30 ++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcce92716c8..34256c64fef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai - Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob. - CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc. - Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc. +- Plugins/update: treat OpenClaw CalVer correction versions like `2026.5.3-1` as satisfying base plugin API ranges, so correction builds can install plugins that require the base runtime API. Fixes #77293. (#77450) Thanks @p3nchan. - fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987. - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. - Active Memory: send a bounded latest-message search query to the recall worker so channel/runtime metadata does not become the memory search string. Fixes #65309. Thanks @joeykrug, @westley3601, @pimenov, and @tasi333. diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index 1e404208df7..45e8d85b61c 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -100,6 +100,12 @@ describe("clawhub helpers", () => { expect(satisfiesPluginApiRange("invalid", "^1.2.0")).toBe(false); }); + it("treats OpenClaw CalVer correction versions as stable plugin API hosts", () => { + expect(satisfiesPluginApiRange("2026.5.3-1", ">=2026.5.3")).toBe(true); + expect(satisfiesPluginApiRange("2026.5.3-2", ">=2026.5.3")).toBe(true); + expect(satisfiesPluginApiRange("2026.5.3-beta.1", ">=2026.5.3")).toBe(false); + }); + it("accepts legacy bare major.minor plugin api ranges as lower bounds", () => { expect(satisfiesPluginApiRange("2026.5.2", "2026.4")).toBe(true); expect(satisfiesPluginApiRange("2026.4.0", "2026.4")).toBe(true); diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index 1d2b0472348..d8f4a4df59c 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -542,6 +542,13 @@ function satisfiesSemverRange(version: string, range: string): boolean { return tokens.every((token) => satisfiesComparator(version, token)); } +const OPENCLAW_CALVER_STABLE_CORRECTION_PATTERN = /^[vV]?(\d{4}\.\d{1,2}\.\d{1,2})-\d+$/; + +function normalizeCalVerCorrectionForPluginApi(pluginApiVersion: string): string { + const match = OPENCLAW_CALVER_STABLE_CORRECTION_PATTERN.exec(pluginApiVersion.trim()); + return match?.[1] ?? pluginApiVersion; +} + function buildUrl(params: Pick): URL { const url = new URL(params.path, `${normalizeBaseUrl(params.baseUrl)}/`); for (const [key, value] of Object.entries(params.search ?? {})) { @@ -1046,7 +1053,10 @@ export function satisfiesPluginApiRange( if (!pluginApiRange) { return true; } - return satisfiesSemverRange(pluginApiVersion, pluginApiRange); + return satisfiesSemverRange( + normalizeCalVerCorrectionForPluginApi(pluginApiVersion), + pluginApiRange, + ); } export function satisfiesGatewayMinimum( diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index 99cc2a922a9..63d0ebb3eb2 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -852,6 +852,36 @@ describe("installPluginFromClawHub", () => { expect(archiveCleanupMock).toHaveBeenCalledTimes(1); }); + it("installs when a CalVer correction runtime satisfies the base plugin API range", async () => { + resolveCompatibilityHostVersionMock.mockReturnValueOnce("2026.5.3-1"); + fetchClawHubPackageVersionMock.mockResolvedValueOnce({ + version: { + version: "2026.5.3", + createdAt: 0, + changelog: "", + sha256hash: "a9eac48c6129bc44b6f93c9a9f48f6c700d191b7279a1e1915f28df6f59bb1af", + compatibility: { + pluginApiRange: ">=2026.5.3", + minGatewayVersion: "2026.3.0", + }, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + expectSuccessfulClawHubInstall(result); + expect(downloadClawHubPackageArchiveMock).toHaveBeenCalledTimes(1); + expect(installPluginFromArchiveMock).toHaveBeenCalledWith( + expect.objectContaining({ + archivePath: "/tmp/clawhub-demo/archive.zip", + }), + ); + expect(archiveCleanupMock).toHaveBeenCalledTimes(1); + }); + it("does not let a wildcard plugin API range hide an invalid runtime version", async () => { resolveCompatibilityHostVersionMock.mockReturnValueOnce("invalid"); fetchClawHubPackageVersionMock.mockResolvedValueOnce({ From d0cae0d9500edc207eb66683320abbfc6018f166 Mon Sep 17 00:00:00 2001 From: hcl Date: Tue, 5 May 2026 05:47:38 +0800 Subject: [PATCH 057/107] fix(active-memory): skip sub-agent gracefully when no memory tools registered (#77506) (#77515) * fix(active-memory): skip sub-agent gracefully when no memory tools registered (#77506) When memory-core and memory-lancedb are both absent, the embedded memory sub-agent would throw 'No callable tools remain after resolving explicit tool allowlist', which propagated as a noisy warning through the before_prompt_build hook. Catch this specific error in runActiveMemorySubAgent and return an empty NONE result so the gateway log stays clean and the sub-agent run is skipped without disrupting the parent session. * fix(active-memory): skip missing memory-tool subagent runs * fix(active-memory): match inherited missing memory tool errors * fix(active-memory): preserve policy-filtered memory errors --------- Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/active-memory/index.test.ts | 144 +++++++++++++++++++++++++ extensions/active-memory/index.ts | 42 +++++++- 3 files changed, 186 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34256c64fef..e359d8987a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai - fix(qqbot): keep private commands off framework surface [AI]. (#77212) Thanks @pgondhi987. - Claude CLI: honor non-off `/think` levels by passing Claude Code's session-scoped `--effort` flag through the CLI backend seam, so chat bridges no longer show an inert thinking control. Fixes #77303. Thanks @Petr1t. - Agents/subagents: refresh deferred final-delivery payloads when same-session completion output changes, so retried parent notifications use the final child summary instead of stale progress text. Thanks @vincentkoc. +- active-memory: skip the memory sub-agent gracefully instead of logging a confusing allowlist error when no memory plugin (`memory-core` or `memory-lancedb`) is loaded, so active-memory with no memory backend no longer produces misleading "No callable tools remain" warnings in the gateway log. Fixes #77506. Thanks @hclsys. - Memory/wiki: preserve representation from both corpora in `corpus=all` searches while backfilling unused result capacity, so memory hits are not starved by numerically higher wiki integer scores. Fixes #77337. Thanks @hclsys. - Telegram: clean up tool-only draft previews after assistant message boundaries so transient `Surfacing...` tool-status bubbles do not linger when no matching final preview arrives. Thanks @BunsDev. - Cron: surface failed isolated-run diagnostics in `cron show`, status, and run history when requested tools are unavailable, so blocked cron runs report the actual tool-policy failure instead of a misleading green result. Fixes #75763. Thanks @RyanSandoval. diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index d1e56d193fb..fcb711eb33b 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -125,6 +125,23 @@ describe("active-memory plugin", () => { "utf8", ); }; + const makeMemoryToolAllowlistError = ( + reason: string, + sources = "runtime toolsAllow: memory_recall, memory_search, memory_get", + ) => + new Error( + `No callable tools remain after resolving explicit tool allowlist ` + + `(${sources}); ${reason}. ` + + `Fix the allowlist or enable the plugin that registers the requested tool.`, + ); + const hasDebugLine = (needle: string) => + vi + .mocked(api.logger.debug) + .mock.calls.some((call: unknown[]) => String(call[0]).includes(needle)); + const hasWarnLine = (needle: string) => + vi + .mocked(api.logger.warn) + .mock.calls.some((call: unknown[]) => String(call[0]).includes(needle)); beforeEach(async () => { vi.clearAllMocks(); @@ -1646,6 +1663,133 @@ describe("active-memory plugin", () => { expect(result).toBeUndefined(); }); + it("skips the recall subagent when no registered memory tools match", async () => { + const sessionKey = "agent:main:missing-memory-tools"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-missing-memory-tools", + updatedAt: 0, + }; + const error = makeMemoryToolAllowlistError("no registered tools matched"); + expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(true); + runEmbeddedPiAgent.mockRejectedValueOnce(error); + + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order? missing memory tools", messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no memory tools registered")).toBe(true); + expect(hasWarnLine("No callable tools remain")).toBe(false); + const lines = getActiveMemoryLines(sessionKey); + expect(lines).toEqual([expect.stringContaining("🧩 Active Memory: status=empty")]); + expect(lines.join("\n")).not.toContain("status=unavailable"); + }); + + it("skips missing memory tools when the allowlist error includes inherited sources", async () => { + const sessionKey = "agent:main:missing-memory-tools-with-policy-source"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-missing-memory-tools-with-policy-source", + updatedAt: 0, + }; + const error = makeMemoryToolAllowlistError( + "no registered tools matched", + "tools.allow: *, lobster; runtime toolsAllow: memory_recall, memory_search, memory_get", + ); + expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(true); + runEmbeddedPiAgent.mockRejectedValueOnce(error); + + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order? missing memory tools with policy", messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no memory tools registered")).toBe(true); + expect(hasWarnLine("No callable tools remain")).toBe(false); + expect(getActiveMemoryLines(sessionKey)).toEqual([ + expect.stringContaining("🧩 Active Memory: status=empty"), + ]); + }); + + it("keeps memory-tool allowlist errors visible when upstream policy can filter memory tools", async () => { + const sessionKey = "agent:main:memory-tools-filtered-by-policy"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-memory-tools-filtered-by-policy", + updatedAt: 0, + }; + const error = makeMemoryToolAllowlistError( + "no registered tools matched", + "tools.allow: read, exec; runtime toolsAllow: memory_recall, memory_search, memory_get", + ); + expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(false); + runEmbeddedPiAgent.mockRejectedValueOnce(error); + + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order? memory tools filtered by policy", messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no memory tools registered")).toBe(false); + expect(hasWarnLine("No callable tools remain")).toBe(true); + expect(getActiveMemoryLines(sessionKey)).toEqual([ + expect.stringContaining("🧩 Active Memory: status=unavailable"), + ]); + }); + + it.each([ + ["disabled tools", "tools are disabled for this run"], + ["models without tool support", "the selected model does not support tools"], + ])("keeps allowlist errors for %s visible", async (_label, reason) => { + const sessionKey = `agent:main:${reason.replace(/\W+/g, "-")}`; + hoisted.sessionStore[sessionKey] = { + sessionId: `s-${reason.replace(/\W+/g, "-")}`, + updatedAt: 0, + }; + const error = makeMemoryToolAllowlistError(reason); + expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(false); + runEmbeddedPiAgent.mockRejectedValueOnce(error); + + const result = await hooks.before_prompt_build( + { prompt: `what wings should i order? ${reason}`, messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no memory tools registered")).toBe(false); + expect(hasWarnLine(reason)).toBe(true); + expect(getActiveMemoryLines(sessionKey)).toEqual([ + expect.stringContaining("🧩 Active Memory: status=unavailable"), + ]); + }); + + it("does not skip missing memory-tool allowlist errors after abort", async () => { + const sessionKey = "agent:main:missing-memory-tools-after-abort"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-missing-memory-tools-after-abort", + updatedAt: 0, + }; + runEmbeddedPiAgent.mockImplementationOnce(async (params: { abortSignal?: AbortSignal }) => { + Object.defineProperty(params.abortSignal as AbortSignal, "aborted", { + configurable: true, + value: true, + }); + throw makeMemoryToolAllowlistError("no registered tools matched"); + }); + + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order? missing memory tools after abort", messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no memory tools registered")).toBe(false); + expect(getActiveMemoryLines(sessionKey)).toEqual([ + expect.stringContaining("🧩 Active Memory: status=timeout"), + ]); + }); + it("returns partial transcript text on timeout when the subagent has already written assistant output", async () => { __testing.setMinimumTimeoutMsForTests(1); __testing.setSetupGraceTimeoutMsForTests(0); diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index b6f80208ae5..10d891405be 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -41,6 +41,7 @@ const DEFAULT_QMD_SEARCH_MODE = "search" as const; const DEFAULT_TRANSCRIPT_DIR = "active-memory"; const DEFAULT_CIRCUIT_BREAKER_MAX_TIMEOUTS = 3; const DEFAULT_CIRCUIT_BREAKER_COOLDOWN_MS = 60_000; +const ACTIVE_MEMORY_TOOL_ALLOWLIST = ["memory_recall", "memory_search", "memory_get"] as const; const TOGGLE_STATE_FILE = "session-toggles.json"; const DEFAULT_PARTIAL_TRANSCRIPT_MAX_CHARS = 32_000; const DEFAULT_TRANSCRIPT_READ_MAX_LINES = 2_000; @@ -494,6 +495,38 @@ function normalizeOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } +function isMissingRegisteredMemoryToolsError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const message = error.message.trim(); + const prefix = "No callable tools remain after resolving explicit tool allowlist ("; + const suffix = + "); no registered tools matched. Fix the allowlist or enable the plugin that registers the requested tool."; + if (!message.startsWith(prefix) || !message.endsWith(suffix)) { + return false; + } + const sources = message.slice(prefix.length, -suffix.length); + const runtimeSource = `runtime toolsAllow: ${ACTIVE_MEMORY_TOOL_ALLOWLIST.join(", ")}`; + const sourceParts = sources + .split(";") + .map((source) => source.trim()) + .filter(Boolean); + if (!sourceParts.includes(runtimeSource)) { + return false; + } + return sourceParts.every((source) => { + if (source === runtimeSource) { + return true; + } + const entries = source + .slice(source.indexOf(":") + 1) + .split(",") + .map((entry) => entry.trim()); + return entries.includes("*"); + }); +} + function resolveRecallRunChannelContext(params: { api: OpenClawPluginApi; agentId: string; @@ -2394,7 +2427,7 @@ async function runRecallSubagent(params: { timeoutMs: embeddedTimeoutMs, runId: subagentSessionId, trigger: "manual", - toolsAllow: ["memory_recall", "memory_search", "memory_get"], + toolsAllow: [...ACTIVE_MEMORY_TOOL_ALLOWLIST], disableMessageTool: true, allowGatewaySubagentBinding: true, bootstrapContextMode: "lightweight", @@ -2437,6 +2470,12 @@ async function runRecallSubagent(params: { const searchDebug = partialReply ? await readActiveMemorySearchDebug(sessionFile) : undefined; attachPartialTimeoutData(error, partialReply, searchDebug); } + if (!params.abortSignal?.aborted && isMissingRegisteredMemoryToolsError(error)) { + params.api.logger.debug?.( + `active-memory: no memory tools registered (memory-core or memory-lancedb required); skipping sub-agent`, + ); + return { rawReply: "NONE" }; + } throw error; } finally { if (tempDir) { @@ -2959,6 +2998,7 @@ const testing = { buildPromptPrefix, getCachedResult, isCircuitBreakerOpen, + isMissingRegisteredMemoryToolsError, normalizePluginConfig, readActiveMemorySearchDebug, readPartialAssistantText, From a7b665cfed3455d0ed67129289e25ea61f0b4c89 Mon Sep 17 00:00:00 2001 From: Pnant <73925474+Panniantong@users.noreply.github.com> Date: Mon, 4 May 2026 14:53:06 -0700 Subject: [PATCH 058/107] fix(telegram): honor topic requireMention precedence Telegram forum-topic requireMention config now takes precedence over persisted activation state, with focused regression coverage.\n\nFixes #49864.\nThanks @Panniantong. --- CHANGELOG.md | 1 + ...ot-message-context.require-mention.test.ts | 112 ++++++++++++++++++ .../telegram/src/bot-message-context.ts | 2 +- 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 extensions/telegram/src/bot-message-context.require-mention.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e359d8987a0..df8a091e61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai - active-memory: skip the memory sub-agent gracefully instead of logging a confusing allowlist error when no memory plugin (`memory-core` or `memory-lancedb`) is loaded, so active-memory with no memory backend no longer produces misleading "No callable tools remain" warnings in the gateway log. Fixes #77506. Thanks @hclsys. - Memory/wiki: preserve representation from both corpora in `corpus=all` searches while backfilling unused result capacity, so memory hits are not starved by numerically higher wiki integer scores. Fixes #77337. Thanks @hclsys. - Telegram: clean up tool-only draft previews after assistant message boundaries so transient `Surfacing...` tool-status bubbles do not linger when no matching final preview arrives. Thanks @BunsDev. +- Telegram: let explicit forum-topic `requireMention` settings override persisted `/activate` and `/deactivate` state, so per-topic mention gates work consistently. Fixes #49864. Thanks @Panniantong. - Cron: surface failed isolated-run diagnostics in `cron show`, status, and run history when requested tools are unavailable, so blocked cron runs report the actual tool-policy failure instead of a misleading green result. Fixes #75763. Thanks @RyanSandoval. - TUI/escape abort: track the in-flight runId after `chat.send` resolves so pressing Esc during the gap before the first gateway event aborts the run instead of repeatedly printing `no active run`. Fixes #1296. Thanks @Lukavyi and @romneyda. - TUI/render: stop the long-token sanitizer from injecting literal spaces inside inline code spans, fenced code blocks, table borders, and bare hyphenated/dotted identifiers, so copied package names, entity IDs, and shell line-continuations stay byte-for-byte intact while narrow-terminal protection still chunks unidentifiable long prose tokens. Fixes #48432, #39505. Thanks @DocOellerson, @xeusoc, @CCcassiusdjs, @akramcodez, @brokemac79, @romneyda. diff --git a/extensions/telegram/src/bot-message-context.require-mention.test.ts b/extensions/telegram/src/bot-message-context.require-mention.test.ts new file mode 100644 index 00000000000..deed88a9901 --- /dev/null +++ b/extensions/telegram/src/bot-message-context.require-mention.test.ts @@ -0,0 +1,112 @@ +import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { defaultRouteConfig } = vi.hoisted(() => ({ + defaultRouteConfig: { + agents: { + list: [{ id: "main", default: true }], + }, + channels: { telegram: {} }, + messages: { groupChat: { mentionPatterns: [] } }, + }, +})); + +vi.mock("openclaw/plugin-sdk/runtime-config-snapshot", async () => { + const actual = await vi.importActual< + typeof import("openclaw/plugin-sdk/runtime-config-snapshot") + >("openclaw/plugin-sdk/runtime-config-snapshot"); + return { + ...actual, + getRuntimeConfig: vi.fn(() => defaultRouteConfig), + }; +}); + +const { buildTelegramMessageContextForTest } = + await import("./bot-message-context.test-harness.js"); + +describe("buildTelegramMessageContext requireMention precedence", () => { + function buildForumMessage(threadId = 99) { + return { + message_id: 1, + chat: { + id: -1001234567890, + type: "supergroup" as const, + title: "Forum", + is_forum: true, + }, + date: 1_700_000_000, + text: "hello everyone", + message_thread_id: threadId, + from: { id: 42, first_name: "Alice" }, + }; + } + + beforeEach(() => { + vi.mocked(getRuntimeConfig).mockReturnValue(defaultRouteConfig as never); + }); + + it("lets explicit topic requireMention=false override group requireMention=true", async () => { + const ctx = await buildTelegramMessageContextForTest({ + message: buildForumMessage(), + resolveGroupActivation: () => undefined, + resolveGroupRequireMention: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: true }, + topicConfig: { requireMention: false }, + }), + }); + + expect(ctx).not.toBeNull(); + }); + + it("lets explicit topic requireMention=false override mention activation", async () => { + const resolveGroupActivation = vi.fn(() => true); + + const ctx = await buildTelegramMessageContextForTest({ + message: buildForumMessage(), + resolveGroupActivation, + resolveGroupRequireMention: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: true }, + topicConfig: { requireMention: false }, + }), + }); + + expect(ctx).not.toBeNull(); + expect(resolveGroupActivation).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: -1001234567890, + messageThreadId: 99, + sessionKey: "agent:main:telegram:group:-1001234567890:topic:99", + }), + ); + }); + + it("lets explicit topic requireMention=true override always activation", async () => { + const ctx = await buildTelegramMessageContextForTest({ + message: buildForumMessage(), + resolveGroupActivation: () => false, + resolveGroupRequireMention: () => false, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: { requireMention: true }, + }), + }); + + expect(ctx).toBeNull(); + }); + + it("keeps activation fallback when no topic requireMention is configured", async () => { + const ctx = await buildTelegramMessageContextForTest({ + message: buildForumMessage(), + resolveGroupActivation: () => false, + resolveGroupRequireMention: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: true }, + topicConfig: { agentId: "main" }, + }), + }); + + expect(ctx).not.toBeNull(); + }); +}); diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index d7c44e39587..e969c668161 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -411,8 +411,8 @@ export const buildTelegramMessageContext = async ({ }); const baseRequireMention = resolveGroupRequireMention(chatId); const requireMention = firstDefined( - activationOverride, topicConfig?.requireMention, + activationOverride, telegramGroupConfig?.requireMention, baseRequireMention, ); From 43b5df729593376d733364057b77f030634f85d3 Mon Sep 17 00:00:00 2001 From: Mogglemoss Date: Mon, 4 May 2026 14:57:28 -0700 Subject: [PATCH 059/107] fix(secretrefs): resolve external channel contracts in dist/ sidecars (#77421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(secretrefs): resolve external channel contracts in dist/ sidecars Externalized channel plugins published to npm (e.g. @openclaw/discord since 2026.5.2) keep their compiled secret-contract-api artifact under /dist/, per the package.json `openclaw.runtimeExtensions` convention. The runtime contract loader added in #76449 only searched the rootDir, so npm-installed plugins silently dropped their channel SecretRef contracts: the runtime snapshot left `channels..token` as an unresolved SecretRef, the plugin's `isConfigured` check then returned false, and the gateway recorded `error: not configured` without firing the usual channel startup logs. Look in `/dist/` as well as `/`, preferring dist when running from a built openclaw artifact and rootDir when running from source. The new `loads dist/ secret-contract-api sidecars …` test in channel-contract-api.external.test.ts mirrors the real npm-package layout and fails without this change. Refs #76371. Fixes #77416. * docs: credit changelog contributor --------- Co-authored-by: Magpie Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com> --- CHANGELOG.md | 1 + .../channel-contract-api.external.test.ts | 60 +++++++++++++++++++ src/secrets/channel-contract-api.ts | 25 ++++---- 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df8a091e61d..ae545c7c722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Changes - Plugins/active-memory: skip session-store channel entries that contain `:` when resolving the recall subagent's channel, so QQ c2c agent IDs (e.g. `c2c:10D4F7C2…`) and other scoped conversation IDs do not reach bundled-plugin `dirName` validation and crash the recall run. The same guard already applied to explicit `channelId` params (#76704); this extends it to store-derived channels. (#77396) Thanks @hclsys. +- Secrets/external channel contracts: also look in `/dist/` when resolving the `secret-contract-api` sidecar, so npm-published externalized channel plugins (e.g. `@openclaw/discord` since 2026.5.2) whose compiled artifacts live under `dist/` actually contribute their channel SecretRef contracts to the runtime snapshot. Without this, env-backed `channels.discord.token` SecretRefs silently failed to resolve at gateway start on 2026.5.3, leaving the channel `not configured` even though #76449 had landed the generic external-contract loader. Thanks @mogglemoss. - Models/auth: add `openclaw models auth list [--provider ] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments” path. Thanks @vincentkoc. - Control UI/header: show the active agent name in dashboard breadcrumbs without adding the current session key, keeping non-chat views oriented without crowding the topbar. - Control UI/cron: make the New Job sidebar collapsible so the jobs list can reclaim space while keeping the form one click away. Thanks @BunsDev. diff --git a/src/secrets/channel-contract-api.external.test.ts b/src/secrets/channel-contract-api.external.test.ts index 47a4b9a16ad..8fff506a62c 100644 --- a/src/secrets/channel-contract-api.external.test.ts +++ b/src/secrets/channel-contract-api.external.test.ts @@ -98,6 +98,66 @@ describe("external channel secret contract api", () => { expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function"); }); + it("loads dist/ secret-contract-api sidecars for compiled npm-published external channel plugins", () => { + const rootDir = makeTrackedTempDir("openclaw-channel-secret-contract-dist", tempDirs); + fs.mkdirSync(path.join(rootDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(rootDir, "dist", "secret-contract-api.cjs"), + ` +module.exports = { + secretTargetRegistryEntries: [ + { + id: "channels.discord.token", + targetType: "channels.discord.token", + configFile: "openclaw.json", + pathPattern: "channels.discord.token", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true + } + ], + collectRuntimeConfigAssignments(params) { + params.context.assignments.push({ + path: "channels.discord.token", + ref: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + expected: "string", + apply() {} + }); + } +}; +`, + "utf8", + ); + const record = { + id: "discord", + origin: "global", + channels: ["discord"], + channelConfigs: {}, + rootDir, + }; + loadPluginMetadataSnapshotMock.mockReturnValue({ + plugins: [record], + }); + + const api = loadChannelSecretContractApi({ + channelId: "discord", + config: { channels: { discord: {} } }, + env: {}, + loadablePluginOrigins: new Map([["discord", "global"]]), + }); + + expect(api?.secretTargetRegistryEntries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "channels.discord.token", + }), + ]), + ); + expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function"); + }); + it("skips external channel records outside the loadable plugin origin set", () => { const record = writeExternalChannelPlugin({ pluginId: "discord", channelId: "discord" }); loadPluginMetadataSnapshotMock.mockReturnValue({ diff --git a/src/secrets/channel-contract-api.ts b/src/secrets/channel-contract-api.ts index 9f97ff59e78..908b2d48e5b 100644 --- a/src/secrets/channel-contract-api.ts +++ b/src/secrets/channel-contract-api.ts @@ -87,16 +87,21 @@ function orderedContractApiExtensions(): readonly string[] { } function resolvePluginContractApiPath(rootDir: string): string | null { - for (const extension of orderedContractApiExtensions()) { - const candidate = path.join(rootDir, `secret-contract-api${extension}`); - if (fs.existsSync(candidate)) { - return candidate; - } - } - for (const extension of orderedContractApiExtensions()) { - const candidate = path.join(rootDir, `contract-api${extension}`); - if (fs.existsSync(candidate)) { - return candidate; + // Compiled npm-published plugins place their public artifacts under /dist/ + // (per package.json `openclaw.runtimeExtensions`), while flat-layout plugins keep + // them at /. Search both, preferring dist/ when running from built openclaw + // artifacts and rootDir/ when running from source. + const searchDirs = RUNNING_FROM_BUILT_ARTIFACT + ? [path.join(rootDir, "dist"), rootDir] + : [rootDir, path.join(rootDir, "dist")]; + for (const basename of ["secret-contract-api", "contract-api"]) { + for (const dir of searchDirs) { + for (const extension of orderedContractApiExtensions()) { + const candidate = path.join(dir, `${basename}${extension}`); + if (fs.existsSync(candidate)) { + return candidate; + } + } } } return null; From 8ee08b2b77a5b1436542966a93f4fd89f2c1a9f5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:06:49 +0100 Subject: [PATCH 060/107] chore: update dependencies --- CHANGELOG.md | 1 + extensions/acpx/package.json | 4 +- extensions/acpx/skills/acp-router/SKILL.md | 4 +- extensions/acpx/src/codex-auth-bridge.test.ts | 4 +- extensions/acpx/src/codex-auth-bridge.ts | 18 +- extensions/acpx/src/manifest.test.ts | 4 +- extensions/acpx/src/runtime.test.ts | 4 +- extensions/amazon-bedrock-mantle/package.json | 4 +- extensions/amazon-bedrock/package.json | 4 +- extensions/anthropic-vertex/package.json | 4 +- extensions/anthropic/package.json | 2 +- extensions/bonjour/package.json | 2 +- extensions/browser/package.json | 2 +- extensions/codex/package.json | 4 +- extensions/discord/package.json | 2 +- extensions/fireworks/package.json | 2 +- extensions/github-copilot/package.json | 2 +- extensions/google/package.json | 2 +- extensions/googlechat/package.json | 2 +- extensions/kimi-coding/package.json | 2 +- extensions/lmstudio/package.json | 2 +- extensions/memory-lancedb/package.json | 2 +- extensions/memory-wiki/package.json | 2 +- extensions/migrate-hermes/package.json | 2 +- extensions/nextcloud-talk/package.json | 2 +- extensions/nostr/package.json | 2 +- extensions/ollama/package.json | 2 +- extensions/openai/openclaw.plugin.test.ts | 2 +- extensions/openai/package.json | 2 +- extensions/qa-lab/package.json | 6 +- extensions/qa-matrix/package.json | 2 +- extensions/qqbot/package.json | 2 +- extensions/slack/package.json | 4 +- extensions/synology-chat/package.json | 2 +- extensions/telegram/package.json | 2 +- extensions/tlon/package.json | 4 +- extensions/webhooks/package.json | 2 +- extensions/whatsapp/package.json | 2 +- extensions/xai/package.json | 2 +- extensions/zalo/package.json | 2 +- package.json | 38 +- ...ntprotocol__claude-agent-acp@0.32.0.patch} | 10 +- pnpm-lock.yaml | 811 +++++++++--------- ...-embedded-subscribe.handlers.tools.test.ts | 42 + .../pi-embedded-subscribe.handlers.tools.ts | 6 +- 45 files changed, 552 insertions(+), 476 deletions(-) rename patches/{@agentclientprotocol__claude-agent-acp@0.31.4.patch => @agentclientprotocol__claude-agent-acp@0.32.0.patch} (82%) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae545c7c722..101b3d967e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Dependencies: refresh runtime and provider packages including Pi 0.73.0, ACPX adapters, OpenAI, Anthropic, Slack, and TypeScript native preview, while keeping the Bedrock runtime installer override pinned below the Windows ARM Node 24 npm resolver failure. - Plugins/active-memory: skip session-store channel entries that contain `:` when resolving the recall subagent's channel, so QQ c2c agent IDs (e.g. `c2c:10D4F7C2…`) and other scoped conversation IDs do not reach bundled-plugin `dirName` validation and crash the recall run. The same guard already applied to explicit `channelId` params (#76704); this extends it to store-derived channels. (#77396) Thanks @hclsys. - Secrets/external channel contracts: also look in `/dist/` when resolving the `secret-contract-api` sidecar, so npm-published externalized channel plugins (e.g. `@openclaw/discord` since 2026.5.2) whose compiled artifacts live under `dist/` actually contribute their channel SecretRef contracts to the runtime snapshot. Without this, env-backed `channels.discord.token` SecretRefs silently failed to resolve at gateway start on 2026.5.3, leaving the channel `not configured` even though #76449 had landed the generic external-contract loader. Thanks @mogglemoss. - Models/auth: add `openclaw models auth list [--provider ] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments” path. Thanks @vincentkoc. diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index 7f5fe44bb5e..aef42a1ec98 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -8,8 +8,8 @@ }, "type": "module", "dependencies": { - "@agentclientprotocol/claude-agent-acp": "0.31.4", - "@zed-industries/codex-acp": "0.12.0", + "@agentclientprotocol/claude-agent-acp": "0.32.0", + "@zed-industries/codex-acp": "0.13.0", "acpx": "0.6.1" }, "devDependencies": { diff --git a/extensions/acpx/skills/acp-router/SKILL.md b/extensions/acpx/skills/acp-router/SKILL.md index 8ecfcb0d57e..9f8b7b5e304 100644 --- a/extensions/acpx/skills/acp-router/SKILL.md +++ b/extensions/acpx/skills/acp-router/SKILL.md @@ -211,8 +211,8 @@ ${ACPX_CMD} codex sessions close oc-codex- Defaults are: - `openclaw -> openclaw acp` -- `claude -> npx -y @agentclientprotocol/claude-agent-acp@^0.31.0` -- `codex -> bundled @zed-industries/codex-acp@0.12.0 through OpenClaw's isolated CODEX_HOME wrapper` +- `claude -> bundled @agentclientprotocol/claude-agent-acp@0.32.0` +- `codex -> bundled @zed-industries/codex-acp@0.13.0 through OpenClaw's isolated CODEX_HOME wrapper` - `copilot -> copilot --acp --stdio` - `cursor -> cursor-agent acp` - `droid -> droid exec --output-format acp` diff --git a/extensions/acpx/src/codex-auth-bridge.test.ts b/extensions/acpx/src/codex-auth-bridge.test.ts index 051c493647b..3dfee4a1401 100644 --- a/extensions/acpx/src/codex-auth-bridge.test.ts +++ b/extensions/acpx/src/codex-auth-bridge.test.ts @@ -163,7 +163,7 @@ describe("prepareAcpxCodexAuthConfig", () => { }); const wrapper = await fs.readFile(generated.wrapperPath, "utf8"); - expect(wrapper).toContain('"@zed-industries/codex-acp@^0.12.0"'); + expect(wrapper).toContain('"@zed-industries/codex-acp@0.13.0"'); expect(wrapper).toContain('"--", "codex-acp"'); expect(wrapper).not.toContain("@zed-industries/codex-acp@^0.11.1"); }); @@ -184,7 +184,7 @@ describe("prepareAcpxCodexAuthConfig", () => { }); const wrapper = await fs.readFile(generated.wrapperPath, "utf8"); - expect(wrapper).toContain('"@agentclientprotocol/claude-agent-acp@0.31.4"'); + expect(wrapper).toContain('"@agentclientprotocol/claude-agent-acp@0.32.0"'); expect(wrapper).toContain('"--", "claude-agent-acp"'); expect(wrapper).not.toContain("@agentclientprotocol/claude-agent-acp@^0.31.0"); expect(wrapper).not.toContain("@agentclientprotocol/claude-agent-acp@0.31.0"); diff --git a/extensions/acpx/src/codex-auth-bridge.ts b/extensions/acpx/src/codex-auth-bridge.ts index 05f5f87699d..30668a16af4 100644 --- a/extensions/acpx/src/codex-auth-bridge.ts +++ b/extensions/acpx/src/codex-auth-bridge.ts @@ -4,10 +4,8 @@ import path from "node:path"; import type { ResolvedAcpxPluginConfig } from "./config.js"; const CODEX_ACP_PACKAGE = "@zed-industries/codex-acp"; -const CODEX_ACP_PACKAGE_RANGE = "^0.12.0"; const CODEX_ACP_BIN = "codex-acp"; const CLAUDE_ACP_PACKAGE = "@agentclientprotocol/claude-agent-acp"; -const CLAUDE_ACP_PACKAGE_VERSION = "0.31.4"; const CLAUDE_ACP_BIN = "claude-agent-acp"; const RUN_CONFIGURED_COMMAND_SENTINEL = "--openclaw-run-configured"; const requireFromHere = createRequire(import.meta.url); @@ -15,8 +13,22 @@ const requireFromHere = createRequire(import.meta.url); type PackageManifest = { name?: unknown; bin?: unknown; + dependencies?: Record; }; +const selfManifest = requireFromHere("../package.json") as PackageManifest; + +function readManifestDependencyVersion(packageName: string): string { + const version = selfManifest.dependencies?.[packageName]; + if (typeof version !== "string" || version.trim() === "") { + throw new Error(`Missing ${packageName} dependency version in @openclaw/acpx manifest`); + } + return version; +} + +const CODEX_ACP_PACKAGE_VERSION = readManifestDependencyVersion(CODEX_ACP_PACKAGE); +const CLAUDE_ACP_PACKAGE_VERSION = readManifestDependencyVersion(CLAUDE_ACP_PACKAGE); + function quoteCommandPart(value: string): string { return JSON.stringify(value); } @@ -205,7 +217,7 @@ child.on("exit", (code, signal) => { function buildCodexAcpWrapperScript(installedBinPath?: string): string { return buildAdapterWrapperScript({ displayName: "Codex", - packageSpec: `${CODEX_ACP_PACKAGE}@${CODEX_ACP_PACKAGE_RANGE}`, + packageSpec: `${CODEX_ACP_PACKAGE}@${CODEX_ACP_PACKAGE_VERSION}`, binName: CODEX_ACP_BIN, installedBinPath, envSetup: `const codexHome = fileURLToPath(new URL("./codex-home/", import.meta.url)); diff --git a/extensions/acpx/src/manifest.test.ts b/extensions/acpx/src/manifest.test.ts index 8e9e3ee30d9..f43df0315b0 100644 --- a/extensions/acpx/src/manifest.test.ts +++ b/extensions/acpx/src/manifest.test.ts @@ -13,8 +13,8 @@ describe("acpx package manifest", () => { ) as AcpxPackageManifest; expect(packageJson.dependencies?.acpx).toBeDefined(); - expect(packageJson.dependencies?.["@zed-industries/codex-acp"]).toBe("0.12.0"); - expect(packageJson.dependencies?.["@agentclientprotocol/claude-agent-acp"]).toBe("0.31.4"); + expect(packageJson.dependencies?.["@zed-industries/codex-acp"]).toBe("0.13.0"); + expect(packageJson.dependencies?.["@agentclientprotocol/claude-agent-acp"]).toBe("0.32.0"); expect(packageJson.devDependencies?.["@agentclientprotocol/claude-agent-acp"]).toBeUndefined(); }); }); diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 3d74c9216b3..ca7e1ccda40 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -9,7 +9,7 @@ type TestSessionStore = { const DOCUMENTED_OPENCLAW_BRIDGE_COMMAND = "env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token --session agent:main:main"; -const CODEX_ACP_COMMAND = "npx @zed-industries/codex-acp@^0.12.0"; +const CODEX_ACP_COMMAND = "npx @zed-industries/codex-acp@0.13.0"; const CODEX_ACP_WRAPPER_COMMAND = `node "/tmp/openclaw/acpx/codex-acp-wrapper.mjs"`; function makeRuntime( @@ -226,7 +226,7 @@ describe("AcpxRuntime fresh reset wrapper", () => { reasoningEffort: "medium", }), ).toBe( - "npx @zed-industries/codex-acp@^0.12.0 -c model=gpt-5.4 -c model_reasoning_effort=medium", + "npx @zed-industries/codex-acp@0.13.0 -c model=gpt-5.4 -c model_reasoning_effort=medium", ); expect(__testing.isCodexAcpCommand("openclaw acp")).toBe(false); }); diff --git a/extensions/amazon-bedrock-mantle/package.json b/extensions/amazon-bedrock-mantle/package.json index 052738fef02..5a21065f8fa 100644 --- a/extensions/amazon-bedrock-mantle/package.json +++ b/extensions/amazon-bedrock-mantle/package.json @@ -5,9 +5,9 @@ "description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin", "type": "module", "dependencies": { - "@anthropic-ai/sdk": "0.92.0", + "@anthropic-ai/sdk": "0.93.0", "@aws/bedrock-token-generator": "^1.1.0", - "@mariozechner/pi-ai": "0.71.1" + "@mariozechner/pi-ai": "0.73.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/amazon-bedrock/package.json b/extensions/amazon-bedrock/package.json index 2cc1469ceea..ab2b0ea2bb2 100644 --- a/extensions/amazon-bedrock/package.json +++ b/extensions/amazon-bedrock/package.json @@ -5,8 +5,8 @@ "description": "OpenClaw Amazon Bedrock provider plugin", "type": "module", "dependencies": { - "@aws-sdk/client-bedrock": "3.1041.0", - "@aws-sdk/client-bedrock-runtime": "3.1041.0", + "@aws-sdk/client-bedrock": "3.1042.0", + "@aws-sdk/client-bedrock-runtime": "3.1042.0", "@aws-sdk/credential-provider-node": "3.972.39" }, "devDependencies": { diff --git a/extensions/anthropic-vertex/package.json b/extensions/anthropic-vertex/package.json index 63dd29426b4..1e182a6cbf1 100644 --- a/extensions/anthropic-vertex/package.json +++ b/extensions/anthropic-vertex/package.json @@ -6,8 +6,8 @@ "type": "module", "dependencies": { "@anthropic-ai/vertex-sdk": "^0.16.0", - "@mariozechner/pi-agent-core": "0.71.1", - "@mariozechner/pi-ai": "0.71.1" + "@mariozechner/pi-agent-core": "0.73.0", + "@mariozechner/pi-ai": "0.73.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/anthropic/package.json b/extensions/anthropic/package.json index c311cc15527..9f9b47d6b42 100644 --- a/extensions/anthropic/package.json +++ b/extensions/anthropic/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Anthropic provider plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.71.1" + "@mariozechner/pi-ai": "0.73.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/bonjour/package.json b/extensions/bonjour/package.json index c0eab7e0f0b..8dc12d8fb00 100644 --- a/extensions/bonjour/package.json +++ b/extensions/bonjour/package.json @@ -4,7 +4,7 @@ "description": "OpenClaw Bonjour/mDNS gateway discovery", "type": "module", "dependencies": { - "@homebridge/ciao": "^1.3.7" + "@homebridge/ciao": "^1.3.8" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/browser/package.json b/extensions/browser/package.json index e295cee0c13..a46d5450012 100644 --- a/extensions/browser/package.json +++ b/extensions/browser/package.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", - "undici": "8.1.0" + "undici": "8.2.0" }, "openclaw": { "extensions": [ diff --git a/extensions/codex/package.json b/extensions/codex/package.json index e17fe4f3d7b..7653fc282fd 100644 --- a/extensions/codex/package.json +++ b/extensions/codex/package.json @@ -8,11 +8,11 @@ }, "type": "module", "dependencies": { - "@mariozechner/pi-coding-agent": "0.71.1", + "@mariozechner/pi-coding-agent": "0.73.0", "@openai/codex": "0.128.0", "ajv": "^8.20.0", "ws": "^8.20.0", - "zod": "^4.4.1" + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 8add267074c..8df832be851 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -13,7 +13,7 @@ "https-proxy-agent": "^9.0.0", "opusscript": "^0.1.1", "typebox": "1.1.37", - "undici": "8.1.0", + "undici": "8.2.0", "ws": "^8.20.0" }, "devDependencies": { diff --git a/extensions/fireworks/package.json b/extensions/fireworks/package.json index 233e2738ff0..d31dc7ed2ea 100644 --- a/extensions/fireworks/package.json +++ b/extensions/fireworks/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Fireworks provider plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.71.1" + "@mariozechner/pi-ai": "0.73.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/github-copilot/package.json b/extensions/github-copilot/package.json index f5abdf96f46..08193f611a1 100644 --- a/extensions/github-copilot/package.json +++ b/extensions/github-copilot/package.json @@ -8,7 +8,7 @@ "@clack/prompts": "^1.3.0" }, "devDependencies": { - "@mariozechner/pi-ai": "0.71.1", + "@mariozechner/pi-ai": "0.73.0", "@openclaw/plugin-sdk": "workspace:*" }, "openclaw": { diff --git a/extensions/google/package.json b/extensions/google/package.json index 2b8da51f2dc..39f90c9207f 100644 --- a/extensions/google/package.json +++ b/extensions/google/package.json @@ -6,7 +6,7 @@ "type": "module", "dependencies": { "@google/genai": "^1.51.0", - "@mariozechner/pi-ai": "0.71.1" + "@mariozechner/pi-ai": "0.73.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 05c3bef7153..ea2fac4063a 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -10,7 +10,7 @@ "dependencies": { "gaxios": "7.1.4", "google-auth-library": "10.6.2", - "zod": "^4.4.1" + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/kimi-coding/package.json b/extensions/kimi-coding/package.json index 79ee8572373..d9d114107ec 100644 --- a/extensions/kimi-coding/package.json +++ b/extensions/kimi-coding/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Kimi provider plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.71.1" + "@mariozechner/pi-ai": "0.73.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/lmstudio/package.json b/extensions/lmstudio/package.json index dae65f824a8..1570a48a2ee 100644 --- a/extensions/lmstudio/package.json +++ b/extensions/lmstudio/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw LM Studio provider plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.71.1" + "@mariozechner/pi-ai": "0.73.0" }, "openclaw": { "extensions": [ diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index 18776511d3c..91d16cdce98 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -10,7 +10,7 @@ "dependencies": { "@lancedb/lancedb": "^0.27.2", "apache-arrow": "18.1.0", - "openai": "^6.35.0", + "openai": "^6.36.0", "typebox": "1.1.37" }, "devDependencies": { diff --git a/extensions/memory-wiki/package.json b/extensions/memory-wiki/package.json index aaf48c0ec07..adf0eafeb72 100644 --- a/extensions/memory-wiki/package.json +++ b/extensions/memory-wiki/package.json @@ -6,7 +6,7 @@ "type": "module", "dependencies": { "typebox": "1.1.37", - "yaml": "^2.8.3" + "yaml": "^2.8.4" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/migrate-hermes/package.json b/extensions/migrate-hermes/package.json index 8545f784842..30e5e6b5f3a 100644 --- a/extensions/migrate-hermes/package.json +++ b/extensions/migrate-hermes/package.json @@ -5,7 +5,7 @@ "description": "Hermes to OpenClaw migration provider", "type": "module", "dependencies": { - "yaml": "^2.8.3" + "yaml": "^2.8.4" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index de1cded5b29..f7982b43033 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -8,7 +8,7 @@ }, "type": "module", "dependencies": { - "zod": "^4.4.1" + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 4af0a36a2c5..b21602a182d 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -9,7 +9,7 @@ "type": "module", "dependencies": { "nostr-tools": "^2.23.3", - "zod": "^4.4.1" + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/ollama/package.json b/extensions/ollama/package.json index e316c09a8d6..029c19ce174 100644 --- a/extensions/ollama/package.json +++ b/extensions/ollama/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Ollama provider plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.71.1", + "@mariozechner/pi-ai": "0.73.0", "typebox": "1.1.37" }, "devDependencies": { diff --git a/extensions/openai/openclaw.plugin.test.ts b/extensions/openai/openclaw.plugin.test.ts index 4580250244d..936d6d4c8fb 100644 --- a/extensions/openai/openclaw.plugin.test.ts +++ b/extensions/openai/openclaw.plugin.test.ts @@ -60,7 +60,7 @@ function providerWizardByKey() { describe("OpenAI plugin manifest", () => { it("keeps runtime dependencies in the package manifest", () => { - expect(packageJson.dependencies?.["@mariozechner/pi-ai"]).toBe("0.71.1"); + expect(packageJson.dependencies?.["@mariozechner/pi-ai"]).toBe("0.73.0"); expect(packageJson.dependencies?.ws).toBe("^8.20.0"); }); diff --git a/extensions/openai/package.json b/extensions/openai/package.json index d01eddd8ad0..efc27b9df50 100644 --- a/extensions/openai/package.json +++ b/extensions/openai/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw OpenAI provider plugins", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.71.1", + "@mariozechner/pi-ai": "0.73.0", "ws": "^8.20.0" }, "devDependencies": { diff --git a/extensions/qa-lab/package.json b/extensions/qa-lab/package.json index eb3d35f4ea4..0e9a1133e36 100644 --- a/extensions/qa-lab/package.json +++ b/extensions/qa-lab/package.json @@ -5,11 +5,11 @@ "description": "OpenClaw QA lab plugin with private debugger UI and scenario runner", "type": "module", "dependencies": { - "@copilotkit/aimock": "1.16.4", + "@copilotkit/aimock": "1.17.0", "@modelcontextprotocol/sdk": "1.29.0", "playwright-core": "1.59.1", - "yaml": "^2.8.3", - "zod": "^4.4.1" + "yaml": "^2.8.4", + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/discord": "workspace:*", diff --git a/extensions/qa-matrix/package.json b/extensions/qa-matrix/package.json index 2626a063e08..ffe54943230 100644 --- a/extensions/qa-matrix/package.json +++ b/extensions/qa-matrix/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Matrix QA runner plugin", "type": "module", "dependencies": { - "undici": "8.1.0" + "undici": "8.2.0" }, "devDependencies": { "@openclaw/matrix": "workspace:*", diff --git a/extensions/qqbot/package.json b/extensions/qqbot/package.json index f5e9f770eaa..fb645b248f2 100644 --- a/extensions/qqbot/package.json +++ b/extensions/qqbot/package.json @@ -13,7 +13,7 @@ "mpg123-decoder": "^1.0.3", "silk-wasm": "^3.7.1", "ws": "^8.20.0", - "zod": "^4.4.1" + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/slack/package.json b/extensions/slack/package.json index c0554804967..926e5cd142f 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -6,8 +6,8 @@ "type": "module", "dependencies": { "@slack/bolt": "^4.7.2", - "@slack/types": "^2.20.1", - "@slack/web-api": "^7.15.1", + "@slack/types": "^2.21.0", + "@slack/web-api": "^7.15.2", "https-proxy-agent": "^9.0.0" }, "devDependencies": { diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index d6e3dab5db2..82894b63927 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -8,7 +8,7 @@ }, "type": "module", "dependencies": { - "zod": "^4.4.1" + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index b5c6b5e1e71..b70a5dd6fa7 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -9,7 +9,7 @@ "@grammyjs/transformer-throttler": "^1.2.1", "grammy": "^1.42.0", "typebox": "1.1.37", - "undici": "8.1.0" + "undici": "8.2.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index c5277de87f6..6ce6feec34b 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -8,8 +8,8 @@ }, "type": "module", "dependencies": { - "@aws-sdk/client-s3": "3.1041.0", - "@aws-sdk/s3-request-presigner": "3.1041.0", + "@aws-sdk/client-s3": "3.1042.0", + "@aws-sdk/s3-request-presigner": "3.1042.0", "@tloncorp/tlon-skill": "0.3.5", "@urbit/aura": "^3.0.0" }, diff --git a/extensions/webhooks/package.json b/extensions/webhooks/package.json index c6e23fbdbff..f22a6ba6ffd 100644 --- a/extensions/webhooks/package.json +++ b/extensions/webhooks/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw webhook bridge plugin", "type": "module", "dependencies": { - "zod": "^4.4.1" + "zod": "^4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 9ced33efe05..1e0eec5a716 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -12,7 +12,7 @@ "https-proxy-agent": "^9.0.0", "jimp": "^1.6.1", "typebox": "1.1.37", - "undici": "8.1.0" + "undici": "8.2.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/xai/package.json b/extensions/xai/package.json index 9010e145bd4..91870caa2cc 100644 --- a/extensions/xai/package.json +++ b/extensions/xai/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw xAI plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.71.1", + "@mariozechner/pi-ai": "0.73.0", "typebox": "1.1.37" }, "devDependencies": { diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 81f12da0dd3..b28ad2d1ca1 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -8,7 +8,7 @@ }, "type": "module", "dependencies": { - "undici": "8.1.0" + "undici": "8.2.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/package.json b/package.json index f6956d29d9b..c82af822f5c 100644 --- a/package.json +++ b/package.json @@ -1663,27 +1663,27 @@ }, "dependencies": { "@agentclientprotocol/sdk": "0.21.0", - "@anthropic-ai/sdk": "0.92.0", + "@anthropic-ai/sdk": "0.93.0", "@anthropic-ai/vertex-sdk": "^0.16.0", - "@aws-sdk/client-bedrock": "3.1041.0", - "@aws-sdk/client-bedrock-runtime": "3.1041.0", + "@aws-sdk/client-bedrock": "3.1042.0", + "@aws-sdk/client-bedrock-runtime": "3.1042.0", "@aws-sdk/credential-provider-node": "3.972.39", "@aws/bedrock-token-generator": "^1.1.0", "@clack/prompts": "^1.3.0", "@google/genai": "^1.51.0", "@grammyjs/runner": "^2.0.3", "@grammyjs/transformer-throttler": "^1.2.1", - "@homebridge/ciao": "^1.3.7", + "@homebridge/ciao": "^1.3.8", "@lydell/node-pty": "1.2.0-beta.12", - "@mariozechner/pi-agent-core": "0.71.1", - "@mariozechner/pi-ai": "0.71.1", - "@mariozechner/pi-coding-agent": "0.71.1", - "@mariozechner/pi-tui": "0.71.1", + "@mariozechner/pi-agent-core": "0.73.0", + "@mariozechner/pi-ai": "0.73.0", + "@mariozechner/pi-coding-agent": "0.73.0", + "@mariozechner/pi-tui": "0.73.0", "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "^0.6.0", "@slack/bolt": "^4.7.2", - "@slack/types": "^2.20.1", - "@slack/web-api": "^7.15.1", + "@slack/types": "^2.21.0", + "@slack/web-api": "^7.15.2", "ajv": "^8.20.0", "chalk": "^5.6.2", "chokidar": "^5.0.0", @@ -1695,7 +1695,7 @@ "global-agent": "^4.1.3", "grammy": "^1.42.0", "https-proxy-agent": "^9.0.0", - "ipaddr.js": "^2.3.0", + "ipaddr.js": "^2.4.0", "jiti": "^2.6.1", "json5": "^2.2.3", "jszip": "^3.10.1", @@ -1703,7 +1703,7 @@ "markdown-it": "14.1.1", "minimatch": "10.2.5", "node-edge-tts": "^1.2.10", - "openai": "^6.35.0", + "openai": "^6.36.0", "openshell": "0.1.0", "pdfjs-dist": "^5.7.284", "playwright-core": "1.59.1", @@ -1714,15 +1714,15 @@ "tree-sitter-bash": "^0.25.1", "tslog": "^4.10.2", "typebox": "1.1.37", - "undici": "8.1.0", + "undici": "8.2.0", "web-push": "^3.6.7", "web-tree-sitter": "^0.26.8", "ws": "^8.20.0", - "yaml": "^2.8.3", - "zod": "^4.4.1" + "yaml": "^2.8.4", + "zod": "^4.4.3" }, "devDependencies": { - "@copilotkit/aimock": "1.16.4", + "@copilotkit/aimock": "1.17.0", "@grammyjs/types": "^3.26.0", "@lit-labs/signals": "^0.2.0", "@lit/context": "^1.1.6", @@ -1731,7 +1731,7 @@ "@types/markdown-it": "^14.1.2", "@types/node": "25.6.0", "@types/ws": "^8.18.1", - "@typescript/native-preview": "7.0.0-dev.20260501.1", + "@typescript/native-preview": "7.0.0-dev.20260504.1", "@vitest/coverage-v8": "^4.1.5", "jscpd": "4.0.9", "jsdom": "^29.1.1", @@ -1761,7 +1761,7 @@ "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8", "pnpm": { "overrides": { - "@anthropic-ai/sdk": "0.92.0", + "@anthropic-ai/sdk": "0.93.0", "hono": "4.12.14", "@hono/node-server": "1.19.14", "@aws-sdk/client-bedrock-runtime": "3.1024.0", @@ -1818,7 +1818,7 @@ }, "patchedDependencies": { "@whiskeysockets/baileys@7.0.0-rc.9": "patches/@whiskeysockets__baileys@7.0.0-rc.9.patch", - "@agentclientprotocol/claude-agent-acp@0.31.4": "patches/@agentclientprotocol__claude-agent-acp@0.31.4.patch" + "@agentclientprotocol/claude-agent-acp@0.32.0": "patches/@agentclientprotocol__claude-agent-acp@0.32.0.patch" } } } diff --git a/patches/@agentclientprotocol__claude-agent-acp@0.31.4.patch b/patches/@agentclientprotocol__claude-agent-acp@0.32.0.patch similarity index 82% rename from patches/@agentclientprotocol__claude-agent-acp@0.31.4.patch rename to patches/@agentclientprotocol__claude-agent-acp@0.32.0.patch index a765f36823f..3e228a51a5c 100644 --- a/patches/@agentclientprotocol__claude-agent-acp@0.31.4.patch +++ b/patches/@agentclientprotocol__claude-agent-acp@0.32.0.patch @@ -1,8 +1,8 @@ diff --git a/dist/acp-agent.js b/dist/acp-agent.js -index 0a8f5e3c57ed05189cba546bd65fc18143744d09..a8522d86a5a2f1bbcdd446d222cb9b7b5acb79ca 100644 +index e1d9aa9f0815f57ea2fd299a7f2b8ef0917ca191..875fdfb25fbfa905ca80728355d25a17e6d89148 100644 --- a/dist/acp-agent.js +++ b/dist/acp-agent.js -@@ -421,6 +421,7 @@ export class ClaudeAcpAgent { +@@ -436,6 +436,7 @@ export class ClaudeAcpAgent { session.promptRunning = true; let handedOff = false; let stopReason = "end_turn"; @@ -10,7 +10,7 @@ index 0a8f5e3c57ed05189cba546bd65fc18143744d09..a8522d86a5a2f1bbcdd446d222cb9b7b try { while (true) { const { value: message, done } = await session.query.next(); -@@ -428,6 +429,9 @@ export class ClaudeAcpAgent { +@@ -443,6 +444,9 @@ export class ClaudeAcpAgent { if (session.cancelled) { return { stopReason: "cancelled" }; } @@ -20,7 +20,7 @@ index 0a8f5e3c57ed05189cba546bd65fc18143744d09..a8522d86a5a2f1bbcdd446d222cb9b7b break; } if (session.emitRawSDKMessages && -@@ -496,7 +500,7 @@ export class ClaudeAcpAgent { +@@ -499,7 +503,7 @@ export class ClaudeAcpAgent { break; } case "session_state_changed": { @@ -29,7 +29,7 @@ index 0a8f5e3c57ed05189cba546bd65fc18143744d09..a8522d86a5a2f1bbcdd446d222cb9b7b return { stopReason, usage: sessionUsage(session) }; } break; -@@ -601,6 +605,7 @@ export class ClaudeAcpAgent { +@@ -621,6 +625,7 @@ export class ClaudeAcpAgent { unreachable(message, this.logger); break; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b40ba19b7e..dc35dae2bd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - '@anthropic-ai/sdk': 0.92.0 + '@anthropic-ai/sdk': 0.93.0 hono: 4.12.14 '@hono/node-server': 1.19.14 '@aws-sdk/client-bedrock-runtime': 3.1024.0 @@ -32,9 +32,9 @@ overrides: packageExtensionsChecksum: sha256-n+P/SQo4Pf+dHYpYn1Y6wL4cJEVoVzZ835N0OEp4TM8= patchedDependencies: - '@agentclientprotocol/claude-agent-acp@0.31.4': - hash: e8b472d71289ac8de9813c57d79abac524889ca96f279f6f3ad08043434f6615 - path: patches/@agentclientprotocol__claude-agent-acp@0.31.4.patch + '@agentclientprotocol/claude-agent-acp@0.32.0': + hash: 1fe782f9679d7a725cbe59e51d61419fbb25d4c463d186c43c95644770cb2b98 + path: patches/@agentclientprotocol__claude-agent-acp@0.32.0.patch '@whiskeysockets/baileys@7.0.0-rc.9': hash: 23ec8efe1484afa57c51b96955ba331d1467521a8e676a18c2690da7e70a6201 path: patches/@whiskeysockets__baileys@7.0.0-rc.9.patch @@ -45,16 +45,16 @@ importers: dependencies: '@agentclientprotocol/sdk': specifier: 0.21.0 - version: 0.21.0(zod@4.4.1) + version: 0.21.0(zod@4.4.3) '@anthropic-ai/sdk': - specifier: 0.92.0 - version: 0.92.0(zod@4.4.1) + specifier: 0.93.0 + version: 0.93.0(zod@4.4.3) '@anthropic-ai/vertex-sdk': specifier: ^0.16.0 - version: 0.16.0(zod@4.4.1) + version: 0.16.0(zod@4.4.3) '@aws-sdk/client-bedrock': - specifier: 3.1041.0 - version: 3.1041.0 + specifier: 3.1042.0 + version: 3.1042.0 '@aws-sdk/client-bedrock-runtime': specifier: 3.1024.0 version: 3.1024.0 @@ -69,7 +69,7 @@ importers: version: 1.3.0 '@google/genai': specifier: ^1.51.0 - version: 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1)) + version: 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@grammyjs/runner': specifier: ^2.0.3 version: 2.0.3(grammy@1.42.0) @@ -77,26 +77,26 @@ importers: specifier: ^1.2.1 version: 1.2.1(grammy@1.42.0) '@homebridge/ciao': - specifier: ^1.3.7 - version: 1.3.7 + specifier: ^1.3.8 + version: 1.3.8 '@lydell/node-pty': specifier: 1.2.0-beta.12 version: 1.2.0-beta.12 '@mariozechner/pi-agent-core': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@mariozechner/pi-coding-agent': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@mariozechner/pi-tui': - specifier: 0.71.1 - version: 0.71.1 + specifier: 0.73.0 + version: 0.73.0 '@modelcontextprotocol/sdk': specifier: 1.29.0 - version: 1.29.0(zod@4.4.1) + version: 1.29.0(zod@4.4.3) '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -104,11 +104,11 @@ importers: specifier: ^4.7.2 version: 4.7.2(@types/express@5.0.6) '@slack/types': - specifier: ^2.20.1 - version: 2.20.1 + specifier: ^2.21.0 + version: 2.21.0 '@slack/web-api': - specifier: ^7.15.1 - version: 7.15.1 + specifier: ^7.15.2 + version: 7.15.2 ajv: specifier: ^8.20.0 version: 8.20.0 @@ -143,8 +143,8 @@ importers: specifier: ^9.0.0 version: 9.0.0 ipaddr.js: - specifier: ^2.3.0 - version: 2.3.0 + specifier: ^2.4.0 + version: 2.4.0 jiti: specifier: ^2.6.1 version: 2.6.1 @@ -167,8 +167,8 @@ importers: specifier: ^1.2.10 version: 1.2.10 openai: - specifier: ^6.35.0 - version: 6.35.0(ws@8.20.0)(zod@4.4.1) + specifier: ^6.36.0 + version: 6.36.0(ws@8.20.0)(zod@4.4.3) openshell: specifier: 0.1.0 version: 0.1.0 @@ -200,8 +200,8 @@ importers: specifier: 1.1.37 version: 1.1.37 undici: - specifier: 8.1.0 - version: 8.1.0 + specifier: 8.2.0 + version: 8.2.0 web-push: specifier: ^3.6.7 version: 3.6.7 @@ -212,19 +212,15 @@ importers: specifier: ^8.20.0 version: 8.20.0 yaml: - specifier: ^2.8.3 - version: 2.8.3 + specifier: ^2.8.4 + version: 2.8.4 zod: - specifier: ^4.4.1 - version: 4.4.1 - optionalDependencies: - sqlite-vec: - specifier: 0.1.9 - version: 0.1.9 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@copilotkit/aimock': - specifier: 1.16.4 - version: 1.16.4(vitest@4.1.5) + specifier: 1.17.0 + version: 1.17.0(vitest@4.1.5) '@grammyjs/types': specifier: ^3.26.0 version: 3.26.0 @@ -250,8 +246,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260501.1 - version: 7.0.0-dev.20260501.1 + specifier: 7.0.0-dev.20260504.1 + version: 7.0.0-dev.20260504.1 '@vitest/coverage-v8': specifier: ^4.1.5 version: 4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5) @@ -278,7 +274,7 @@ importers: version: 0.21.1(signal-polyfill@0.2.2) tsdown: specifier: 0.21.10 - version: 0.21.10(@typescript/native-preview@7.0.0-dev.20260501.1)(typescript@6.0.3) + version: 0.21.10(@typescript/native-preview@7.0.0-dev.20260504.1)(typescript@6.0.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -287,16 +283,20 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + optionalDependencies: + sqlite-vec: + specifier: 0.1.9 + version: 0.1.9 extensions/acpx: dependencies: '@agentclientprotocol/claude-agent-acp': - specifier: 0.31.4 - version: 0.31.4(patch_hash=e8b472d71289ac8de9813c57d79abac524889ca96f279f6f3ad08043434f6615) + specifier: 0.32.0 + version: 0.32.0(patch_hash=1fe782f9679d7a725cbe59e51d61419fbb25d4c463d186c43c95644770cb2b98) '@zed-industries/codex-acp': - specifier: 0.12.0 - version: 0.12.0 + specifier: 0.13.0 + version: 0.13.0 acpx: specifier: 0.6.1 version: 0.6.1 @@ -314,8 +314,8 @@ importers: extensions/amazon-bedrock: dependencies: '@aws-sdk/client-bedrock': - specifier: 3.1041.0 - version: 3.1041.0 + specifier: 3.1042.0 + version: 3.1042.0 '@aws-sdk/client-bedrock-runtime': specifier: 3.1024.0 version: 3.1024.0 @@ -330,14 +330,14 @@ importers: extensions/amazon-bedrock-mantle: dependencies: '@anthropic-ai/sdk': - specifier: 0.92.0 - version: 0.92.0(zod@4.4.1) + specifier: 0.93.0 + version: 0.93.0(zod@4.4.3) '@aws/bedrock-token-generator': specifier: ^1.1.0 version: 1.1.0 '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -346,8 +346,8 @@ importers: extensions/anthropic: dependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -357,13 +357,13 @@ importers: dependencies: '@anthropic-ai/vertex-sdk': specifier: ^0.16.0 - version: 0.16.0(zod@4.4.1) + version: 0.16.0(zod@4.4.3) '@mariozechner/pi-agent-core': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -393,8 +393,8 @@ importers: extensions/bonjour: dependencies: '@homebridge/ciao': - specifier: ^1.3.7 - version: 1.3.7 + specifier: ^1.3.8 + version: 1.3.8 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -410,7 +410,7 @@ importers: dependencies: '@modelcontextprotocol/sdk': specifier: 1.29.0 - version: 1.29.0(zod@4.4.1) + version: 1.29.0(zod@4.4.3) commander: specifier: ^14.0.3 version: 14.0.3 @@ -431,8 +431,8 @@ importers: specifier: workspace:* version: link:../../packages/plugin-sdk undici: - specifier: 8.1.0 - version: 8.1.0 + specifier: 8.2.0 + version: 8.2.0 extensions/byteplus: devDependencies: @@ -461,8 +461,8 @@ importers: extensions/codex: dependencies: '@mariozechner/pi-coding-agent': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@openai/codex': specifier: 0.128.0 version: 0.128.0 @@ -473,8 +473,8 @@ importers: specifier: ^8.20.0 version: 8.20.0 zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -593,8 +593,8 @@ importers: specifier: 1.1.37 version: 1.1.37 undici: - specifier: 8.1.0 - version: 8.1.0 + specifier: 8.2.0 + version: 8.2.0 ws: specifier: ^8.20.0 version: 8.20.0 @@ -685,8 +685,8 @@ importers: extensions/fireworks: dependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -699,8 +699,8 @@ importers: version: 1.3.0 devDependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@openclaw/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk @@ -709,10 +709,10 @@ importers: dependencies: '@google/genai': specifier: ^1.51.0 - version: 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1)) + version: 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -743,8 +743,8 @@ importers: specifier: 10.6.2 version: 10.6.2 zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -804,8 +804,8 @@ importers: extensions/kimi-coding: dependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -846,8 +846,8 @@ importers: extensions/lmstudio: dependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) extensions/lobster: dependencies: @@ -944,8 +944,8 @@ importers: specifier: 18.1.0 version: 18.1.0 openai: - specifier: ^6.35.0 - version: 6.35.0(ws@8.20.0)(zod@4.4.1) + specifier: ^6.36.0 + version: 6.36.0(ws@8.20.0)(zod@4.4.3) typebox: specifier: 1.1.37 version: 1.1.37 @@ -960,8 +960,8 @@ importers: specifier: 1.1.37 version: 1.1.37 yaml: - specifier: ^2.8.3 - version: 2.8.3 + specifier: ^2.8.4 + version: 2.8.4 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -998,8 +998,8 @@ importers: extensions/migrate-hermes: dependencies: yaml: - specifier: ^2.8.3 - version: 2.8.3 + specifier: ^2.8.4 + version: 2.8.4 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1063,8 +1063,8 @@ importers: extensions/nextcloud-talk: dependencies: zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1079,8 +1079,8 @@ importers: specifier: ^2.23.3 version: 2.23.3(typescript@6.0.3) zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1098,8 +1098,8 @@ importers: extensions/ollama: dependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) typebox: specifier: 1.1.37 version: 1.1.37 @@ -1117,8 +1117,8 @@ importers: extensions/openai: dependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) ws: specifier: ^8.20.0 version: 8.20.0 @@ -1177,20 +1177,20 @@ importers: extensions/qa-lab: dependencies: '@copilotkit/aimock': - specifier: 1.16.4 - version: 1.16.4(vitest@4.1.5) + specifier: 1.17.0 + version: 1.17.0(vitest@4.1.5) '@modelcontextprotocol/sdk': specifier: 1.29.0 - version: 1.29.0(zod@4.4.1) + version: 1.29.0(zod@4.4.3) playwright-core: specifier: 1.59.1 version: 1.59.1 yaml: - specifier: ^2.8.3 - version: 2.8.3 + specifier: ^2.8.4 + version: 2.8.4 zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/discord': specifier: workspace:* @@ -1208,8 +1208,8 @@ importers: extensions/qa-matrix: dependencies: undici: - specifier: 8.1.0 - version: 8.1.0 + specifier: 8.2.0 + version: 8.2.0 devDependencies: '@openclaw/matrix': specifier: workspace:* @@ -1242,8 +1242,8 @@ importers: specifier: ^8.20.0 version: 8.20.0 zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1307,11 +1307,11 @@ importers: specifier: ^4.7.2 version: 4.7.2(@types/express@5.0.6) '@slack/types': - specifier: ^2.20.1 - version: 2.20.1 + specifier: ^2.21.0 + version: 2.21.0 '@slack/web-api': - specifier: ^7.15.1 - version: 7.15.1 + specifier: ^7.15.2 + version: 7.15.2 https-proxy-agent: specifier: ^9.0.0 version: 9.0.0 @@ -1335,8 +1335,8 @@ importers: extensions/synology-chat: dependencies: zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1373,8 +1373,8 @@ importers: specifier: 1.1.37 version: 1.1.37 undici: - specifier: 8.1.0 - version: 8.1.0 + specifier: 8.2.0 + version: 8.2.0 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1389,11 +1389,11 @@ importers: extensions/tlon: dependencies: '@aws-sdk/client-s3': - specifier: 3.1041.0 - version: 3.1041.0 + specifier: 3.1042.0 + version: 3.1042.0 '@aws-sdk/s3-request-presigner': - specifier: 3.1041.0 - version: 3.1041.0 + specifier: 3.1042.0 + version: 3.1042.0 '@tloncorp/tlon-skill': specifier: 0.3.5 version: 0.3.5 @@ -1523,8 +1523,8 @@ importers: extensions/webhooks: dependencies: zod: - specifier: ^4.4.1 - version: 4.4.1 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1545,8 +1545,8 @@ importers: specifier: 1.1.37 version: 1.1.37 undici: - specifier: 8.1.0 - version: 8.1.0 + specifier: 8.2.0 + version: 8.2.0 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1558,8 +1558,8 @@ importers: extensions/xai: dependencies: '@mariozechner/pi-ai': - specifier: 0.71.1 - version: 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + specifier: 0.73.0 + version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) typebox: specifier: 1.1.37 version: 1.1.37 @@ -1586,8 +1586,8 @@ importers: extensions/zalo: dependencies: undici: - specifier: 8.1.0 - version: 8.1.0 + specifier: 8.2.0 + version: 8.2.0 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1652,7 +1652,7 @@ importers: version: 14.1.2 '@vitest/browser-playwright': specifier: 4.1.5 - version: 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5) + version: 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) jsdom: specifier: ^29.1.1 version: 29.1.1(@noble/hashes@2.0.1) @@ -1661,15 +1661,15 @@ importers: version: 1.59.1 vite: specifier: 8.0.10 - version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) vitest: specifier: 4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) packages: - '@agentclientprotocol/claude-agent-acp@0.31.4': - resolution: {integrity: sha512-Ge2qzNN7vXQje0H+xoPhcRToubgdkgpY/YoqNSeJGpx8S90V/uposdsE+OSgIA+4nHcUEbgV9OmCiIqpyEsA9g==} + '@agentclientprotocol/claude-agent-acp@0.32.0': + resolution: {integrity: sha512-3WIaD1bTmIciqHdeU97oeNajOG9H+ctloXnQ+R/T563C2CM8u1K7QsNqqgqR2F+Cn8NVBkXdHRvAMtUHglLzAw==} hasBin: true '@agentclientprotocol/sdk@0.20.0': @@ -1682,58 +1682,58 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.121': - resolution: {integrity: sha512-zVHcXvx6Hl/glDcOCH+EyNx4KPE9cMGLk42eEBSZe014tAN5W8bwM/By08iM6dxijnpH0NQRNNEAW+BryWzuDg==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.126': + resolution: {integrity: sha512-JFlJBbeAlx7Ic5s4lGUN9SppobryXk/lIqPCvhp6KrJTQIerh3MIBzxsVIJ0MaDut7jVni/oYgsvDni7NIyqHA==} cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.121': - resolution: {integrity: sha512-lIXdqKj+bpfDxCk/eU1F1TXNqsIsLTRrkUG/wx19WIGZ8gLUmmVSveUKGlNegTs7S6evMvuezprJzDJT4TcvPA==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.126': + resolution: {integrity: sha512-J8BpMj16NK9FUaG3HnHSivyp4Xww9DKWHiC8QSHT9oiT8pH5IG7nl0jxyjIq/lY79evlTY+ubgDVWlMUhUAN/g==} cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.121': - resolution: {integrity: sha512-4XaGK+dRBYy7krln7BrDG0WsdE6ejUSgHjWHlUGXoubFfZUvls4GSahLcYjJBArLi4dLnxKw8zEuiQguPAIbrw==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.126': + resolution: {integrity: sha512-GO0BnIUw3LQ3XAy+nipAabkN0GwQGPhHB6ITI4XLoR99fLHB3TA6WfyvTf0fnpxd25A+c/+UsAoxz4zBQaHlhA==} cpu: [arm64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.121': - resolution: {integrity: sha512-AQSnJzaiFvQpUPfO1tWLvsHgb6KNar4QYEQ/5/sk1itfgr3Fx9gxTreq43wX7AXSvkBX1QlDaP1aR1sfM/g/lQ==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.126': + resolution: {integrity: sha512-LM+mnfQsgI+1i5mYZwIPDDf14NGBu5wbhzm5U8P11dCa2p8sXmKoWpkbO16BFM2NxeW44I/RXCxE5qFsbz4zcg==} cpu: [arm64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.121': - resolution: {integrity: sha512-sQoGIgzLlBRrwizxsCV/lbaEuxXom/cfOwlDtQ2HnS1IzDDSjSf5d5pugpWItkOyXBWcHzMUu731WTTutvd/BQ==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.126': + resolution: {integrity: sha512-ByJGO0+mu7EplxSFSCIHd7QWsXdrF3qgtzQ177o/j+oSppLoqR1ot5ktf8aw5oR3CC5lFHg4tqd6TnneQpEoIg==} cpu: [x64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.2.121': - resolution: {integrity: sha512-DJUgpm7au086WaQV/S7BGOt2M8D90spGZRizT3twYsacf1BxzK1qsXqB/Pw1lUjPy6pI107pml/TaPzWuS/Vzg==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.2.126': + resolution: {integrity: sha512-yaOTDcYCdscxC0LKg9w8IwSa5g+993WggFZJBTZpqvflA2+WMQeTarDnKlsFTCw9XUZkL8XZeBALYJGx0HutuA==} cpu: [x64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.121': - resolution: {integrity: sha512-6n/NHkHxs0/lCJX3XPADjo1EFzXBf0IwYz/nyzJGBCDJjGKmgTe0i8eYBr/hviwt1/OPeK7dmVzVSVl6EL9Azg==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.126': + resolution: {integrity: sha512-gv3MOsOBkCx3LajOOIjD7AKsOtz/qNHsS2oshGt2GVoy7JA3XbCDeCetDjM6SorV4SE+7F/IH0UJdXe5ejI/Zg==} cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.2.121': - resolution: {integrity: sha512-v2/R918/t94cCwc6rmbxk+UYeQPtF2oBLtQAk+cT0M60hvqmCZO2noyZx5uTp8TQncOlG4MkINIeNY2yfmWSoQ==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.2.126': + resolution: {integrity: sha512-oRV75HwyoOd1/t5+kipAM2g62CaElpKGvSBx3Ys4lCwCiFUyOnmet/O+hRXENsY6ShDeQZEcJL2UWljr2d5NQw==} cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk@0.2.121': - resolution: {integrity: sha512-hwZNYTkGLKVixd/V/OCJwfH/SdfxZXGV0m6wvy5EBq6qfB+lvJTRz/MSOSa7dHqo4/F7zJY68crEEca68Wrxpw==} + '@anthropic-ai/claude-agent-sdk@0.2.126': + resolution: {integrity: sha512-4ZrVu0XUEwNG6wxvsLgppRAmSfAf3oeEMEUPhgazb0AXUUe/7W8MxwZKJWOffqSLWaNYzOt3ZCIL7NJY6toqWw==} engines: {node: '>=18.0.0'} peerDependencies: zod: ^4.0.0 - '@anthropic-ai/sdk@0.92.0': - resolution: {integrity: sha512-l653JFC83wCglH8H83t1xpgDurCyPyslYW1maPRdCsfuNuGbLvQjQ81sWd3Go3LWRm0jNspzAhuqAYV8r9joSw==} + '@anthropic-ai/sdk@0.93.0': + resolution: {integrity: sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -1786,16 +1786,16 @@ packages: resolution: {integrity: sha512-nIhsn0/eYrL2fTh4kMO7Hpfmhv+AkkXl0KGNpD6+fdmotGvRBWcDv9/PmP/+sT6gvrKTYyzH3vu4efpTPzzP0Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock@3.1041.0': - resolution: {integrity: sha512-xUpJ9iRgpj89d9QzjqYUlCnHYNQ/mblICGWhLdpZwvJpege4c36/W40fiYsvs3c3ql58JHQAnGdbNU6cNV1zew==} + '@aws-sdk/client-bedrock@3.1042.0': + resolution: {integrity: sha512-oEVjGU8wgW+eTF7ApdRU4jTs/iMVl4OdfpLmiNLuB082UVxxN/fQ5GIX2Ktbyt+x0mPlI3fug36XnOyf7oCo+Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-cognito-identity@3.1041.0': - resolution: {integrity: sha512-h8DxvCsv95RSHTZPyEwGCqOyiQYVWQ4tFe5im4d0qFvFc9xRmseTu3ZsQ9nd+uOzU9rkCoDHClyqUxXU7nm90Q==} + '@aws-sdk/client-cognito-identity@3.1042.0': + resolution: {integrity: sha512-Cdan/gdzcNEwnvaxzCDQUDJnAAROfvnxjlv5/kvc78E1yQYdgqMN9vEzaqePDNjj8V0rel/iGx2tTlf3KY+V5g==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1041.0': - resolution: {integrity: sha512-sQV14bIqslnBHuSlLMD+fc3pH+ajop6vnrFlJ4wM4JDqcYwVik4O+9srnZUrkesFw5y+CN0GfOQ06CAgtC4mjQ==} + '@aws-sdk/client-s3@3.1042.0': + resolution: {integrity: sha512-z3Ibstr7ckDT10dz/nkk4+93LitrrO49Oq563/JoFHt30ZNodPBCfSxysKcelLyi/lNVF1MZrhZZfikUAG3iNQ==} engines: {node: '>=20.0.0'} '@aws-sdk/core@3.974.8': @@ -1842,8 +1842,8 @@ packages: resolution: {integrity: sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-providers@3.1041.0': - resolution: {integrity: sha512-Ps7dcWV1JbXKoFy8QpWhTpWkX0x2tiZFmDdgojK98/rqyybPdwEtGB8xY/N2uJjE0MZkrV9X7T3Xrnk/rGFoNw==} + '@aws-sdk/credential-providers@3.1042.0': + resolution: {integrity: sha512-TKOJXoBKTtfWkRrMITPBq4CFfiXydKc7bcvTONLhXgs1BuyHPcVp2KL0Rl1//xmlw48lYgnT/jn99SanLhB8EQ==} engines: {node: '>=20.0.0'} '@aws-sdk/eventstream-handler-node@3.972.14': @@ -1906,8 +1906,8 @@ packages: resolution: {integrity: sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==} engines: {node: '>=20.0.0'} - '@aws-sdk/s3-request-presigner@3.1041.0': - resolution: {integrity: sha512-DlKsPQ8Z75wgeDSHbjUPNDQCYUF0OLBkqllZqFei61KIoQDqEeKUCwuCf6RhNLjaP4b8oSpBA9+FmUS+zm3xUg==} + '@aws-sdk/s3-request-presigner@3.1042.0': + resolution: {integrity: sha512-yWgXWDg4W0Vk1xlY4M7puM07ce6PPBS4tBytNOpu57k+wY0puXgxkGN0+k/dUAA4sR4Th6+wDps50gBBLj48Ew==} engines: {node: '>=20.0.0'} '@aws-sdk/signature-v4-multi-region@3.996.25': @@ -1922,6 +1922,10 @@ packages: resolution: {integrity: sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1042.0': + resolution: {integrity: sha512-rOEGTVOrceb/1CfIWK0zl1v2WS70f/i5bDirLl5xdFAbVQ5znub6Ezf2ugmJEg+rionO0IkwbKX3Dh3T/oZjbA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.8': resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} engines: {node: '>=20.0.0'} @@ -2101,8 +2105,8 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} - '@copilotkit/aimock@1.16.4': - resolution: {integrity: sha512-DA9WjJWpi2Yh36ltsnfMycj+BbifSS9G0pyHw0JjQZQPm41+FziGIdl2gusBtwYebStypQ4v9Jj2rjqjJqqtvQ==} + '@copilotkit/aimock@1.17.0': + resolution: {integrity: sha512-yhU5LahwobRC71Y3JNSqSjvuUU0KtFbaXywgIT6boQwt0XuXmsi91yGlI+tMRb7yNutF2cwXV/qolUYwuqXwJQ==} engines: {node: '>=24.0.0'} hasBin: true peerDependencies: @@ -2423,8 +2427,8 @@ packages: '@hapi/hoek@9.3.0': resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} - '@homebridge/ciao@1.3.7': - resolution: {integrity: sha512-ncvcXQe4vrqBLNqnVjQjke5NpNin6SO9bStfBZ4jgZk/xIjD9GMcH8vp8XKd7hw5akIzwITMiDMysIKvE5rHBw==} + '@homebridge/ciao@1.3.8': + resolution: {integrity: sha512-lNhpCsZVbdbjz2trFjQdzQ3cUIMZQMIMksi7wd3ntTIYgdaGLqT1Ms97DfVIJYHzRuduf56ISvgU8RRLTpK/ng==} hasBin: true '@hono/node-server@1.19.14': @@ -2920,22 +2924,22 @@ packages: resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==} hasBin: true - '@mariozechner/pi-agent-core@0.71.1': - resolution: {integrity: sha512-LMXcKoPmjD06EHwnl7IGMkJs/l3Qdl9z1xKsQGqqyd60ZgdxaATtR40Yyzcku1ogu16NhCHrUg6PJ9XeRcT+qQ==} + '@mariozechner/pi-agent-core@0.73.0': + resolution: {integrity: sha512-ugcpvq0X9fr9fTSK29/3S4+KU/eeVMrBb7ZU3HqiF3xD7I1GlgumLj4FYmDrYSEA6+rzgNWlJUKwjKh9o0Z6AA==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-ai@0.71.1': - resolution: {integrity: sha512-xksl4Y20qnjGbF3/eo0rX+TXEiZkkgRCEO8n/q7tMeVKhQ41migVG+msF+xTJoC3HkrTWfak3Y2Z6UjTUbjeTg==} + '@mariozechner/pi-ai@0.73.0': + resolution: {integrity: sha512-phKOpcde/ssz6UYszkmaGJ9LF9mgt/AP8LrtSwsfap+kMSeFfSQ2/mCSBT1mLJ2BqVuff9uXs1/+op1aQeaafQ==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-coding-agent@0.71.1': - resolution: {integrity: sha512-pP7ymz+MmZrcN5aUldm1q1cVbG3u04yZR/XsHEfidku5W3PP1uxsA0A4g4NOhXnkK5EZ+Qg6H12BAbVvl7Qq2Q==} + '@mariozechner/pi-coding-agent@0.73.0': + resolution: {integrity: sha512-Fs2dRIgtjDT8X5VDGNGzxj251B0FvkRsgX03YJv1FK4wg5Maj+jkf8/5A6tbPnPcXsCgs41xxJRf3tF5vJRccA==} engines: {node: '>=20.6.0'} hasBin: true - '@mariozechner/pi-tui@0.71.1': - resolution: {integrity: sha512-jNMN9EmGiH8EIKG62fceOTonoJ9k0cohTdjQCDrOk77vnxPVK+3be/+S1xk4hxviltwxlRH0d7mGQXs+CuEL8g==} + '@mariozechner/pi-tui@0.73.0': + resolution: {integrity: sha512-St1W+tMPKHatfK+lblsKfL+SsFyFVMK2tW6xHpBfCiMuevbOCRo/CMatso7mu1642UO04ncmfCrrpUK5L9aoog==} engines: {node: '>=20.0.0'} '@matrix-org/matrix-sdk-crypto-nodejs@0.5.1': @@ -3867,12 +3871,12 @@ packages: resolution: {integrity: sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ==} engines: {node: '>= 18', npm: '>= 8.6.0'} - '@slack/types@2.20.1': - resolution: {integrity: sha512-eWX2mdt1ktpn8+40iiMc404uGrih+2fxiky3zBcPjtXKj6HLRdYlmhrPkJi7JTJm8dpXR6BWVWEDBXtaWMKD6A==} + '@slack/types@2.21.0': + resolution: {integrity: sha512-ZLMsKnD5KLRPmhFEoGoBQUD5Pc2bH3xFc5ygHlioEc0WmLGyZGoGCtMff4rpejrFnptrhfxcKpWxW4r3g39R0A==} engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} - '@slack/web-api@7.15.1': - resolution: {integrity: sha512-y+TAF7TszcmFzbVtBkFqAdBwKSoD+8shkNxhp4WIfFwXmCKdFje9WD6evROApPa2FTy1v1uc9yBaJs3609PPgg==} + '@slack/web-api@7.15.2': + resolution: {integrity: sha512-/m9qVFkiq85Oa/FSQwYIRDa/AO4qNYkDh4sRBK1WqEc2+RyG7w4tbU6rBIwUOcc/TmWOIr24Nraquxg7um5mYw==} engines: {node: '>= 18', npm: '>= 8.6.0'} '@smithy/chunked-blob-reader-native@4.2.3': @@ -4059,8 +4063,8 @@ packages: resolution: {integrity: sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==} engines: {node: '>=18.0.0'} - '@smithy/util-retry@4.3.6': - resolution: {integrity: sha512-p6/FO1n2KxMeQyna067i0uJ6TSbb165ZhnRtCpWh4Foxqbfc6oW+XITaL8QkFJj3KFnDe2URt4gOhgU06EP9ew==} + '@smithy/util-retry@4.3.8': + resolution: {integrity: sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw==} engines: {node: '>=18.0.0'} '@smithy/util-stream@4.5.25': @@ -4256,8 +4260,8 @@ packages: '@twurple/common@8.1.3': resolution: {integrity: sha512-B2BT42fJAEYqSPGjTd6qyZoUv6kgFzIvUJuTIrOUcBiJxcvZh8tD+WLRd5xfMKhtLbUFgesYlHxdPhmdar8/zw==} - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -4373,50 +4377,50 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-OIYsqKouI2U7W5Q6VgUz7+t9FpIXNFk30xSUG7gGlN1bdDniWfW7t5n6mzEtiHUVTxRgJQBjXGAlhVa6A9h+pg==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-+Qs1Q7Qxfp11n/hU3pweFU+EQ37FnDsdWOOxb7/vCy8QGBysrLUUYRhQ+GSW3s663oMtN6+9Kf82hk3ZT+kXlg==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-hQ5UsEyOz3ErQE3sKKHMCfJJGQenD0DSCi2ob+ywElXirG2NyFNA8cmx1g+MIm1lpQeEQslWZhe9EGwo9DJAbg==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-Wr3GWTRiMgibmhe88cjQ612ZyY7sbgsPYEaWKGPUxBaXtMHFIzgTBIoJMuaQqQx4GEJs6AUDyhnIHG1gx4rJjg==} engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-fbaFKE1UvtsQ6i1eJjBiNbglR9ywXrW/CH1sqYPEtr0WgTUpixbE6inQOXjB0jlEA9RzQq+QMzDyaCDmU82Dkw==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-y1Qai5l55Sl+/3B0hyQtvynq//C22BKFH3CfU35fbLYUo4P/ISUycyAbcA+PAPazpDFO3E56I96QUQrbJL2VVA==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-agkTW/t85XSJKWGcXdUV9ZmSi3Akh3POK+HhWehigEJR3W/jebiO9njifETfoUF6cpoYkFn+CZvfAJ00IWGZfA==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-s8QkhZe0M4QD2xhK1Xiy2JUQv1AOl8kUg5DLx1G8ws0f1BK/oKyqDNbxhZMGINYLFvkjpr9lOxt7qehSnpJMYQ==} engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-Sd8D+S88P7K0IH1U+a8pK20ZD+GM54t48/GLw9ebSklfCdt0iKdHgprjKIcl54C3SocGCcvEBPr1thwtTO9Vtg==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-ngN3Ie3Vin6pFtqeNywxm86RTxgI0Fo0GZyJ1PxokLES8J3xfMPtMYfv85c/+5uz5+7T+m4LRLyY5IoLY4gtuw==} engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-07sJNDnU7KHfo/trv/cBXpgFBELDYJAsTx5kNvBckSQUxbX+p/b9oQ3eFbtK3zDP4EEKdeiD9EelIy22atBnzA==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-/GZDJN/CsLbqIe7EdWDkXhNX9C41VjemBeUN6+9ckvEFLH8XyKTmXPYikNOn0N819M1KSeNZltplyUslfROOdw==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-8rzd/eQZyBuR+IRiPnIQrCwSuXIGBFiL8LsUMFqQt2WAUlQ0gGWBlLJHUVU4YNlju9QROjNHUGpJ52XGZbFv0Q==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-EYQBdVZq4xIzhTtKxw6wvee9238hEb7XrPG413AEZBD3kcR3qqvPULXsPzQyEpneCReATSaihscP/LfhMQYUmA==} engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260501.1': - resolution: {integrity: sha512-skD0ig8IzPwSY1L8VmNgfaxkfT8ImBwKeIypfZyJA+zHzWvroRKbRbT2GryOSREl22ZqLOuDfcq+7BdA0rjF2Q==} + '@typescript/native-preview@7.0.0-dev.20260504.1': + resolution: {integrity: sha512-bHFGxyIU83qjj6ywn3817A+Ug2ZID0GiBA5WFdbc/T7EjcrKnUUylexq0fU81N/mTbfw3FyP6ZCEdO2Ntcl/VQ==} engines: {node: '>=16.20.0'} hasBin: true @@ -4512,44 +4516,44 @@ packages: resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67} version: 2.0.1 - '@zed-industries/codex-acp-darwin-arm64@0.12.0': - resolution: {integrity: sha512-RvTXH21sLpswEo8xLeQXcA/uWZauyNP1y+WI6b355+/o7sQ5wrvBkxt+NyhaJXJIQvbfdpl04LND4cmM+DTcNg==} + '@zed-industries/codex-acp-darwin-arm64@0.13.0': + resolution: {integrity: sha512-SNJbpxOD1b98pK1Qw2pZjFJbfYBICheRs3mYvLMgHABehdypaeYKnEmEGp3Bu/gUT6JFAtOPRtaU+sfxKzgCvw==} cpu: [arm64] os: [darwin] hasBin: true - '@zed-industries/codex-acp-darwin-x64@0.12.0': - resolution: {integrity: sha512-N7EhrUTioix3L21qnm6kZzAESc+B7Mac+/uW3khn/UQe7fJJ7u1ojbgMPDdGo/8Xm6HBBXgak2NOj7mJ+NNXSw==} + '@zed-industries/codex-acp-darwin-x64@0.13.0': + resolution: {integrity: sha512-R5CQi2mmi9Nk2P6t48T5JoOQx0jWnP9DzLf5jcTnCLqk1tsg9XtASpLBtsedll9MesBax6aflDvz+0dyWW+3Mw==} cpu: [x64] os: [darwin] hasBin: true - '@zed-industries/codex-acp-linux-arm64@0.12.0': - resolution: {integrity: sha512-Kq35FclgZiSMBKyf80PnCvvJ3xfMjZIkPJXpci35U/VqXVQelhHCwYWwA3waTxvW07tNHxsehv1eQICz7wZdVQ==} + '@zed-industries/codex-acp-linux-arm64@0.13.0': + resolution: {integrity: sha512-Z3f2D94SOgy+BVFEIWxoR64IQB+d4/zgjHB1oeBS5yAYKaX7Wv3W6x+XGktDx+KnfD7c9vSSdFdknI6cZ8hO7g==} cpu: [arm64] os: [linux] hasBin: true - '@zed-industries/codex-acp-linux-x64@0.12.0': - resolution: {integrity: sha512-twmX9noSqfgWgVkGG1dd9u20Pxp8vNRXggvJ61RQSrNYITGuqHil2F3ViYICZoXyr9w1gok28bWG5DU2d9adPg==} + '@zed-industries/codex-acp-linux-x64@0.13.0': + resolution: {integrity: sha512-sWNfyeuwEHPo6DSbcjklnBr7M8+MWd2b9oVbIqgwxryTPpm0ZPF3U28PWR3/vGxS5UmhGiZIShe9tqx8FsvvBg==} cpu: [x64] os: [linux] hasBin: true - '@zed-industries/codex-acp-win32-arm64@0.12.0': - resolution: {integrity: sha512-VoFsTIrQopO917x2EpxYXm3jTIoSknCbzP76FwX9uOThlRms+M+fHWJ4kJttOPpeofz1ulAS3vPVMQ3WNlvnhw==} + '@zed-industries/codex-acp-win32-arm64@0.13.0': + resolution: {integrity: sha512-oxd6IF5dVHsa7zLnK1VAClzGADqn4N9TVSPb+3X4CqnOs4y4M9JPHSEEPiRYF44ibDJTWR+9EZ673djRYEGraw==} cpu: [arm64] os: [win32] hasBin: true - '@zed-industries/codex-acp-win32-x64@0.12.0': - resolution: {integrity: sha512-HImgXGIYgW6Wxr3rylrHS7Dzs35zvcQQB7eqAEWZ2Lj+3AxP/7TViW9KkjS+PTPnVWqpTkz0hYDQhk63Ruw3JA==} + '@zed-industries/codex-acp-win32-x64@0.13.0': + resolution: {integrity: sha512-675+tZlhzDMBJUrgiTnbcCMB15MQ8B0Ih/GmzB9MqW/FDFJqOFjXe4P+M7joePzQqa7QYwf36le50sDokXDrew==} cpu: [x64] os: [win32] hasBin: true - '@zed-industries/codex-acp@0.12.0': - resolution: {integrity: sha512-0d7gRzOiYTgDmIyh783mCcq50h3mdOg/TtKdLfBIghOLushpQRwhuLjKK8Q9hxZfNlPL0Ua56DoPjnsW8amf8g==} + '@zed-industries/codex-acp@0.13.0': + resolution: {integrity: sha512-Ep3gINMVB8qQL3kozJxEzG4YP7NmWUb5s+8yu8tQ7YSPfaIPXBIQQmO5sQk2Uu2av+gIC2EchbwaSSG3Mo17YQ==} hasBin: true abbrev@1.1.1: @@ -5377,14 +5381,14 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.1: + resolution: {integrity: sha512-h2r7rcm6Ee/J8o0LD5djLuFVcfbZxhvho4vvsbeV0aMvXjUgqv4YpxpkEx0d68l6+IleVfLAdVEfhR7QNMkGHQ==} fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} - fast-xml-builder@1.1.5: - resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} + fast-xml-builder@1.1.7: + resolution: {integrity: sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ==} fast-xml-parser@5.7.0: resolution: {integrity: sha512-MTcrUoRQ1GSQ9iG3QJzBGquYYYeA7piZaJoIWbPFGbRn6Jj6z7xgoAyi4DrZX4y2ZIQQBF59gc/zmvvejjgoFQ==} @@ -5738,8 +5742,8 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} - ipaddr.js@2.3.0: - resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} engines: {node: '>= 10'} ircv3@0.33.1: @@ -6098,8 +6102,8 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - lru-cache@11.3.5: - resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} lru-cache@6.0.0: @@ -6395,10 +6399,6 @@ packages: resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} engines: {node: ^18 || ^20 || >= 21} - node-gyp-build@4.8.4: - resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} - hasBin: true - node-downloader-helper@2.1.11: resolution: {integrity: sha512-882fH2C9AWdiPCwz/2beq5t8FGMZK9Dx8TJUOIxzMCbvG7XUKM5BuJwN5f0NKo4SCQK6jR4p2TPm54mYGdGchQ==} engines: {node: '>=14.18'} @@ -6421,6 +6421,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-sarif-builder@3.4.0: resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==} engines: {node: '>=20'} @@ -6518,8 +6522,8 @@ packages: zod: optional: true - openai@6.35.0: - resolution: {integrity: sha512-L/skwIGnt5xQZHb0UfTu9uAUKbis3ehKypOuJKi20QvG7UStV6C8IC3myGYHcdiF4kms/bAvOJ9UqqNWqi8x/Q==} + openai@6.36.0: + resolution: {integrity: sha512-Has2YbIusMq9wQEierFsgf9c783dy1y9arX459LmphNacEkkM5yxi2RIyXP0LmkOroQyW19iTwALHL8Yf26UKA==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -6746,8 +6750,8 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} - postcss@8.5.13: - resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} prism-media@1.3.5: @@ -7519,8 +7523,8 @@ packages: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} engines: {node: '>=20.18.1'} - undici@8.1.0: - resolution: {integrity: sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==} + undici@8.2.0: + resolution: {integrity: sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==} engines: {node: '>=22.19.0'} unhomoglyph@1.0.6: @@ -7800,8 +7804,8 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} - yaml@2.8.3: - resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + yaml@2.8.4: + resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} engines: {node: '>= 14.6'} hasBin: true @@ -7849,82 +7853,82 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.4.1: - resolution: {integrity: sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: - '@agentclientprotocol/claude-agent-acp@0.31.4(patch_hash=e8b472d71289ac8de9813c57d79abac524889ca96f279f6f3ad08043434f6615)': + '@agentclientprotocol/claude-agent-acp@0.32.0(patch_hash=1fe782f9679d7a725cbe59e51d61419fbb25d4c463d186c43c95644770cb2b98)': dependencies: - '@agentclientprotocol/sdk': 0.21.0(zod@4.4.1) - '@anthropic-ai/claude-agent-sdk': 0.2.121(zod@4.4.1) - zod: 4.4.1 + '@agentclientprotocol/sdk': 0.21.0(zod@4.4.3) + '@anthropic-ai/claude-agent-sdk': 0.2.126(zod@4.4.3) + zod: 4.4.3 transitivePeerDependencies: - '@cfworker/json-schema' - supports-color - '@agentclientprotocol/sdk@0.20.0(zod@4.4.1)': + '@agentclientprotocol/sdk@0.20.0(zod@4.4.3)': dependencies: - zod: 4.4.1 + zod: 4.4.3 - '@agentclientprotocol/sdk@0.21.0(zod@4.4.1)': + '@agentclientprotocol/sdk@0.21.0(zod@4.4.3)': dependencies: - zod: 4.4.1 + zod: 4.4.3 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.121': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.121': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.121': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.121': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.121': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.2.121': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.121': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.2.121': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.2.126': optional: true - '@anthropic-ai/claude-agent-sdk@0.2.121(zod@4.4.1)': + '@anthropic-ai/claude-agent-sdk@0.2.126(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.92.0(zod@4.4.1) - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.1) - zod: 4.4.1 + '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + zod: 4.4.3 optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.2.121 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.2.121 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.2.121 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.2.121 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.2.121 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.2.121 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.2.121 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.2.121 + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.2.126 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.2.126 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.2.126 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.2.126 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.2.126 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.2.126 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.2.126 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.2.126 transitivePeerDependencies: - '@cfworker/json-schema' - supports-color - '@anthropic-ai/sdk@0.92.0(zod@4.4.1)': + '@anthropic-ai/sdk@0.93.0(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: - zod: 4.4.1 + zod: 4.4.3 - '@anthropic-ai/vertex-sdk@0.16.0(zod@4.4.1)': + '@anthropic-ai/vertex-sdk@0.16.0(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.92.0(zod@4.4.1) + '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) google-auth-library: 9.15.1 transitivePeerDependencies: - encoding @@ -8043,14 +8047,14 @@ snapshots: '@smithy/util-defaults-mode-node': 4.2.54 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 '@smithy/util-stream': 4.5.25 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/client-bedrock@3.1041.0': + '@aws-sdk/client-bedrock@3.1042.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 @@ -8061,7 +8065,7 @@ snapshots: '@aws-sdk/middleware-recursion-detection': 3.972.11 '@aws-sdk/middleware-user-agent': 3.972.38 '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/token-providers': 3.1041.0 + '@aws-sdk/token-providers': 3.1042.0 '@aws-sdk/types': 3.973.8 '@aws-sdk/util-endpoints': 3.996.8 '@aws-sdk/util-user-agent-browser': 3.972.10 @@ -8089,13 +8093,13 @@ snapshots: '@smithy/util-defaults-mode-node': 4.2.54 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/client-cognito-identity@3.1041.0': + '@aws-sdk/client-cognito-identity@3.1042.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 @@ -8133,13 +8137,13 @@ snapshots: '@smithy/util-defaults-mode-node': 4.2.54 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/client-s3@3.1041.0': + '@aws-sdk/client-s3@3.1042.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 @@ -8191,7 +8195,7 @@ snapshots: '@smithy/util-defaults-mode-node': 4.2.54 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 '@smithy/util-stream': 4.5.25 '@smithy/util-utf8': 4.2.2 '@smithy/util-waiter': 4.3.0 @@ -8212,7 +8216,7 @@ snapshots: '@smithy/types': 4.14.1 '@smithy/util-base64': 4.3.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 @@ -8335,9 +8339,9 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-providers@3.1041.0': + '@aws-sdk/credential-providers@3.1042.0': dependencies: - '@aws-sdk/client-cognito-identity': 3.1041.0 + '@aws-sdk/client-cognito-identity': 3.1042.0 '@aws-sdk/core': 3.974.8 '@aws-sdk/credential-provider-cognito-identity': 3.972.31 '@aws-sdk/credential-provider-env': 3.972.34 @@ -8466,7 +8470,7 @@ snapshots: '@smithy/core': 3.23.17 '@smithy/protocol-http': 5.3.14 '@smithy/types': 4.14.1 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 tslib: 2.8.1 '@aws-sdk/middleware-websocket@3.972.16': @@ -8522,7 +8526,7 @@ snapshots: '@smithy/util-defaults-mode-node': 4.2.54 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: @@ -8536,7 +8540,7 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/s3-request-presigner@3.1041.0': + '@aws-sdk/s3-request-presigner@3.1042.0': dependencies: '@aws-sdk/signature-v4-multi-region': 3.996.25 '@aws-sdk/types': 3.973.8 @@ -8580,6 +8584,18 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/token-providers@3.1042.0': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/types@3.973.8': dependencies: '@smithy/types': 4.14.1 @@ -8633,7 +8649,7 @@ snapshots: '@aws/bedrock-token-generator@1.1.0': dependencies: - '@aws-sdk/credential-providers': 3.1041.0 + '@aws-sdk/credential-providers': 3.1042.0 '@aws-sdk/util-format-url': 3.972.10 '@smithy/config-resolver': 4.4.17 '@smithy/hash-node': 4.2.14 @@ -8817,14 +8833,14 @@ snapshots: '@clawdbot/lobster@2026.4.6': dependencies: ajv: 8.20.0 - yaml: 2.8.3 + yaml: 2.8.4 '@colors/colors@1.5.0': optional: true - '@copilotkit/aimock@1.16.4(vitest@4.1.5)': + '@copilotkit/aimock@1.17.0(vitest@4.1.5)': optionalDependencies: - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) '@create-markdown/preview@2.0.3(shiki@3.23.0)': optionalDependencies: @@ -9046,14 +9062,14 @@ snapshots: optionalDependencies: '@noble/hashes': 2.0.1 - '@google/genai@1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))': + '@google/genai@1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.6.2 p-retry: 4.6.2 protobufjs: 7.5.5 ws: 8.20.0 optionalDependencies: - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.1) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color @@ -9089,7 +9105,7 @@ snapshots: '@hapi/hoek@9.3.0': {} - '@homebridge/ciao@1.3.7': + '@homebridge/ciao@1.3.8': dependencies: debug: 4.4.3 fast-deep-equal: 3.1.3 @@ -9627,9 +9643,9 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1)': + '@mariozechner/pi-agent-core@0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)': dependencies: - '@mariozechner/pi-ai': 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) + '@mariozechner/pi-ai': 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) typebox: 1.1.37 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -9640,19 +9656,19 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1)': + '@mariozechner/pi-ai@0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.92.0(zod@4.4.1) + '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1024.0 - '@google/genai': 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1)) + '@google/genai': 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@mistralai/mistralai': 2.2.1 chalk: 5.6.2 - openai: 6.26.0(ws@8.20.0)(zod@4.4.1) + openai: 6.26.0(ws@8.20.0)(zod@4.4.3) partial-json: 0.1.7 proxy-agent: 6.5.0 typebox: 1.1.37 undici: 7.25.0 - zod-to-json-schema: 3.25.2(zod@4.4.1) + zod-to-json-schema: 3.25.2(zod@4.4.3) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -9662,12 +9678,12 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1)': + '@mariozechner/pi-coding-agent@0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)': dependencies: '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) - '@mariozechner/pi-ai': 0.71.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.1))(ws@8.20.0)(zod@4.4.1) - '@mariozechner/pi-tui': 0.71.1 + '@mariozechner/pi-agent-core': 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + '@mariozechner/pi-ai': 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + '@mariozechner/pi-tui': 0.73.0 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cli-highlight: 2.1.11 @@ -9684,7 +9700,7 @@ snapshots: typebox: 1.1.37 undici: 7.25.0 uuid: 14.0.0 - yaml: 2.8.3 + yaml: 2.8.4 optionalDependencies: '@mariozechner/clipboard': 0.3.5 transitivePeerDependencies: @@ -9696,7 +9712,7 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.71.1': + '@mariozechner/pi-tui@0.73.0': dependencies: '@types/mime-types': 2.1.4 chalk: 5.6.2 @@ -9788,13 +9804,13 @@ snapshots: '@mistralai/mistralai@2.2.1': dependencies: ws: 8.20.0 - zod: 4.4.1 - zod-to-json-schema: 3.25.2(zod@4.4.1) + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) transitivePeerDependencies: - bufferutil - utf-8-validate - '@modelcontextprotocol/sdk@1.29.0(zod@4.4.1)': + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.14) ajv: 8.20.0 @@ -9811,8 +9827,8 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.4.1 - zod-to-json-schema: 3.25.2(zod@4.4.1) + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) transitivePeerDependencies: - supports-color @@ -9917,7 +9933,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.1 + '@tybys/wasm-util': 0.10.2 optional: true '@noble/ciphers@2.1.1': {} @@ -9983,7 +9999,7 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) - yaml: 2.8.3 + yaml: 2.8.4 '@opentelemetry/context-async-hooks@2.7.1(@opentelemetry/api@1.9.1)': dependencies: @@ -10500,8 +10516,8 @@ snapshots: '@slack/logger': 4.0.1 '@slack/oauth': 3.0.5 '@slack/socket-mode': 2.0.7 - '@slack/types': 2.20.1 - '@slack/web-api': 7.15.1 + '@slack/types': 2.21.0 + '@slack/web-api': 7.15.2 '@types/express': 5.0.6 axios: 1.15.0 express: 5.2.1 @@ -10521,7 +10537,7 @@ snapshots: '@slack/oauth@3.0.5': dependencies: '@slack/logger': 4.0.1 - '@slack/web-api': 7.15.1 + '@slack/web-api': 7.15.2 '@types/jsonwebtoken': 9.0.10 '@types/node': 25.6.0 jsonwebtoken: 9.0.3 @@ -10531,7 +10547,7 @@ snapshots: '@slack/socket-mode@2.0.7': dependencies: '@slack/logger': 4.0.1 - '@slack/web-api': 7.15.1 + '@slack/web-api': 7.15.2 '@types/node': 25.6.0 '@types/ws': 8.18.1 eventemitter3: 5.0.4 @@ -10541,12 +10557,12 @@ snapshots: - debug - utf-8-validate - '@slack/types@2.20.1': {} + '@slack/types@2.21.0': {} - '@slack/web-api@7.15.1': + '@slack/web-api@7.15.2': dependencies: '@slack/logger': 4.0.1 - '@slack/types': 2.20.1 + '@slack/types': 2.21.0 '@types/node': 25.6.0 '@types/retry': 0.12.0 axios: 1.15.0 @@ -10702,7 +10718,7 @@ snapshots: '@smithy/smithy-client': 4.12.13 '@smithy/types': 4.14.1 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.6 + '@smithy/util-retry': 4.3.8 '@smithy/uuid': 1.1.2 tslib: 2.8.1 @@ -10853,7 +10869,7 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-retry@4.3.6': + '@smithy/util-retry@4.3.8': dependencies: '@smithy/service-error-classification': 4.3.1 '@smithy/types': 4.14.1 @@ -11061,7 +11077,7 @@ snapshots: klona: 2.0.6 tslib: 2.8.1 - '@tybys/wasm-util@0.10.1': + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true @@ -11189,36 +11205,36 @@ snapshots: '@types/node': 25.6.0 optional: true - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260501.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260504.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260501.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260504.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260501.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260504.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260501.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260504.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260501.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260504.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260501.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260504.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260501.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260504.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260501.1': + '@typescript/native-preview@7.0.0-dev.20260504.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260501.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260501.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260501.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260501.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260501.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260501.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260501.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260504.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260504.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260504.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260504.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260504.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260504.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260504.1 '@typespec/ts-http-runtime@0.3.5': dependencies: @@ -11232,29 +11248,29 @@ snapshots: '@urbit/aura@3.0.0': {} - '@vitest/browser-playwright@4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5)': + '@vitest/browser-playwright@4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)': dependencies: - '@vitest/browser': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5) - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/browser': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) playwright: 1.59.1 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5)': + '@vitest/browser@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) '@vitest/utils': 4.1.5 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) ws: 8.20.0 transitivePeerDependencies: - bufferutil @@ -11274,9 +11290,9 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) optionalDependencies: - '@vitest/browser': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5) + '@vitest/browser': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) '@vitest/expect@4.1.5': dependencies: @@ -11287,13 +11303,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) '@vitest/pretty-format@4.1.5': dependencies: @@ -11347,7 +11363,7 @@ snapshots: '@hapi/boom': 9.1.4 async-mutex: 0.5.0 libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' - lru-cache: 11.3.5 + lru-cache: 11.3.6 music-metadata: 11.12.3 p-queue: 9.2.0 pino: 9.14.0 @@ -11367,32 +11383,32 @@ snapshots: curve25519-js: 0.0.4 protobufjs: 7.5.5 - '@zed-industries/codex-acp-darwin-arm64@0.12.0': + '@zed-industries/codex-acp-darwin-arm64@0.13.0': optional: true - '@zed-industries/codex-acp-darwin-x64@0.12.0': + '@zed-industries/codex-acp-darwin-x64@0.13.0': optional: true - '@zed-industries/codex-acp-linux-arm64@0.12.0': + '@zed-industries/codex-acp-linux-arm64@0.13.0': optional: true - '@zed-industries/codex-acp-linux-x64@0.12.0': + '@zed-industries/codex-acp-linux-x64@0.13.0': optional: true - '@zed-industries/codex-acp-win32-arm64@0.12.0': + '@zed-industries/codex-acp-win32-arm64@0.13.0': optional: true - '@zed-industries/codex-acp-win32-x64@0.12.0': + '@zed-industries/codex-acp-win32-x64@0.13.0': optional: true - '@zed-industries/codex-acp@0.12.0': + '@zed-industries/codex-acp@0.13.0': optionalDependencies: - '@zed-industries/codex-acp-darwin-arm64': 0.12.0 - '@zed-industries/codex-acp-darwin-x64': 0.12.0 - '@zed-industries/codex-acp-linux-arm64': 0.12.0 - '@zed-industries/codex-acp-linux-x64': 0.12.0 - '@zed-industries/codex-acp-win32-arm64': 0.12.0 - '@zed-industries/codex-acp-win32-x64': 0.12.0 + '@zed-industries/codex-acp-darwin-arm64': 0.13.0 + '@zed-industries/codex-acp-darwin-x64': 0.13.0 + '@zed-industries/codex-acp-linux-arm64': 0.13.0 + '@zed-industries/codex-acp-linux-x64': 0.13.0 + '@zed-industries/codex-acp-win32-arm64': 0.13.0 + '@zed-industries/codex-acp-win32-x64': 0.13.0 abbrev@1.1.1: optional: true @@ -11420,11 +11436,11 @@ snapshots: acpx@0.6.1: dependencies: - '@agentclientprotocol/sdk': 0.20.0(zod@4.4.1) + '@agentclientprotocol/sdk': 0.20.0(zod@4.4.3) commander: 14.0.3 skillflag: 0.1.4 tsx: 4.21.0 - zod: 4.4.1 + zod: 4.4.3 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -11448,7 +11464,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.1 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -12229,20 +12245,20 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.0: {} + fast-uri@3.1.1: {} fast-wrap-ansi@0.2.0: dependencies: fast-string-width: 3.0.2 - fast-xml-builder@1.1.5: + fast-xml-builder@1.1.7: dependencies: path-expression-matcher: 1.5.0 fast-xml-parser@5.7.0: dependencies: '@nodable/entities': 2.1.0 - fast-xml-builder: 1.1.5 + fast-xml-builder: 1.1.7 path-expression-matcher: 1.5.0 strnum: 2.2.3 @@ -12607,7 +12623,7 @@ snapshots: hosted-git-info@9.0.3: dependencies: - lru-cache: 11.3.5 + lru-cache: 11.3.6 html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): dependencies: @@ -12715,7 +12731,7 @@ snapshots: ipaddr.js@1.9.1: {} - ipaddr.js@2.3.0: {} + ipaddr.js@2.4.0: {} ircv3@0.33.1: dependencies: @@ -12882,7 +12898,7 @@ snapshots: decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1) is-potential-custom-element-name: 1.0.1 - lru-cache: 11.3.5 + lru-cache: 11.3.6 parse5: 8.0.1 saxes: 6.0.0 symbol-tree: 3.2.4 @@ -13105,7 +13121,7 @@ snapshots: longest-streak@3.1.0: {} - lru-cache@11.3.5: {} + lru-cache@11.3.6: {} lru-cache@6.0.0: dependencies: @@ -13121,7 +13137,7 @@ snapshots: lru-memoizer@3.0.0: dependencies: lodash.clonedeep: 4.5.0 - lru-cache: 11.3.5 + lru-cache: 11.3.6 lru_map@0.4.1: {} @@ -13588,8 +13604,6 @@ snapshots: node-addon-api@8.7.0: {} - node-gyp-build@4.8.4: {} - node-downloader-helper@2.1.11: {} node-edge-tts@1.2.10: @@ -13612,6 +13626,8 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-gyp-build@4.8.4: {} + node-sarif-builder@3.4.0: dependencies: '@types/sarif': 2.1.7 @@ -13706,15 +13722,15 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 - openai@6.26.0(ws@8.20.0)(zod@4.4.1): + openai@6.26.0(ws@8.20.0)(zod@4.4.3): optionalDependencies: ws: 8.20.0 - zod: 4.4.1 + zod: 4.4.3 - openai@6.35.0(ws@8.20.0)(zod@4.4.1): + openai@6.36.0(ws@8.20.0)(zod@4.4.3): optionalDependencies: ws: 8.20.0 - zod: 4.4.1 + zod: 4.4.3 openshell@0.1.0: dependencies: @@ -13915,7 +13931,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.3.5 + lru-cache: 11.3.6 minipass: 7.1.3 path-to-regexp@8.4.0: {} @@ -13974,7 +13990,7 @@ snapshots: pngjs@7.0.0: {} - postcss@8.5.13: + postcss@8.5.14: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 @@ -14311,7 +14327,7 @@ snapshots: glob: 7.2.3 optional: true - rolldown-plugin-dts@0.23.2(@typescript/native-preview@7.0.0-dev.20260501.1)(rolldown@1.0.0-rc.17)(typescript@6.0.3): + rolldown-plugin-dts@0.23.2(@typescript/native-preview@7.0.0-dev.20260504.1)(rolldown@1.0.0-rc.17)(typescript@6.0.3): dependencies: '@babel/generator': 8.0.0-rc.3 '@babel/helper-validator-identifier': 8.0.0-rc.3 @@ -14325,7 +14341,7 @@ snapshots: picomatch: 4.0.4 rolldown: 1.0.0-rc.17 optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260501.1 + '@typescript/native-preview': 7.0.0-dev.20260504.1 typescript: 6.0.3 transitivePeerDependencies: - oxc-resolver @@ -14606,6 +14622,7 @@ snapshots: sqlite-vec-linux-arm64: 0.1.9 sqlite-vec-linux-x64: 0.1.9 sqlite-vec-windows-x64: 0.1.9 + optional: true stackback@0.0.2: {} @@ -14798,7 +14815,7 @@ snapshots: ts-algebra@2.0.0: {} - tsdown@0.21.10(@typescript/native-preview@7.0.0-dev.20260501.1)(typescript@6.0.3): + tsdown@0.21.10(@typescript/native-preview@7.0.0-dev.20260504.1)(typescript@6.0.3): dependencies: ansis: 4.2.0 cac: 7.0.0 @@ -14809,7 +14826,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.4 rolldown: 1.0.0-rc.17 - rolldown-plugin-dts: 0.23.2(@typescript/native-preview@7.0.0-dev.20260501.1)(rolldown@1.0.0-rc.17)(typescript@6.0.3) + rolldown-plugin-dts: 0.23.2(@typescript/native-preview@7.0.0-dev.20260504.1)(rolldown@1.0.0-rc.17)(typescript@6.0.3) semver: 7.7.4 tinyexec: 1.1.2 tinyglobby: 0.2.16 @@ -14873,7 +14890,7 @@ snapshots: undici@7.25.0: {} - undici@8.1.0: {} + undici@8.2.0: {} unhomoglyph@1.0.6: {} @@ -14949,11 +14966,11 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): + vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.13 + postcss: 8.5.14 rolldown: 1.0.0-rc.17 tinyglobby: 0.2.16 optionalDependencies: @@ -14962,12 +14979,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 tsx: 4.21.0 - yaml: 2.8.3 + yaml: 2.8.4 - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -14984,12 +15001,12 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 25.6.0 - '@vitest/browser-playwright': 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.5) + '@vitest/browser-playwright': 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) '@vitest/coverage-v8': 4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5) jsdom: 29.1.1(@noble/hashes@2.0.1) transitivePeerDependencies: @@ -15102,7 +15119,7 @@ snapshots: yallist@5.0.0: {} - yaml@2.8.3: {} + yaml@2.8.4: {} yargs-parser@18.1.3: dependencies: @@ -15168,12 +15185,12 @@ snapshots: - bufferutil - utf-8-validate - zod-to-json-schema@3.25.2(zod@4.4.1): + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: - zod: 4.4.1 + zod: 4.4.3 zod@3.25.76: {} - zod@4.4.1: {} + zod@4.4.3: {} zwitch@2.0.4: {} diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts index d263d05b07c..89e53214f6a 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts @@ -8,6 +8,7 @@ import type { MessagingToolSend } from "./pi-embedded-messaging.types.js"; import { handleToolExecutionEnd, handleToolExecutionStart, + handleToolExecutionUpdate, } from "./pi-embedded-subscribe.handlers.tools.js"; import type { ToolCallSummary, @@ -713,6 +714,47 @@ describe("handleToolExecutionEnd exec approval prompts", () => { }); describe("handleToolExecutionEnd derived tool events", () => { + it("emits command output deltas for exec update results", async () => { + const { ctx, onAgentEvent } = createTestContext(); + + await handleToolExecutionStart( + ctx as never, + { + type: "tool_execution_start", + toolName: "exec", + toolCallId: "tool-exec-update-output", + args: { command: "npm test" }, + } as never, + ); + + handleToolExecutionUpdate( + ctx as never, + { + type: "tool_execution_update", + toolName: "exec", + toolCallId: "tool-exec-update-output", + partialResult: { + details: { + status: "running", + aggregated: "RUN src/example.test.ts", + }, + }, + } as never, + ); + + expect(onAgentEvent).toHaveBeenCalledWith( + expect.objectContaining({ + stream: "command_output", + data: expect.objectContaining({ + itemId: "command:tool-exec-update-output", + phase: "delta", + output: "RUN src/example.test.ts", + status: "running", + }), + }), + ); + }); + it("emits command output events for exec results", async () => { const { ctx, onAgentEvent } = createTestContext(); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index ecc1445f287..3df28deeb87 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -772,7 +772,11 @@ export function handleToolExecutionUpdate( }, }); if (isExecToolName(toolName)) { - const output = extractToolResultText(sanitized); + const execDetails = readExecToolDetails(sanitized); + const output = + execDetails && "aggregated" in execDetails + ? execDetails.aggregated + : extractToolResultText(sanitized); const commandData: AgentItemEventData = { itemId: buildCommandItemId(toolCallId), phase: "update", From 7e229f0d3d63acc52a831d5017b22e45c4feffa5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 15:11:14 -0700 Subject: [PATCH 061/107] fix(docker): prune external plugin dist (#77547) --- CHANGELOG.md | 1 + Dockerfile | 1 + scripts/prune-docker-plugin-dist.d.mts | 6 +++ scripts/prune-docker-plugin-dist.mjs | 52 ++++++++++++++++++ src/dockerfile.test.ts | 3 ++ src/plugins/prune-docker-plugin-dist.test.ts | 56 ++++++++++++++++++++ 6 files changed, 119 insertions(+) create mode 100644 scripts/prune-docker-plugin-dist.d.mts create mode 100644 scripts/prune-docker-plugin-dist.mjs create mode 100644 src/plugins/prune-docker-plugin-dist.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 101b3d967e6..98500da708f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc. +- Docker: prune package-excluded plugin dist directories from runtime images unless the build explicitly opts that plugin in, so official external plugins such as Feishu stay install-on-demand instead of shipping partial metadata without compiled runtime output. Fixes #77424. Thanks @vincentkoc. - CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc. - CLI/update: use an absolute POSIX npm script shell during package-manager updates, so restricted PATH environments can still run dependency lifecycle scripts while updating from `--tag main`. Fixes #77530. Thanks @PeterTremonti. - Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. diff --git a/Dockerfile b/Dockerfile index 60b50869fbc..d14c730132e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -124,6 +124,7 @@ RUN printf 'packages:\n - .\n - ui\n' > /tmp/pnpm-workspace.runtime.yaml && \ cp /tmp/pnpm-workspace.runtime.yaml pnpm-workspace.yaml && \ CI=true NPM_CONFIG_FROZEN_LOCKFILE=false pnpm prune --prod && \ node scripts/postinstall-bundled-plugins.mjs && \ + OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs && \ find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \ node scripts/check-package-dist-imports.mjs /app diff --git a/scripts/prune-docker-plugin-dist.d.mts b/scripts/prune-docker-plugin-dist.d.mts new file mode 100644 index 00000000000..cdc7d9163bd --- /dev/null +++ b/scripts/prune-docker-plugin-dist.d.mts @@ -0,0 +1,6 @@ +export function parseDockerPluginKeepList(value: unknown): Set; +export function pruneDockerPluginDist(params?: { + cwd?: string; + repoRoot?: string; + env?: NodeJS.ProcessEnv; +}): string[]; diff --git a/scripts/prune-docker-plugin-dist.mjs b/scripts/prune-docker-plugin-dist.mjs new file mode 100644 index 00000000000..cd01d591bc6 --- /dev/null +++ b/scripts/prune-docker-plugin-dist.mjs @@ -0,0 +1,52 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { collectRootPackageExcludedExtensionDirs } from "./lib/bundled-plugin-build-entries.mjs"; +import { removePathIfExists } from "./runtime-postbuild-shared.mjs"; + +function parsePluginList(value) { + if (typeof value !== "string") { + return new Set(); + } + return new Set( + value + .split(/[\s,]+/u) + .map((entry) => entry.trim()) + .filter(Boolean), + ); +} + +export function parseDockerPluginKeepList(value) { + return parsePluginList(value); +} + +export function pruneDockerPluginDist(params = {}) { + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); + const env = params.env ?? process.env; + const keepPluginIds = parseDockerPluginKeepList(env.OPENCLAW_EXTENSIONS); + const excludedPluginIds = collectRootPackageExcludedExtensionDirs({ cwd: repoRoot }); + const removed = []; + + for (const pluginId of [...excludedPluginIds].toSorted((left, right) => + left.localeCompare(right), + )) { + if (keepPluginIds.has(pluginId)) { + continue; + } + + for (const root of ["dist", "dist-runtime"]) { + const pluginDistDir = path.join(repoRoot, root, "extensions", pluginId); + if (!fs.existsSync(pluginDistDir)) { + continue; + } + removePathIfExists(pluginDistDir); + removed.push(path.relative(repoRoot, pluginDistDir).replaceAll("\\", "/")); + } + } + + return removed; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + pruneDockerPluginDist(); +} diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index d550f9dd45d..b795f19f9a0 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -111,6 +111,9 @@ describe("Dockerfile", () => { expect(dockerfile).toContain("pnpm-workspace.runtime.yaml"); expect(dockerfile).toContain(" - ui\\n"); expect(dockerfile).toContain("CI=true NPM_CONFIG_FROZEN_LOCKFILE=false pnpm prune --prod"); + expect(dockerfile).toContain( + 'OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs', + ); expect(dockerfile).toContain("prune must not rediscover unrelated workspaces"); expect(dockerfile).not.toContain( `npm install --prefix "${BUNDLED_PLUGIN_ROOT_DIR}/$ext" --omit=dev --silent`, diff --git a/src/plugins/prune-docker-plugin-dist.test.ts b/src/plugins/prune-docker-plugin-dist.test.ts new file mode 100644 index 00000000000..d162c83f334 --- /dev/null +++ b/src/plugins/prune-docker-plugin-dist.test.ts @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + parseDockerPluginKeepList, + pruneDockerPluginDist, +} from "../../scripts/prune-docker-plugin-dist.mjs"; +import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "../../test/helpers/temp-repo.js"; + +const tempDirs: string[] = []; + +function makeRepoRoot(prefix: string): string { + return makeTempRepoRoot(tempDirs, prefix); +} + +function writeDistPluginFile(repoRoot: string, root: "dist" | "dist-runtime", pluginId: string) { + const pluginDir = path.join(repoRoot, root, "extensions", pluginId); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, "openclaw.plugin.json"), "{}\n", "utf8"); +} + +afterEach(() => { + cleanupTempDirs(tempDirs); +}); + +describe("pruneDockerPluginDist", () => { + it("parses space and comma separated Docker plugin keep lists", () => { + expect([...parseDockerPluginKeepList("diagnostics-otel feishu,discord")]).toEqual([ + "diagnostics-otel", + "feishu", + "discord", + ]); + }); + + it("removes package-excluded plugin dist unless Docker explicitly opts it in", () => { + const repoRoot = makeRepoRoot("openclaw-docker-plugin-dist-"); + writeJsonFile(path.join(repoRoot, "package.json"), { + files: ["dist/**", "!dist/extensions/diagnostics-otel/**", "!dist/extensions/feishu/**"], + }); + writeDistPluginFile(repoRoot, "dist", "diagnostics-otel"); + writeDistPluginFile(repoRoot, "dist", "feishu"); + writeDistPluginFile(repoRoot, "dist-runtime", "feishu"); + writeDistPluginFile(repoRoot, "dist", "telegram"); + + const removed = pruneDockerPluginDist({ + repoRoot, + env: { OPENCLAW_EXTENSIONS: "diagnostics-otel" } as NodeJS.ProcessEnv, + }); + + expect(removed).toEqual(["dist/extensions/feishu", "dist-runtime/extensions/feishu"]); + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "diagnostics-otel"))).toBe(true); + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "feishu"))).toBe(false); + expect(fs.existsSync(path.join(repoRoot, "dist-runtime", "extensions", "feishu"))).toBe(false); + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "telegram"))).toBe(true); + }); +}); From e259938e9643a43cdc88aa324e08a2ab5f5d7176 Mon Sep 17 00:00:00 2001 From: Satoshi Date: Mon, 4 May 2026 14:42:16 +0100 Subject: [PATCH 062/107] fix: harden startup readiness and discord replies (cherry picked from commit 3956672106b3387d42427a485a9ca01e77f3b78f) --- ...startup-readiness-and-leak-fix-20260504.md | 93 +++++++++++++++++++ .../src/monitor/provider.lifecycle.test.ts | 31 ++++--- .../discord/src/monitor/provider.lifecycle.ts | 70 ++++++++------ .../src/monitor/reply-delivery.test.ts | 70 ++++++++++++++ .../discord/src/monitor/reply-delivery.ts | 7 +- .../discord/src/monitor/reply-safety.ts | 64 +++++++++++++ src/gateway/operator-approvals-client.ts | 6 +- .../server-startup-unavailable-methods.ts | 1 + src/infra/approval-handler-bootstrap.test.ts | 49 ++++++++++ src/infra/approval-handler-bootstrap.ts | 29 ++++++ .../exec-approval-channel-runtime.test.ts | 4 +- src/infra/exec-approval-channel-runtime.ts | 6 +- 12 files changed, 381 insertions(+), 49 deletions(-) create mode 100644 docs/status/openclaw-startup-readiness-and-leak-fix-20260504.md create mode 100644 extensions/discord/src/monitor/reply-safety.ts diff --git a/docs/status/openclaw-startup-readiness-and-leak-fix-20260504.md b/docs/status/openclaw-startup-readiness-and-leak-fix-20260504.md new file mode 100644 index 00000000000..e996cfd1a17 --- /dev/null +++ b/docs/status/openclaw-startup-readiness-and-leak-fix-20260504.md @@ -0,0 +1,93 @@ +# OpenClaw Startup Readiness And Leak Fix - 2026-05-04 + +## Current Truth + +- Incident inputs confirmed Discord front-channel leakage of internal execution/commentary-like traces and Gateway startup instability in the same window. +- Observed bad startup keywords from local operator evidence: + - `gateway event loop readiness timeout` + - `discord: gateway was not ready after 15000ms; restarting gateway` + - `sessions.list` requests around 40 seconds + - `exit 78` with systemd `RestartPreventExitStatus=78` +- This source fix addresses the startup terminal-fail path and Discord final outbound leakage guard. It does not restart any running Gateway by itself. + +## Code Changes + +- Startup control-plane load shedding: + - Added `sessions.list` to `STARTUP_UNAVAILABLE_GATEWAY_METHODS`. + - During sidecar startup, Gateway now returns retryable startup `UNAVAILABLE` for `sessions.list` instead of dispatching the costly session scan path. +- Native approval bootstrap readiness handling: + - Changed approval-client readiness failure text away from the production incident keyword. + - Changed exec-approval runtime readiness failure text away from the production incident keyword. + - Classified gateway readiness/startup close errors as retryable bootstrap deferrals. + - Normalized legacy readiness-timeout errors before logging retry deferrals, so old incident keywords do not reappear in native-approval retry logs. + - Native approval handler startup now warns and retries instead of emitting the old terminal-looking `failed to start native approval handler` path for readiness-only failures. +- Discord gateway READY wait: + - Replaced the one-restart-then-throw startup behavior with reconnect plus 2 second backoff until READY, stop, or abort. + - Removed the old log string `gateway was not ready after 15000ms; restarting gateway` from the nonfatal retry path. +- Discord final outbound safety filter: + - Added `extensions/discord/src/monitor/reply-safety.ts`. + - `deliverDiscordReply` sanitizes payload text at the final Discord send boundary. + - The filter uses the existing assistant-visible-text sanitizer, strips standalone internal trace/channel lines outside code fences, drops pure-internal text-only payloads, and preserves media-only payloads. + +## Why This Should Work + +- The startup window no longer allows Control UI `sessions.list` polling to compete with sidecar/channel readiness through the expensive session listing path. +- Discord READY timeout no longer escalates a transient event-loop stall into a thrown startup failure after a single reconnect attempt. +- Approval handler readiness failures are treated as recoverable gateway-readiness deferrals, matching the actual failure mode from the incident. +- Leakage protection is placed at the last Discord send boundary, so upstream mistakes in agent output assembly, commentary routing, or tool-call formatting get one final scrub before front-channel delivery. + +## Modified Files + +- `src/gateway/server-startup-unavailable-methods.ts` +- `src/gateway/operator-approvals-client.ts` +- `src/infra/approval-handler-bootstrap.ts` +- `src/infra/approval-handler-bootstrap.test.ts` +- `src/infra/exec-approval-channel-runtime.ts` +- `src/infra/exec-approval-channel-runtime.test.ts` +- `extensions/discord/src/monitor/provider.lifecycle.ts` +- `extensions/discord/src/monitor/provider.lifecycle.test.ts` +- `extensions/discord/src/monitor/reply-delivery.ts` +- `extensions/discord/src/monitor/reply-delivery.test.ts` +- `extensions/discord/src/monitor/reply-safety.ts` +- `docs/status/openclaw-startup-readiness-and-leak-fix-20260504.md` + +## Validation + +- `node scripts/run-vitest.mjs run --config test/vitest/vitest.extension-discord.config.ts extensions/discord/src/monitor/provider.lifecycle.test.ts extensions/discord/src/monitor/reply-delivery.test.ts` + - Passed: 2 files, 28 tests. +- `OPENCLAW_GATEWAY_PROJECT_SHARDS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts src/gateway/server-methods.control-plane-rate-limit.test.ts` + - Passed: 1 file, 12 tests. +- `node scripts/run-vitest.mjs run --config test/vitest/vitest.infra.config.ts src/infra/approval-handler-bootstrap.test.ts src/infra/exec-approval-channel-runtime.test.ts` + - Passed: 2 files, 30 tests. +- `git diff --check` + - Passed. + +## Acceptance Log Keywords + +- Must stay absent during the 30-60 minute post-deploy startup soak: + - `gateway event loop readiness timeout` + - `discord: gateway was not ready after 15000ms; restarting gateway` + - `discord gateway did not reach READY within 15000ms after restart` + - `sessions.list` with 40 second scale durations + - `exit 78` +- Expected nonterminal readiness retry keyword if Discord is slow to become READY: + - `discord: gateway READY wait timed out after 15000ms; reconnecting with backoff` +- Expected approval bootstrap deferral keyword if Gateway is still starting: + - `native approval handler deferred until gateway readiness recovers` + +## Risks + +- `sessions.list` is temporarily unavailable during startup until sidecars clear startup gating. Control UI must retry retryable `UNAVAILABLE` responses. +- The Discord READY wait can keep reconnecting until stop/abort. If credentials or network are truly broken, operator-visible status remains `startup-not-ready` instead of crashing the Gateway. +- The final outbound scrub intentionally removes standalone internal trace lines. A user-visible reply that literally begins with `analysis:`, `commentary:`, or tool execution labels outside a code fence will be stripped from Discord text. Code-fenced examples are preserved. + +## Rollback + +- Source rollback: `git revert ` from this repo. +- If already deployed, rebuild/reinstall the reverted source using the normal OpenClaw packaging path, then restart the Gateway using the operator's configured service manager. + +## Next Action + +- Deploy this source build to an isolated or production-managed OpenClaw path. +- Run a 30-60 minute startup soak with Control UI open and Discord connected. +- During the soak, watch `/tmp/openclaw/openclaw-2026-05-04.log` or the active daily log for the acceptance keywords above. diff --git a/extensions/discord/src/monitor/provider.lifecycle.test.ts b/extensions/discord/src/monitor/provider.lifecycle.test.ts index 9bacc3a38e3..3bbf2f3c1f0 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.test.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.test.ts @@ -333,7 +333,7 @@ describe("runDiscordGatewayLifecycle", () => { expect(statusSink).toHaveBeenCalledTimes(callCountAfterCleanup); }); - it("restarts the gateway once when startup never reaches READY, then recovers", async () => { + it("reconnects with backoff when startup never reaches READY, then recovers", async () => { vi.useFakeTimers(); try { const { emitter, gateway } = createGatewayHarness(); @@ -347,10 +347,13 @@ describe("runDiscordGatewayLifecycle", () => { const { lifecycleParams, runtimeError, statusSink } = createLifecycleHarness({ gateway }); const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams); - await vi.advanceTimersByTimeAsync(16_500); + await vi.advanceTimersByTimeAsync(18_500); await expect(lifecyclePromise).resolves.toBeUndefined(); expect(runtimeError).toHaveBeenCalledWith( + expect.stringContaining("gateway READY wait timed out after 15000ms"), + ); + expect(runtimeError).not.toHaveBeenCalledWith( expect.stringContaining("gateway was not ready after 15000ms; restarting gateway"), ); expect(gateway.disconnect).toHaveBeenCalledTimes(1); @@ -396,14 +399,14 @@ describe("runDiscordGatewayLifecycle", () => { expect(gateway.connect).toHaveBeenCalledTimes(1); expect(gateway.connect).toHaveBeenCalledWith(false); - await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(3_000); await expect(lifecyclePromise).resolves.toBeUndefined(); } finally { vi.useRealTimers(); } }); - it("fails when startup still is not ready after a restart", async () => { + it("keeps retrying when startup still is not ready after a reconnect", async () => { vi.useFakeTimers(); try { const { emitter, gateway } = createGatewayHarness(); @@ -414,19 +417,17 @@ describe("runDiscordGatewayLifecycle", () => { const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams); lifecyclePromise.catch(() => {}); - await vi.advanceTimersByTimeAsync(31_000); + await vi.advanceTimersByTimeAsync(34_000); - await expect(lifecyclePromise).rejects.toThrow( - "discord gateway did not reach READY within 15000ms after restart", - ); - expect(gateway.disconnect).toHaveBeenCalledTimes(1); - expect(gateway.connect).toHaveBeenCalledTimes(1); + expect(gateway.disconnect).toHaveBeenCalledTimes(2); + expect(gateway.connect).toHaveBeenCalledTimes(2); expect(gateway.connect).toHaveBeenCalledWith(false); - expectLifecycleCleanup({ - threadStop, - waitCalls: 0, - gatewaySupervisor, - }); + expect(waitForDiscordGatewayStopMock).not.toHaveBeenCalled(); + + gateway.isConnected = true; + await vi.advanceTimersByTimeAsync(2_500); + await expect(lifecyclePromise).resolves.toBeUndefined(); + expectLifecycleCleanup({ threadStop, waitCalls: 1, gatewaySupervisor }); } finally { vi.useRealTimers(); } diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts index af7223c4da0..9bad4270702 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -25,6 +25,7 @@ const MAX_DISCORD_GATEWAY_READY_TIMEOUT_MS = 120_000; const DISCORD_GATEWAY_READY_TIMEOUT_ENV = "OPENCLAW_DISCORD_READY_TIMEOUT_MS"; const DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_ENV = "OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS"; const DISCORD_GATEWAY_READY_POLL_MS = 250; +const DISCORD_GATEWAY_READY_RETRY_BACKOFF_MS = 2_000; const DISCORD_GATEWAY_STARTUP_DISCONNECT_DRAIN_TIMEOUT_MS = 5_000; const DISCORD_GATEWAY_STARTUP_TERMINATE_CLOSE_TIMEOUT_MS = 1_000; const DISCORD_GATEWAY_TRANSPORT_ACTIVITY_STATUS_MIN_INTERVAL_MS = 30_000; @@ -355,41 +356,50 @@ async function waitForGatewayReady(params: { return "stopped"; }; - const firstAttempt = await waitUntilReady(); - if (firstAttempt !== "timeout") { - return; - } if (!params.gateway) { - throw new Error(`discord gateway did not reach READY within ${params.readyTimeoutMs}ms`); - } - - const restartAt = Date.now(); - params.runtime.error?.( - danger(`discord: gateway was not ready after ${params.readyTimeoutMs}ms; restarting gateway`), - ); - params.pushStatus?.({ - connected: false, - lastEventAt: restartAt, - lastDisconnect: { - at: restartAt, - error: "startup-not-ready", - }, - lastError: "startup-not-ready", - }); - if (params.abortSignal?.aborted) { + const attempt = await waitUntilReady(); + if (attempt === "timeout") { + throw new Error(`discord gateway did not reach READY within ${params.readyTimeoutMs}ms`); + } return; } - await params.beforeRestart?.(); - await restartGatewayAfterReadyTimeout({ - gateway: params.gateway, - abortSignal: params.abortSignal, - runtime: params.runtime, - }); - if ((await waitUntilReady()) === "timeout") { - throw new Error( - `discord gateway did not reach READY within ${params.readyTimeoutMs}ms after restart`, + let attempt = 0; + while (!params.abortSignal?.aborted) { + const result = await waitUntilReady(); + if (result !== "timeout") { + return; + } + + attempt += 1; + const restartAt = Date.now(); + params.runtime.error?.( + danger( + `discord: gateway READY wait timed out after ${params.readyTimeoutMs}ms; reconnecting with backoff (attempt ${attempt})`, + ), ); + params.pushStatus?.({ + connected: false, + lastEventAt: restartAt, + lastDisconnect: { + at: restartAt, + error: "startup-not-ready", + }, + lastError: "startup-not-ready", + }); + await params.beforeRestart?.(); + await restartGatewayAfterReadyTimeout({ + gateway: params.gateway, + abortSignal: params.abortSignal, + runtime: params.runtime, + }); + if (params.abortSignal?.aborted) { + return; + } + await new Promise((resolve) => { + const timeout = setTimeout(resolve, DISCORD_GATEWAY_READY_RETRY_BACKOFF_MS); + timeout.unref?.(); + }); } } diff --git a/extensions/discord/src/monitor/reply-delivery.test.ts b/extensions/discord/src/monitor/reply-delivery.test.ts index 1fb35a1c4cf..7192b1971e3 100644 --- a/extensions/discord/src/monitor/reply-delivery.test.ts +++ b/extensions/discord/src/monitor/reply-delivery.test.ts @@ -105,6 +105,76 @@ describe("deliverDiscordReply", () => { ); }); + it("strips internal execution trace lines at the final Discord send boundary", async () => { + await deliverDiscordReply({ + replies: [ + { + text: [ + "📊 Session Status: current", + "🛠️ Exec: run git status", + "📖 Read: lines 1-40 from secret.md", + "Visible reply.", + ].join("\n"), + }, + ], + target: "channel:101", + token: "token", + accountId: "default", + runtime, + cfg, + textLimit: 2000, + }); + + expect(deliverOutboundPayloadsMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ text: "Visible reply." }], + }), + ); + }); + + it("drops pure internal trace text while preserving media-only delivery", async () => { + await deliverDiscordReply({ + replies: [ + { + text: "commentary: calling tool\nanalysis: inspect private state", + mediaUrl: "https://example.com/result.png", + }, + ], + target: "channel:101", + token: "token", + accountId: "default", + runtime, + cfg, + textLimit: 2000, + }); + + expect(deliverOutboundPayloadsMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ mediaUrl: "https://example.com/result.png", text: undefined }], + }), + ); + }); + + it("does not strip ordinary code-fenced examples of tool-call labels", async () => { + const text = ["Example:", "```", "🛠️ Exec: run ls", "```"].join("\n"); + + await deliverDiscordReply({ + replies: [{ text }], + target: "channel:101", + token: "token", + accountId: "default", + runtime, + cfg, + textLimit: 2000, + }); + + expect(deliverOutboundPayloadsMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ text }], + }), + ); + }); + it("passes resolved Discord formatting options as explicit delivery options", async () => { const baseCfg = { channels: { diff --git a/extensions/discord/src/monitor/reply-delivery.ts b/extensions/discord/src/monitor/reply-delivery.ts index 6703e9e44f2..c1b34574c91 100644 --- a/extensions/discord/src/monitor/reply-delivery.ts +++ b/extensions/discord/src/monitor/reply-delivery.ts @@ -18,6 +18,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { RequestClient } from "../internal/discord.js"; import { sendMessageDiscord, sendVoiceMessageDiscord } from "../send.js"; +import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js"; export type DiscordThreadBindingLookupRecord = { accountId: string; @@ -175,13 +176,17 @@ export async function deliverDiscordReply(params: { void params.runtime; const delivery = resolveDiscordDeliveryOptions(params); + const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies); + if (payloads.length === 0) { + return; + } await deliverOutboundPayloads({ cfg: params.cfg, channel: "discord", to: delivery.to, accountId: params.accountId, - payloads: params.replies, + payloads, replyToId: normalizeOptionalString(params.replyToId), replyToMode: delivery.replyToMode, formatting: delivery.formatting, diff --git a/extensions/discord/src/monitor/reply-safety.ts b/extensions/discord/src/monitor/reply-safety.ts new file mode 100644 index 00000000000..d21079e7371 --- /dev/null +++ b/extensions/discord/src/monitor/reply-safety.ts @@ -0,0 +1,64 @@ +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; +import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-runtime"; + +const DISCORD_INTERNAL_TRACE_LINE_RE = + /^(?:>\s*)?(?:(?:📊|🛠️|📖|📝|🔍|🔎|⚙️)\s*)?(?:Session Status|Exec|Read|Edit|Write|Patch|Search|Open|Click|Find|Screenshot|Update Plan|Tool Call|Tool Result|Function Call|Shell|Command)\s*:/i; +const DISCORD_INTERNAL_CHANNEL_LINE_RE = + /^(?:>\s*)?(?:analysis|commentary|tool[-_ ]?call|tool[-_ ]?result|function[-_ ]?call|thinking|reasoning)\s*[:=]/i; + +function stripDiscordInternalTraceLines(text: string): string { + let inFence = false; + const kept: string[] = []; + for (const line of text.split(/\r?\n/)) { + if (/^\s*```/.test(line)) { + inFence = !inFence; + kept.push(line); + continue; + } + if (!inFence) { + const trimmed = line.trim(); + if ( + DISCORD_INTERNAL_TRACE_LINE_RE.test(trimmed) || + DISCORD_INTERNAL_CHANNEL_LINE_RE.test(trimmed) + ) { + continue; + } + } + kept.push(line); + } + return kept.join("\n"); +} + +function collapseExcessBlankLines(text: string): string { + return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n"); +} + +export function sanitizeDiscordFrontChannelText(text: string): string { + const withoutAssistantScaffolding = sanitizeAssistantVisibleText(text); + const withoutTraceLines = stripDiscordInternalTraceLines(withoutAssistantScaffolding); + return collapseExcessBlankLines(withoutTraceLines).trim(); +} + +export function sanitizeDiscordFrontChannelReplyPayloads( + payloads: readonly ReplyPayload[], +): ReplyPayload[] { + const safePayloads: ReplyPayload[] = []; + for (const payload of payloads) { + const originalParts = resolveSendableOutboundReplyParts(payload); + const safeText = + typeof payload.text === "string" + ? sanitizeDiscordFrontChannelText(payload.text) + : payload.text; + const nextPayload = + safeText === payload.text + ? payload + : ({ ...payload, text: safeText || undefined } as ReplyPayload); + const nextParts = resolveSendableOutboundReplyParts(nextPayload); + if (!nextParts.hasText && !originalParts.hasMedia) { + continue; + } + safePayloads.push(nextPayload); + } + return safePayloads; +} diff --git a/src/gateway/operator-approvals-client.ts b/src/gateway/operator-approvals-client.ts index 5e83f96ac0b..dcdeba0ede6 100644 --- a/src/gateway/operator-approvals-client.ts +++ b/src/gateway/operator-approvals-client.ts @@ -118,7 +118,11 @@ export async function withOperatorApprovalsGatewayClient( clientOptions: { preauthHandshakeTimeoutMs: params.config.gateway?.handshakeTimeoutMs }, }); if (!readiness.ready) { - throw new Error("gateway event loop readiness timeout"); + throw new Error( + readiness.aborted + ? "gateway approval client start aborted before readiness" + : "gateway readiness unavailable before approval client start", + ); } await ready; return await run(gatewayClient); diff --git a/src/gateway/server-startup-unavailable-methods.ts b/src/gateway/server-startup-unavailable-methods.ts index 294b980486a..0e162e7f75e 100644 --- a/src/gateway/server-startup-unavailable-methods.ts +++ b/src/gateway/server-startup-unavailable-methods.ts @@ -2,6 +2,7 @@ export const STARTUP_UNAVAILABLE_GATEWAY_METHODS = [ "agent.wait", "chat.history", "models.list", + "sessions.list", "sessions.abort", "sessions.create", "sessions.send", diff --git a/src/infra/approval-handler-bootstrap.test.ts b/src/infra/approval-handler-bootstrap.test.ts index 4b4aa42d2c0..307b4a7bea0 100644 --- a/src/infra/approval-handler-bootstrap.test.ts +++ b/src/infra/approval-handler-bootstrap.test.ts @@ -232,6 +232,55 @@ describe("startChannelApprovalHandlerBootstrap", () => { await cleanup(); }); + it("defers retryable gateway readiness startup failures without terminal error logs", async () => { + vi.useFakeTimers(); + const channelRuntime = createRuntimeChannel(); + const readinessError = new Error("gateway event loop readiness timeout"); + const start = vi.fn().mockRejectedValueOnce(readinessError).mockResolvedValueOnce(undefined); + const stop = vi.fn().mockResolvedValue(undefined); + const logger = { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + child: vi.fn(), + isEnabled: vi.fn().mockReturnValue(true), + isVerboseEnabled: vi.fn().mockReturnValue(false), + verbose: vi.fn(), + }; + createChannelApprovalHandlerFromCapability + .mockResolvedValueOnce({ start, stop }) + .mockResolvedValueOnce({ start, stop }); + + const cleanup = await startTestBootstrap({ channelRuntime, logger }); + + registerApprovalContext(channelRuntime); + await flushTransitions(); + + expect(start).toHaveBeenCalledTimes(1); + await flushTransitions(); + expect(logger.error).not.toHaveBeenCalledWith( + expect.stringContaining("failed to start native approval handler"), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("native approval handler deferred until gateway readiness recovers"), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("gateway readiness unavailable before approval handler start"), + ); + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining("gateway event loop readiness timeout"), + ); + + await vi.advanceTimersByTimeAsync(1_000); + await flushTransitions(); + + expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalledTimes(2); + expect(start).toHaveBeenCalledTimes(2); + + await cleanup(); + }); + it("does not retry terminal native approval startup failures", async () => { vi.useFakeTimers(); const channelRuntime = createRuntimeChannel(); diff --git a/src/infra/approval-handler-bootstrap.ts b/src/infra/approval-handler-bootstrap.ts index a1ccce4424a..34f7663e697 100644 --- a/src/infra/approval-handler-bootstrap.ts +++ b/src/infra/approval-handler-bootstrap.ts @@ -17,6 +17,28 @@ import { isExecApprovalChannelRuntimeTerminalStartError } from "./exec-approval- type ApprovalBootstrapHandler = ChannelApprovalHandler; const APPROVAL_HANDLER_BOOTSTRAP_RETRY_MS = 1_000; +function isRetryableApprovalBootstrapStartError(error: unknown): boolean { + const message = String(error); + return ( + message.includes("gateway readiness unavailable before approval client start") || + message.includes("gateway approval client start aborted before readiness") || + message.includes("gateway readiness unavailable before exec approval runtime start") || + message.includes("gateway approval runtime start aborted before readiness") || + message.includes("gateway event loop readiness timeout") || + message.includes("gateway starting") || + message.includes("code=1013") || + message.includes("close code 1013") + ); +} + +function formatRetryableApprovalBootstrapStartError(error: unknown): string { + const message = String(error); + if (message.includes("gateway event loop readiness timeout")) { + return "gateway readiness unavailable before approval handler start"; + } + return message; +} + export async function startChannelApprovalHandlerBootstrap(params: { plugin: Pick; cfg: OpenClawConfig; @@ -122,6 +144,13 @@ export async function startChannelApprovalHandlerBootstrap(params: { logger.error(`native approval handler disabled: ${String(error)}`); return; } + if (isRetryableApprovalBootstrapStartError(error)) { + logger.warn( + `native approval handler deferred until gateway readiness recovers: ${formatRetryableApprovalBootstrapStartError(error)}`, + ); + scheduleRetryForContext(context, generation); + return; + } logger.error(`failed to start native approval handler: ${String(error)}`); scheduleRetryForContext(context, generation); } diff --git a/src/infra/exec-approval-channel-runtime.test.ts b/src/infra/exec-approval-channel-runtime.test.ts index b15ff27238a..181eea169cb 100644 --- a/src/infra/exec-approval-channel-runtime.test.ts +++ b/src/infra/exec-approval-channel-runtime.test.ts @@ -291,7 +291,9 @@ describe("createExecApprovalChannelRuntime", () => { finalizeResolved: async () => undefined, }); - await expect(runtime.start()).rejects.toThrow("gateway event loop readiness timeout"); + await expect(runtime.start()).rejects.toThrow( + "gateway readiness unavailable before exec approval runtime start", + ); expect(mockGatewayClientStarts).not.toHaveBeenCalled(); expect(mockGatewayClientStops).toHaveBeenCalledTimes(1); diff --git a/src/infra/exec-approval-channel-runtime.ts b/src/infra/exec-approval-channel-runtime.ts index 3c0cd134396..0c313eafc66 100644 --- a/src/infra/exec-approval-channel-runtime.ts +++ b/src/infra/exec-approval-channel-runtime.ts @@ -365,7 +365,11 @@ export function createExecApprovalChannelRuntime< }, }); if (!readiness.ready) { - throw new Error("gateway event loop readiness timeout"); + throw new Error( + readiness.aborted + ? "gateway approval runtime start aborted before readiness" + : "gateway readiness unavailable before exec approval runtime start", + ); } await ready; if (stopClientIfInactive(client)) { From 5572c8137cb3deac71cf05174474d4a03c276d4a Mon Sep 17 00:00:00 2001 From: Satoshi Date: Mon, 4 May 2026 20:49:38 +0100 Subject: [PATCH 063/107] fix(discord): preserve non-text payloads in reply scrub --- CHANGELOG.md | 1 + .../src/monitor/reply-delivery.test.ts | 74 +++++++++++++++++++ .../discord/src/monitor/reply-safety.ts | 29 +++++++- 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98500da708f..afea0101f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai - CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc. - Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc. - Plugins/update: treat OpenClaw CalVer correction versions like `2026.5.3-1` as satisfying base plugin API ranges, so correction builds can install plugins that require the base runtime API. Fixes #77293. (#77450) Thanks @p3nchan. +- Discord/reply-safety: preserve component-only channel payloads (for example `channelData.discord.components` and presentation/interactive-only replies) when final text scrubbing removes all text, so Discord still delivers non-text outbound payloads instead of dropping them. (#77478) Thanks @NikolaFC. - fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987. - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. - Active Memory: send a bounded latest-message search query to the recall worker so channel/runtime metadata does not become the memory search string. Fixes #65309. Thanks @joeykrug, @westley3601, @pimenov, and @tasi333. diff --git a/extensions/discord/src/monitor/reply-delivery.test.ts b/extensions/discord/src/monitor/reply-delivery.test.ts index 7192b1971e3..17fd63f6e39 100644 --- a/extensions/discord/src/monitor/reply-delivery.test.ts +++ b/extensions/discord/src/monitor/reply-delivery.test.ts @@ -155,6 +155,80 @@ describe("deliverDiscordReply", () => { ); }); + it("preserves component-only channelData payloads when text scrubs empty", async () => { + const channelData = { + discord: { + components: [ + { + type: 1, + components: [ + { + type: 2, + style: 1, + label: "Open", + custom_id: "open", + }, + ], + }, + ], + }, + }; + + await deliverDiscordReply({ + replies: [ + { + text: "analysis: internal only", + channelData, + }, + ], + target: "channel:101", + token: "token", + accountId: "default", + runtime, + cfg, + textLimit: 2000, + }); + + expect(deliverOutboundPayloadsMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ channelData, text: undefined }], + }), + ); + }); + + it("preserves presentation-only payloads when text scrubs empty", async () => { + const presentation = { + title: "Action required", + blocks: [ + { + type: "buttons" as const, + buttons: [{ label: "Approve", value: "approve", style: "primary" as const }], + }, + ], + }; + + await deliverDiscordReply({ + replies: [ + { + text: "commentary: hidden", + presentation, + }, + ], + target: "channel:101", + token: "token", + accountId: "default", + runtime, + cfg, + textLimit: 2000, + }); + + expect(deliverOutboundPayloadsMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ presentation, text: undefined }], + }), + ); + }); + it("does not strip ordinary code-fenced examples of tool-call labels", async () => { const text = ["Example:", "```", "🛠️ Exec: run ls", "```"].join("\n"); diff --git a/extensions/discord/src/monitor/reply-safety.ts b/extensions/discord/src/monitor/reply-safety.ts index d21079e7371..0cb0d6e7610 100644 --- a/extensions/discord/src/monitor/reply-safety.ts +++ b/extensions/discord/src/monitor/reply-safety.ts @@ -7,6 +7,32 @@ const DISCORD_INTERNAL_TRACE_LINE_RE = const DISCORD_INTERNAL_CHANNEL_LINE_RE = /^(?:>\s*)?(?:analysis|commentary|tool[-_ ]?call|tool[-_ ]?result|function[-_ ]?call|thinking|reasoning)\s*[:=]/i; +function hasNonEmptyRecord(value: unknown): value is Record { + return Boolean( + value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0, + ); +} + +function hasInteractiveOrPresentationBlocks(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const record = value as Record; + if (typeof record.title === "string" && record.title.trim().length > 0) { + return true; + } + return Array.isArray(record.blocks) && record.blocks.length > 0; +} + +function hasNonTextReplyPayloadContent(payload: ReplyPayload): boolean { + return ( + payload.audioAsVoice === true || + hasNonEmptyRecord(payload.channelData) || + hasInteractiveOrPresentationBlocks(payload.interactive) || + hasInteractiveOrPresentationBlocks(payload.presentation) + ); +} + function stripDiscordInternalTraceLines(text: string): string { let inFence = false; const kept: string[] = []; @@ -45,7 +71,6 @@ export function sanitizeDiscordFrontChannelReplyPayloads( ): ReplyPayload[] { const safePayloads: ReplyPayload[] = []; for (const payload of payloads) { - const originalParts = resolveSendableOutboundReplyParts(payload); const safeText = typeof payload.text === "string" ? sanitizeDiscordFrontChannelText(payload.text) @@ -55,7 +80,7 @@ export function sanitizeDiscordFrontChannelReplyPayloads( ? payload : ({ ...payload, text: safeText || undefined } as ReplyPayload); const nextParts = resolveSendableOutboundReplyParts(nextPayload); - if (!nextParts.hasText && !originalParts.hasMedia) { + if (!nextParts.hasContent && !hasNonTextReplyPayloadContent(nextPayload)) { continue; } safePayloads.push(nextPayload); From 31edc4ee1add3636ce7f4b80678277f8a4779806 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:00:53 +0100 Subject: [PATCH 064/107] fix: clean up startup readiness PR docs --- CHANGELOG.md | 2 +- ...startup-readiness-and-leak-fix-20260504.md | 93 ------------------- 2 files changed, 1 insertion(+), 94 deletions(-) delete mode 100644 docs/status/openclaw-startup-readiness-and-leak-fix-20260504.md diff --git a/CHANGELOG.md b/CHANGELOG.md index afea0101f58..47512b23ff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,7 @@ Docs: https://docs.openclaw.ai - CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc. - Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc. - Plugins/update: treat OpenClaw CalVer correction versions like `2026.5.3-1` as satisfying base plugin API ranges, so correction builds can install plugins that require the base runtime API. Fixes #77293. (#77450) Thanks @p3nchan. -- Discord/reply-safety: preserve component-only channel payloads (for example `channelData.discord.components` and presentation/interactive-only replies) when final text scrubbing removes all text, so Discord still delivers non-text outbound payloads instead of dropping them. (#77478) Thanks @NikolaFC. +- Discord/Gateway startup: retry Discord READY waits with backoff, defer startup `sessions.list` and native approval readiness failures until sidecars recover, and preserve component-only Discord payloads when final reply scrubbing removes all text. (#77478) Thanks @NikolaFC. - fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987. - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. - Active Memory: send a bounded latest-message search query to the recall worker so channel/runtime metadata does not become the memory search string. Fixes #65309. Thanks @joeykrug, @westley3601, @pimenov, and @tasi333. diff --git a/docs/status/openclaw-startup-readiness-and-leak-fix-20260504.md b/docs/status/openclaw-startup-readiness-and-leak-fix-20260504.md deleted file mode 100644 index e996cfd1a17..00000000000 --- a/docs/status/openclaw-startup-readiness-and-leak-fix-20260504.md +++ /dev/null @@ -1,93 +0,0 @@ -# OpenClaw Startup Readiness And Leak Fix - 2026-05-04 - -## Current Truth - -- Incident inputs confirmed Discord front-channel leakage of internal execution/commentary-like traces and Gateway startup instability in the same window. -- Observed bad startup keywords from local operator evidence: - - `gateway event loop readiness timeout` - - `discord: gateway was not ready after 15000ms; restarting gateway` - - `sessions.list` requests around 40 seconds - - `exit 78` with systemd `RestartPreventExitStatus=78` -- This source fix addresses the startup terminal-fail path and Discord final outbound leakage guard. It does not restart any running Gateway by itself. - -## Code Changes - -- Startup control-plane load shedding: - - Added `sessions.list` to `STARTUP_UNAVAILABLE_GATEWAY_METHODS`. - - During sidecar startup, Gateway now returns retryable startup `UNAVAILABLE` for `sessions.list` instead of dispatching the costly session scan path. -- Native approval bootstrap readiness handling: - - Changed approval-client readiness failure text away from the production incident keyword. - - Changed exec-approval runtime readiness failure text away from the production incident keyword. - - Classified gateway readiness/startup close errors as retryable bootstrap deferrals. - - Normalized legacy readiness-timeout errors before logging retry deferrals, so old incident keywords do not reappear in native-approval retry logs. - - Native approval handler startup now warns and retries instead of emitting the old terminal-looking `failed to start native approval handler` path for readiness-only failures. -- Discord gateway READY wait: - - Replaced the one-restart-then-throw startup behavior with reconnect plus 2 second backoff until READY, stop, or abort. - - Removed the old log string `gateway was not ready after 15000ms; restarting gateway` from the nonfatal retry path. -- Discord final outbound safety filter: - - Added `extensions/discord/src/monitor/reply-safety.ts`. - - `deliverDiscordReply` sanitizes payload text at the final Discord send boundary. - - The filter uses the existing assistant-visible-text sanitizer, strips standalone internal trace/channel lines outside code fences, drops pure-internal text-only payloads, and preserves media-only payloads. - -## Why This Should Work - -- The startup window no longer allows Control UI `sessions.list` polling to compete with sidecar/channel readiness through the expensive session listing path. -- Discord READY timeout no longer escalates a transient event-loop stall into a thrown startup failure after a single reconnect attempt. -- Approval handler readiness failures are treated as recoverable gateway-readiness deferrals, matching the actual failure mode from the incident. -- Leakage protection is placed at the last Discord send boundary, so upstream mistakes in agent output assembly, commentary routing, or tool-call formatting get one final scrub before front-channel delivery. - -## Modified Files - -- `src/gateway/server-startup-unavailable-methods.ts` -- `src/gateway/operator-approvals-client.ts` -- `src/infra/approval-handler-bootstrap.ts` -- `src/infra/approval-handler-bootstrap.test.ts` -- `src/infra/exec-approval-channel-runtime.ts` -- `src/infra/exec-approval-channel-runtime.test.ts` -- `extensions/discord/src/monitor/provider.lifecycle.ts` -- `extensions/discord/src/monitor/provider.lifecycle.test.ts` -- `extensions/discord/src/monitor/reply-delivery.ts` -- `extensions/discord/src/monitor/reply-delivery.test.ts` -- `extensions/discord/src/monitor/reply-safety.ts` -- `docs/status/openclaw-startup-readiness-and-leak-fix-20260504.md` - -## Validation - -- `node scripts/run-vitest.mjs run --config test/vitest/vitest.extension-discord.config.ts extensions/discord/src/monitor/provider.lifecycle.test.ts extensions/discord/src/monitor/reply-delivery.test.ts` - - Passed: 2 files, 28 tests. -- `OPENCLAW_GATEWAY_PROJECT_SHARDS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts src/gateway/server-methods.control-plane-rate-limit.test.ts` - - Passed: 1 file, 12 tests. -- `node scripts/run-vitest.mjs run --config test/vitest/vitest.infra.config.ts src/infra/approval-handler-bootstrap.test.ts src/infra/exec-approval-channel-runtime.test.ts` - - Passed: 2 files, 30 tests. -- `git diff --check` - - Passed. - -## Acceptance Log Keywords - -- Must stay absent during the 30-60 minute post-deploy startup soak: - - `gateway event loop readiness timeout` - - `discord: gateway was not ready after 15000ms; restarting gateway` - - `discord gateway did not reach READY within 15000ms after restart` - - `sessions.list` with 40 second scale durations - - `exit 78` -- Expected nonterminal readiness retry keyword if Discord is slow to become READY: - - `discord: gateway READY wait timed out after 15000ms; reconnecting with backoff` -- Expected approval bootstrap deferral keyword if Gateway is still starting: - - `native approval handler deferred until gateway readiness recovers` - -## Risks - -- `sessions.list` is temporarily unavailable during startup until sidecars clear startup gating. Control UI must retry retryable `UNAVAILABLE` responses. -- The Discord READY wait can keep reconnecting until stop/abort. If credentials or network are truly broken, operator-visible status remains `startup-not-ready` instead of crashing the Gateway. -- The final outbound scrub intentionally removes standalone internal trace lines. A user-visible reply that literally begins with `analysis:`, `commentary:`, or tool execution labels outside a code fence will be stripped from Discord text. Code-fenced examples are preserved. - -## Rollback - -- Source rollback: `git revert ` from this repo. -- If already deployed, rebuild/reinstall the reverted source using the normal OpenClaw packaging path, then restart the Gateway using the operator's configured service manager. - -## Next Action - -- Deploy this source build to an isolated or production-managed OpenClaw path. -- Run a 30-60 minute startup soak with Control UI open and Discord connected. -- During the soak, watch `/tmp/openclaw/openclaw-2026-05-04.log` or the active daily log for the acceptance keywords above. From 343f859b900c22bb3ef50c3019cf13ca599a7560 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:08:31 +0100 Subject: [PATCH 065/107] fix: preserve visible Discord labeled replies --- .../src/monitor/reply-delivery.test.ts | 25 +++++++++++++++++++ .../discord/src/monitor/reply-safety.ts | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/extensions/discord/src/monitor/reply-delivery.test.ts b/extensions/discord/src/monitor/reply-delivery.test.ts index 17fd63f6e39..100d5c46b8a 100644 --- a/extensions/discord/src/monitor/reply-delivery.test.ts +++ b/extensions/discord/src/monitor/reply-delivery.test.ts @@ -249,6 +249,31 @@ describe("deliverDiscordReply", () => { ); }); + it("does not strip ordinary visible labeled lines", async () => { + const text = [ + "Command: restart the gateway", + "Search: check recent Discord logs", + "Open: the channel status page", + "Find: the failing account", + ].join("\n"); + + await deliverDiscordReply({ + replies: [{ text }], + target: "channel:101", + token: "token", + accountId: "default", + runtime, + cfg, + textLimit: 2000, + }); + + expect(deliverOutboundPayloadsMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ text }], + }), + ); + }); + it("passes resolved Discord formatting options as explicit delivery options", async () => { const baseCfg = { channels: { diff --git a/extensions/discord/src/monitor/reply-safety.ts b/extensions/discord/src/monitor/reply-safety.ts index 0cb0d6e7610..c4fbd389f8e 100644 --- a/extensions/discord/src/monitor/reply-safety.ts +++ b/extensions/discord/src/monitor/reply-safety.ts @@ -3,7 +3,7 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-pay import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-runtime"; const DISCORD_INTERNAL_TRACE_LINE_RE = - /^(?:>\s*)?(?:(?:📊|🛠️|📖|📝|🔍|🔎|⚙️)\s*)?(?:Session Status|Exec|Read|Edit|Write|Patch|Search|Open|Click|Find|Screenshot|Update Plan|Tool Call|Tool Result|Function Call|Shell|Command)\s*:/i; + /^(?:>\s*)?(?:📊|🛠️|📖|📝|🔍|🔎|⚙️)\s*(?:Session Status|Exec|Read|Edit|Write|Patch|Search|Open|Click|Find|Screenshot|Update Plan|Tool Call|Tool Result|Function Call|Shell|Command)\s*:/i; const DISCORD_INTERNAL_CHANNEL_LINE_RE = /^(?:>\s*)?(?:analysis|commentary|tool[-_ ]?call|tool[-_ ]?result|function[-_ ]?call|thinking|reasoning)\s*[:=]/i; From a7263de2584e1e9434e707843b815627649f53af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:13:32 +0100 Subject: [PATCH 066/107] fix(agents): preserve workspace metadata reuse Pass the resolved agent workspace through hot model refresh paths so workspace-scoped plugin metadata snapshots can be reused. Refs #77519. Refs #77532. --- CHANGELOG.md | 1 + src/agents/btw.test.ts | 7 +++++++ src/agents/btw.ts | 15 +++++++++------ src/agents/pi-embedded-runner/compact.ts | 4 +++- src/agents/pi-embedded-runner/run.ts | 4 +++- src/agents/tools/pdf-tool.test.ts | 16 +++++++++++++++- src/agents/tools/pdf-tool.ts | 5 ++++- 7 files changed, 42 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47512b23ff9..c0688104e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Changes - Dependencies: refresh runtime and provider packages including Pi 0.73.0, ACPX adapters, OpenAI, Anthropic, Slack, and TypeScript native preview, while keeping the Bedrock runtime installer override pinned below the Windows ARM Node 24 npm resolver failure. +- Agents/performance: pass the resolved workspace through BTW, compaction, embedded-run model generation, and PDF model setup so explicit agent-dir model refreshes can reuse the current workspace-scoped plugin metadata snapshot instead of falling back to cold plugin metadata scans. (#77519, #77532) - Plugins/active-memory: skip session-store channel entries that contain `:` when resolving the recall subagent's channel, so QQ c2c agent IDs (e.g. `c2c:10D4F7C2…`) and other scoped conversation IDs do not reach bundled-plugin `dirName` validation and crash the recall run. The same guard already applied to explicit `channelId` params (#76704); this extends it to store-derived channels. (#77396) Thanks @hclsys. - Secrets/external channel contracts: also look in `/dist/` when resolving the `secret-contract-api` sidecar, so npm-published externalized channel plugins (e.g. `@openclaw/discord` since 2026.5.2) whose compiled artifacts live under `dist/` actually contribute their channel SecretRef contracts to the runtime snapshot. Without this, env-backed `channels.discord.token` SecretRefs silently failed to resolve at gateway start on 2026.5.3, leaving the channel `not configured` even though #76449 had landed the generic external-contract loader. Thanks @mogglemoss. - Models/auth: add `openclaw models auth list [--provider ] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments” path. Thanks @vincentkoc. diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index e83b3f179d3..54feec553e3 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -417,6 +417,13 @@ describe("runBtwSideQuestion", () => { const result = await runSideQuestion(); expect(result).toEqual({ text: "Final answer." }); + expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledWith( + expect.any(Object), + DEFAULT_AGENT_DIR, + { + workspaceDir: "/tmp/workspace", + }, + ); }); it("applies provider runtime auth before streaming github-copilot BTW questions", async () => { diff --git a/src/agents/btw.ts b/src/agents/btw.ts index 43bb5e0f444..4f8512ae3b5 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -216,6 +216,7 @@ async function resolveRuntimeModel(params: { provider: string; model: string; agentDir: string; + workspaceDir?: string; sessionEntry?: StoredSessionEntry; sessionStore?: Record; sessionKey?: string; @@ -226,7 +227,8 @@ async function resolveRuntimeModel(params: { authProfileId?: string; authProfileIdSource?: "auto" | "user"; }> { - await ensureOpenClawModelsJson(params.cfg, params.agentDir); + const modelsOptions = params.workspaceDir ? { workspaceDir: params.workspaceDir } : undefined; + await ensureOpenClawModelsJson(params.cfg, params.agentDir, modelsOptions); const authStorage = discoverAuthStorage(params.agentDir); const modelRegistry = discoverModels(authStorage, params.agentDir); const model = resolveModelWithRegistry({ @@ -319,11 +321,17 @@ export async function runBtwSideQuestion( throw new Error("No active session context."); } + const sessionAgentId = resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: params.cfg, + }); + const workspaceDir = resolveAgentWorkspaceDir(params.cfg, sessionAgentId); const { model, authProfileId } = await resolveRuntimeModel({ cfg: params.cfg, provider: params.provider, model: params.model, agentDir: params.agentDir, + workspaceDir, sessionEntry: params.sessionEntry, sessionStore: params.sessionStore, sessionKey: params.sessionKey, @@ -341,11 +349,6 @@ export async function runBtwSideQuestion( apiKeyInfo.mode === "aws-sdk" && !apiKeyInfo.apiKey ? undefined : requireApiKey(apiKeyInfo, model.provider); - const sessionAgentId = resolveSessionAgentId({ - sessionKey: params.sessionKey, - config: params.cfg, - }); - const workspaceDir = resolveAgentWorkspaceDir(params.cfg, sessionAgentId); if (apiKey) { const preparedAuth = await prepareProviderRuntimeAuth({ provider: model.provider, diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 2c63232c19c..9917ddc9361 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -482,7 +482,9 @@ async function compactEmbeddedPiSessionDirectOnce( }; }; const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); - await ensureOpenClawModelsJson(params.config, agentDir); + await ensureOpenClawModelsJson(params.config, agentDir, { + workspaceDir: resolvedWorkspace, + }); const { model, error, authStorage, modelRegistry } = await resolveModelAsync( provider, modelId, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 5fc93d36a66..8e7126bf32d 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -503,7 +503,9 @@ export async function runEmbeddedPiAgent( dynamicModelResolution.model || pluginHarnessOwnsTransport ? dynamicModelResolution : await (async () => { - await ensureOpenClawModelsJson(params.config, agentDir); + await ensureOpenClawModelsJson(params.config, agentDir, { + workspaceDir: resolvedWorkspace, + }); return await resolveModelAsync(provider, modelId, agentDir, params.config); })(); const { model, error, authStorage, modelRegistry } = modelResolution; diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index eee305d0186..77a30edd71e 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -394,17 +394,31 @@ describe("createPdfTool", () => { it("uses native PDF path without eager extraction", async () => { await withTempPdfAgentDir(async (agentDir) => { + const workspaceDir = path.join(agentDir, "workspace"); await stubPdfToolInfra(agentDir, { provider: "anthropic", input: ["text", "document"] }); vi.spyOn(pdfNativeProviders, "anthropicAnalyzePdf").mockResolvedValue("native summary"); const extractSpy = vi.spyOn(pdfExtractModule, "extractPdfContent"); const cfg = withPdfModel(ANTHROPIC_PDF_MODEL); - const tool = requirePdfTool((await loadCreatePdfTool())({ config: cfg, agentDir })); + const tool = requirePdfTool( + (await loadCreatePdfTool())({ config: cfg, agentDir, workspaceDir }), + ); const result = await tool.execute("t1", { prompt: "summarize", pdf: "/tmp/doc.pdf", }); + expect(modelsConfig.ensureOpenClawModelsJson).toHaveBeenCalledWith( + expect.objectContaining({ + agents: expect.objectContaining({ + defaults: expect.objectContaining({ + pdfModel: { primary: ANTHROPIC_PDF_MODEL }, + }), + }), + }), + agentDir, + { workspaceDir }, + ); expect(extractSpy).not.toHaveBeenCalled(); expect(result).toMatchObject({ content: [{ type: "text", text: "native summary" }], diff --git a/src/agents/tools/pdf-tool.ts b/src/agents/tools/pdf-tool.ts index e848f9f7e83..65fbac9af24 100644 --- a/src/agents/tools/pdf-tool.ts +++ b/src/agents/tools/pdf-tool.ts @@ -127,6 +127,7 @@ type PdfSandboxConfig = { async function runPdfPrompt(params: { cfg?: OpenClawConfig; agentDir: string; + workspaceDir?: string; pdfModelConfig: ImageModelConfig; modelOverride?: string; prompt: string; @@ -142,7 +143,8 @@ async function runPdfPrompt(params: { }> { const effectiveCfg = applyImageModelConfigDefaults(params.cfg, params.pdfModelConfig); - await ensureOpenClawModelsJson(effectiveCfg, params.agentDir); + const modelsOptions = params.workspaceDir ? { workspaceDir: params.workspaceDir } : undefined; + await ensureOpenClawModelsJson(effectiveCfg, params.agentDir, modelsOptions); const authStorage = discoverAuthStorage(params.agentDir); const modelRegistry = discoverModels(authStorage, params.agentDir); @@ -482,6 +484,7 @@ export function createPdfTool(options?: { const result = await runPdfPrompt({ cfg: options?.config, agentDir, + ...(options?.workspaceDir ? { workspaceDir: options.workspaceDir } : {}), pdfModelConfig, modelOverride, prompt: promptRaw, From 683d892eede2cd296b5012c6d27c04f094d78550 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:13:58 +0100 Subject: [PATCH 067/107] test: make global install shell test portable --- src/infra/update-global.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index 20c401162f9..e399aba43ac 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -1,3 +1,4 @@ +import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { bundledDistPluginFile } from "openclaw/plugin-sdk/test-fixtures"; @@ -152,6 +153,9 @@ describe("update global helpers", () => { it("uses an absolute POSIX script shell for npm lifecycle scripts during global installs", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + const existsSyncSpy = vi + .spyOn(fsSync, "existsSync") + .mockImplementation((candidate) => candidate === "/bin/sh"); try { await expect( createGlobalInstallEnv({ @@ -163,6 +167,7 @@ describe("update global helpers", () => { NPM_CONFIG_SCRIPT_SHELL: "/bin/sh", }); } finally { + existsSyncSpy.mockRestore(); platformSpy.mockRestore(); } }); From 70b1c17ae01ea34cbb928d41fe1248893b019905 Mon Sep 17 00:00:00 2001 From: Brandon Date: Mon, 4 May 2026 18:15:18 -0400 Subject: [PATCH 068/107] fix(config): prefer plugin ids for built-in channel claims Prefer the manifest plugin id when auto-allowlisting configured built-in channel aliases, with regression coverage for alias/id split plugins and same-name official channel plugins. --- CHANGELOG.md | 1 + .../plugin-auto-enable.channels.test.ts | 50 +++++++++++++++++++ src/config/plugin-auto-enable.shared.ts | 2 +- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0688104e1b..30cd4d07e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Dependencies: refresh runtime and provider packages including Pi 0.73.0, ACPX adapters, OpenAI, Anthropic, Slack, and TypeScript native preview, while keeping the Bedrock runtime installer override pinned below the Windows ARM Node 24 npm resolver failure. - Agents/performance: pass the resolved workspace through BTW, compaction, embedded-run model generation, and PDF model setup so explicit agent-dir model refreshes can reuse the current workspace-scoped plugin metadata snapshot instead of falling back to cold plugin metadata scans. (#77519, #77532) +- Config/plugin auto-enable: prefer the claiming plugin manifest id over a built-in channel alias when auto-allowlisting a configured channel, so WeCom/Yuanbao-style aliases resolve to the installed plugin id. Thanks @Beandon13. - Plugins/active-memory: skip session-store channel entries that contain `:` when resolving the recall subagent's channel, so QQ c2c agent IDs (e.g. `c2c:10D4F7C2…`) and other scoped conversation IDs do not reach bundled-plugin `dirName` validation and crash the recall run. The same guard already applied to explicit `channelId` params (#76704); this extends it to store-derived channels. (#77396) Thanks @hclsys. - Secrets/external channel contracts: also look in `/dist/` when resolving the `secret-contract-api` sidecar, so npm-published externalized channel plugins (e.g. `@openclaw/discord` since 2026.5.2) whose compiled artifacts live under `dist/` actually contribute their channel SecretRef contracts to the runtime snapshot. Without this, env-backed `channels.discord.token` SecretRefs silently failed to resolve at gateway start on 2026.5.3, leaving the channel `not configured` even though #76449 had landed the generic external-contract loader. Thanks @mogglemoss. - Models/auth: add `openclaw models auth list [--provider ] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments” path. Thanks @vincentkoc. diff --git a/src/config/plugin-auto-enable.channels.test.ts b/src/config/plugin-auto-enable.channels.test.ts index 52e38e10645..66060bd2ecf 100644 --- a/src/config/plugin-auto-enable.channels.test.ts +++ b/src/config/plugin-auto-enable.channels.test.ts @@ -335,6 +335,56 @@ describe("applyPluginAutoEnable channels", () => { }); describe("preferOver channel prioritization", () => { + it("uses the plugin manifest id for built-in channel claims", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { + wecom: { token: "enabled" }, + }, + plugins: { + allow: ["existing-plugin"], + }, + }, + env: makeIsolatedEnv(), + manifestRegistry: makeRegistry([ + { + id: "wecom-openclaw-plugin", + channels: ["wecom"], + }, + ]), + }); + + expect(result.config.plugins?.entries?.["wecom-openclaw-plugin"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.wecom).toBeUndefined(); + expect(result.config.plugins?.allow).toEqual(["existing-plugin", "wecom-openclaw-plugin"]); + expect(result.changes.join("\n")).toContain("enabled automatically."); + }); + + it("preserves same-name official channel plugin ids", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { + discord: { token: "enabled" }, + }, + plugins: { + allow: ["existing-plugin"], + }, + }, + env: makeIsolatedEnv(), + manifestRegistry: makeRegistry([ + { + id: "discord", + channels: ["discord"], + }, + ]), + }); + + expect(result.config.channels?.discord?.enabled).toBe(true); + expect(result.config.plugins?.entries?.discord).toBeUndefined(); + expect(result.config.plugins?.allow).toEqual(["existing-plugin", "discord"]); + expect(result.changes.join("\n")).toContain("Discord configured, enabled automatically."); + }); + it("uses manifest channel config preferOver metadata for plugin channels", () => { const result = applyPluginAutoEnable({ config: { diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index bb6c125e9bf..745dff0b4c6 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -303,7 +303,7 @@ function collectPluginIdsForConfiguredChannel( if (preferredIds.size > 0) { return [...preferredIds].toSorted((left, right) => left.localeCompare(right)); } - return [builtInId ?? claims[0]?.plugin.id ?? normalizedChannelId]; + return [claims[0]?.plugin.id ?? builtInId ?? normalizedChannelId]; } function collectConfiguredChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] { From e091d912ceb245b41f6b55499baf0504056d8338 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 15:14:11 -0700 Subject: [PATCH 069/107] fix(model): guide runtime allowlist repairs --- docs/concepts/models.md | 5 +++- docs/help/faq-models.md | 5 +++- .../directive-handling.model-selection.ts | 2 ++ .../reply/directive-handling.model.test.ts | 16 ++++++++++ .../reply/directive-handling.model.ts | 1 + .../reply/model-selection-directive.ts | 29 ++++++++++++++++++- 6 files changed, 55 insertions(+), 3 deletions(-) diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 4b14320eeee..62d5bf07a70 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -119,7 +119,8 @@ openclaw config set agents.defaults.models '{"openai/gpt-5.4":{}}' --strict-json If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and for session overrides. When a user selects a model that isn't in that allowlist, OpenClaw returns: ``` -Model "provider/model" is not allowed. Use /model to list available models. +Model "provider/model" is not allowed. Use /models to list providers, or /models to list models. +Add it with: openclaw config set agents.defaults.models '{"provider/model":{}}' --strict-json --merge ``` @@ -131,6 +132,8 @@ This happens **before** a normal reply is generated, so the message can feel lik +When the rejected command included a runtime override such as `/model openai/gpt-5.5 --runtime codex`, fix the allowlist first, then retry the same `/model ... --runtime ...` command. For native Codex execution, the selected model is still `openai/gpt-5.5`; the `codex` runtime selects the harness and uses Codex auth separately. + For local/GGUF models, store the full provider-prefixed ref in the allowlist, for example `ollama/gemma4:26b`, `lmstudio/Gemma4-26b-a4-it-gguf`, or the exact provider/model shown by `openclaw models list --provider `. diff --git a/docs/help/faq-models.md b/docs/help/faq-models.md index c2de0dc49ff..e308029a98f 100644 --- a/docs/help/faq-models.md +++ b/docs/help/faq-models.md @@ -191,11 +191,14 @@ troubleshooting, see the main [FAQ](/help/faq). session overrides. Choosing a model that isn't in that list returns: ``` - Model "provider/model" is not allowed. Use /model to list available models. + Model "provider/model" is not allowed. Use /models to list providers, or /models to list models. + Add it with: openclaw config set agents.defaults.models '{"provider/model":{}}' --strict-json --merge ``` That error is returned **instead of** a normal reply. Fix: add the model to `agents.defaults.models`, remove the allowlist, or pick a model from `/model list`. + If the command also included `--runtime codex`, add the model first and then retry + the same `/model provider/model --runtime codex` command. diff --git a/src/auto-reply/reply/directive-handling.model-selection.ts b/src/auto-reply/reply/directive-handling.model-selection.ts index 33cfbe9d7f6..72cee70f949 100644 --- a/src/auto-reply/reply/directive-handling.model-selection.ts +++ b/src/auto-reply/reply/directive-handling.model-selection.ts @@ -80,6 +80,7 @@ export function resolveModelSelectionFromDirective(params: { defaultModel: params.defaultModel, aliasIndex: params.aliasIndex, allowedModelKeys: params.allowedModelKeys, + rawRuntime: params.directives.rawModelRuntime, }) : null; const useStoredNumericProfile = @@ -131,6 +132,7 @@ export function resolveModelSelectionFromDirective(params: { defaultModel: params.defaultModel, aliasIndex: params.aliasIndex, allowedModelKeys: params.allowedModelKeys, + rawRuntime: params.directives.rawModelRuntime, }); if (resolved.error) { diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index ff41ea6e17f..758430c6eb0 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -611,6 +611,22 @@ describe("/model chat UX", () => { expect(resolved.errorText).toContain("Browse: /models or /models "); }); + it("includes additive allowlist repair when a runtime switch targets a blocked model", () => { + const resolved = resolveModelSelectionForCommand({ + command: "/model openai/gpt-5.5 --runtime codex", + allowedModelKeys: new Set(["anthropic/claude-opus-4-6"]), + allowedModelCatalog: [], + }); + + expect(resolved.modelSelection).toBeUndefined(); + expect(resolved.errorText).toContain('Model "openai/gpt-5.5" is not allowed.'); + expect(resolved.errorText).toContain( + `openclaw config set agents.defaults.models '{"openai/gpt-5.5":{}}' --strict-json --merge`, + ); + expect(resolved.errorText).toContain("Then retry: /model openai/gpt-5.5 --runtime codex"); + expect(resolved.errorText).toContain("openclaw plugins enable codex"); + }); + it("treats explicit default /model selection as resettable default", () => { const resolved = resolveModelSelectionForCommand({ command: "/model anthropic/claude-opus-4-6", diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 04f30858f09..3203c985bf1 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -265,6 +265,7 @@ export async function maybeHandleModelDirectiveInfo(params: { "", "Tap below to browse models, or use:", "/model to switch", + "/model --runtime to switch harnesses", "/model status for details", ] .filter(Boolean) diff --git a/src/auto-reply/reply/model-selection-directive.ts b/src/auto-reply/reply/model-selection-directive.ts index bce6fa5d8d1..7200c1577b6 100644 --- a/src/auto-reply/reply/model-selection-directive.ts +++ b/src/auto-reply/reply/model-selection-directive.ts @@ -20,6 +20,29 @@ export type ModelDirectiveSelection = { alias?: string; }; +function formatAddModelCommand(modelRef: string): string { + return `openclaw config set agents.defaults.models '${JSON.stringify({ [modelRef]: {} })}' --strict-json --merge`; +} + +function formatNotAllowedError(params: { + modelRef: string; + rawRuntime?: string | undefined; +}): string { + const rawRuntime = params.rawRuntime?.trim(); + const retryCommand = rawRuntime + ? `/model ${params.modelRef} --runtime ${rawRuntime}` + : `/model ${params.modelRef}`; + const lines = [ + `Model "${params.modelRef}" is not allowed. Use /models to list providers, or /models to list models.`, + `Add it with: ${formatAddModelCommand(params.modelRef)}`, + `Then retry: ${retryCommand}`, + ]; + if (rawRuntime && normalizeProviderId(rawRuntime) === "codex") { + lines.push("If the Codex runtime is missing, run: openclaw plugins enable codex"); + } + return lines.join("\n"); +} + const FUZZY_VARIANT_TOKENS = [ "lightning", "preview", @@ -238,6 +261,7 @@ export function resolveModelDirectiveSelection(params: { defaultModel: string; aliasIndex: ModelAliasIndex; allowedModelKeys: Set; + rawRuntime?: string | undefined; }): { selection?: ModelDirectiveSelection; error?: string } { const { raw, defaultProvider, defaultModel, aliasIndex, allowedModelKeys } = params; @@ -401,6 +425,9 @@ export function resolveModelDirectiveSelection(params: { } return { - error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /models to list providers, or /models to list models.`, + error: formatNotAllowedError({ + modelRef: `${resolved.ref.provider}/${resolved.ref.model}`, + rawRuntime: params.rawRuntime, + }), }; } From 57ca91ff388e7114de3e9a0236564004e3d95cf9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 15:14:20 -0700 Subject: [PATCH 070/107] fix(telegram): clarify model picker runtime scope --- extensions/telegram/src/bot-handlers.runtime.ts | 4 ++-- extensions/telegram/src/bot.create-telegram-bot.test.ts | 2 +- extensions/telegram/src/bot.test.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 00be09bef1b..84d7a61c45d 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -1764,8 +1764,8 @@ export const registerTelegramHandlers = ({ ? "reset to default" : `changed to ${escapeHtml(selection.provider)}/${escapeHtml(selection.model)}`; const scopeText = isDefaultSelection - ? "Session selection cleared. New replies use the agent's configured default." - : "Session-only selection. The agent default in openclaw.json is unchanged; /reset or a new session may return to that default."; + ? "Session selection cleared. Runtime unchanged. New replies use the agent's configured default." + : `Session-only model selection. Runtime unchanged. Use /model ${escapeHtml(selection.provider)}/${escapeHtml(selection.model)} --runtime <runtime> to switch harnesses. The agent default in openclaw.json is unchanged; /reset or a new session may return to that default.`; await editMessageWithButtons( `✅ Model ${actionText}\n\n${scopeText}`, [], // Empty buttons = remove inline keyboard diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 2c8546e5088..1d6c747f93f 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -3768,7 +3768,7 @@ describe("createTelegramBot", () => { expect(editMessageTextSpy).toHaveBeenCalledTimes(1); expect(String(editMessageTextSpy.mock.calls.at(-1)?.[2] ?? "")).toContain( - "Session-only selection. The agent default in openclaw.json is unchanged", + "Session-only model selection. Runtime unchanged.", ); expect( editMessageTextSpy.mock.calls.some((call) => diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 63ed0cd1edc..6666167b17d 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1062,7 +1062,7 @@ describe("createTelegramBot", () => { `${CHECK_MARK_EMOJI} Model reset to default`, ); expect(editMessageTextSpy.mock.calls[0]?.[2]).toContain( - "Session selection cleared. New replies use the agent's configured default.", + "Session selection cleared. Runtime unchanged. New replies use the agent's configured default.", ); const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0]; @@ -1209,7 +1209,7 @@ describe("createTelegramBot", () => { `${CHECK_MARK_EMOJI} Model reset to default`, ); expect(editMessageTextSpy.mock.calls[0]?.[2]).toContain( - "Session selection cleared. New replies use the agent's configured default.", + "Session selection cleared. Runtime unchanged. New replies use the agent's configured default.", ); const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0]; @@ -1281,7 +1281,7 @@ describe("createTelegramBot", () => { expect(editMessageTextSpy).toHaveBeenCalledWith( 1234, 17, - `${CHECK_MARK_EMOJI} Model changed to openai/gpt-5.4\n\nSession-only selection. The agent default in openclaw.json is unchanged; /reset or a new session may return to that default.`, + `${CHECK_MARK_EMOJI} Model changed to openai/gpt-5.4\n\nSession-only model selection. Runtime unchanged. Use /model openai/gpt-5.4 --runtime <runtime> to switch harnesses. The agent default in openclaw.json is unchanged; /reset or a new session may return to that default.`, expect.objectContaining({ parse_mode: "HTML" }), ); From 0908f3d538969c355616069e92de03bab0e789b9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 15:14:27 -0700 Subject: [PATCH 071/107] docs(changelog): note model runtime switch repair UX --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30cd4d07e87..8a9f62d728d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai - Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc. - Docker: prune package-excluded plugin dist directories from runtime images unless the build explicitly opts that plugin in, so official external plugins such as Feishu stay install-on-demand instead of shipping partial metadata without compiled runtime output. Fixes #77424. Thanks @vincentkoc. +- Model switching: include the exact additive allowlist repair command when `/model ... --runtime ...` targets a blocked model, and make Telegram's model picker say that it changes only the session model while leaving the runtime unchanged. Thanks @vincentkoc. - CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc. - CLI/update: use an absolute POSIX npm script shell during package-manager updates, so restricted PATH environments can still run dependency lifecycle scripts while updating from `--tag main`. Fixes #77530. Thanks @PeterTremonti. - Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. From a07d8cbf8a233853cc55d250ad720157ff9d3e1a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 15:16:04 -0700 Subject: [PATCH 072/107] fix(docker): normalize plugin build args --- Dockerfile | 8 ++++---- src/dockerfile.test.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index d14c730132e..081e0cfbb1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1.7 -# Opt-in extension dependencies at build time (space-separated directory names). -# Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel matrix" . +# Opt-in plugin dependencies at build time (space- or comma-separated directory names). +# Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,matrix" . # # Multi-stage build produces a minimal runtime image without build tools, # source code, or Bun. Works with Docker, Buildx, and Podman. @@ -32,7 +32,7 @@ ARG OPENCLAW_BUNDLED_PLUGIN_DIR # Copy package.json for opted-in extensions so pnpm resolves their deps. RUN --mount=type=bind,source=${OPENCLAW_BUNDLED_PLUGIN_DIR},target=/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR},readonly \ mkdir -p /out && \ - for ext in $OPENCLAW_EXTENSIONS; do \ + for ext in $(printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' '); do \ if [ -f "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" ]; then \ mkdir -p "/out/$ext" && \ cp "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" "/out/$ext/package.json"; \ @@ -118,7 +118,7 @@ ARG OPENCLAW_BUNDLED_PLUGIN_DIR # prune must not rediscover unrelated workspaces from the later full source # copy. RUN printf 'packages:\n - .\n - ui\n' > /tmp/pnpm-workspace.runtime.yaml && \ - for ext in $OPENCLAW_EXTENSIONS; do \ + for ext in $(printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' '); do \ printf ' - %s/%s\n' "$OPENCLAW_BUNDLED_PLUGIN_DIR" "$ext" >> /tmp/pnpm-workspace.runtime.yaml; \ done && \ cp /tmp/pnpm-workspace.runtime.yaml pnpm-workspace.yaml && \ diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index b795f19f9a0..ef7e2d35f88 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -105,9 +105,18 @@ describe("Dockerfile", () => { it("prunes runtime dependencies after the build stage", async () => { const dockerfile = await readFile(dockerfilePath, "utf8"); + const normalizedExtensionLoop = + "for ext in $(printf '%s\\n' \"$OPENCLAW_EXTENSIONS\" | tr ',' ' '); do \\"; expect(dockerfile).toContain("FROM build AS runtime-assets"); expect(dockerfile).toContain("ARG OPENCLAW_EXTENSIONS"); expect(dockerfile).toContain("ARG OPENCLAW_BUNDLED_PLUGIN_DIR"); + expect(dockerfile).toContain( + "Opt-in plugin dependencies at build time (space- or comma-separated directory names).", + ); + expect(dockerfile).toContain( + 'Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,matrix" .', + ); + expect(dockerfile.split(normalizedExtensionLoop).length - 1).toBe(2); expect(dockerfile).toContain("pnpm-workspace.runtime.yaml"); expect(dockerfile).toContain(" - ui\\n"); expect(dockerfile).toContain("CI=true NPM_CONFIG_FROZEN_LOCKFILE=false pnpm prune --prod"); From be6543caf8ca1d698728b6636ff40f9260636777 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:15:10 +0100 Subject: [PATCH 073/107] fix(doctor): preserve active auth profile metadata --- CHANGELOG.md | 1 + src/commands/doctor-auth-profile-config.ts | 210 +++++++++++ src/commands/doctor-config-flow.ts | 10 +- .../doctor/shared/config-flow-steps.test.ts | 328 ++++++++++++++++++ .../doctor/shared/config-flow-steps.ts | 15 +- .../release-configured-plugin-installs.ts | 49 +-- src/config/model-refs.ts | 77 ++++ src/config/plugin-auto-enable.shared.ts | 66 +--- src/config/validation.ts | 59 +--- 9 files changed, 653 insertions(+), 162 deletions(-) create mode 100644 src/commands/doctor-auth-profile-config.ts create mode 100644 src/config/model-refs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a9f62d728d..722f0e6c56e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai - Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc. - Docker: prune package-excluded plugin dist directories from runtime images unless the build explicitly opts that plugin in, so official external plugins such as Feishu stay install-on-demand instead of shipping partial metadata without compiled runtime output. Fixes #77424. Thanks @vincentkoc. - Model switching: include the exact additive allowlist repair command when `/model ... --runtime ...` targets a blocked model, and make Telegram's model picker say that it changes only the session model while leaving the runtime unchanged. Thanks @vincentkoc. +- Doctor/config: keep active `auth.profiles` metadata intact when `doctor --fix` strips stale secret fields from configs, repairing legacy `:default` API-key profile metadata when model fallbacks or explicit `model@profile` refs still depend on it. Fixes #77400. - CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc. - CLI/update: use an absolute POSIX npm script shell during package-manager updates, so restricted PATH environments can still run dependency lifecycle scripts while updating from `--tag main`. Fixes #77530. Thanks @PeterTremonti. - Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. diff --git a/src/commands/doctor-auth-profile-config.ts b/src/commands/doctor-auth-profile-config.ts new file mode 100644 index 00000000000..bd95da733fd --- /dev/null +++ b/src/commands/doctor-auth-profile-config.ts @@ -0,0 +1,210 @@ +import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; +import { collectConfiguredModelRefs } from "../config/model-refs.js"; +import type { AuthProfileConfig } from "../config/types.auth.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; +import { isRecord } from "../utils.js"; + +const AUTH_PROFILE_MODES = new Set(["api_key", "oauth", "token"]); + +export type AuthProfileConfigProtectionResult = { + config: OpenClawConfig; + repairs: string[]; + warnings: string[]; +}; + +function normalizeProviderId(value: unknown): string { + return normalizeLowercaseStringOrEmpty(value); +} + +function normalizeProfileId(value: unknown): string | null { + return normalizeOptionalString(value) ?? null; +} + +function normalizeMode(value: unknown): AuthProfileConfig["mode"] | null { + return typeof value === "string" && AUTH_PROFILE_MODES.has(value as AuthProfileConfig["mode"]) + ? (value as AuthProfileConfig["mode"]) + : null; +} + +function extractProviderFromModelRef(value: string): string | null { + const { model } = splitTrailingAuthProfile(value); + const slash = model.indexOf("/"); + if (slash <= 0) { + return null; + } + return normalizeProviderId(model.slice(0, slash)) || null; +} + +function extractProviderFromProfileId(profileId: string): string | null { + const colon = profileId.indexOf(":"); + if (colon <= 0) { + return null; + } + return normalizeProviderId(profileId.slice(0, colon)) || null; +} + +function collectActiveAuthHints(config: OpenClawConfig): { + activeProviders: Set; + explicitProfileIds: Set; + explicitProfileProviders: Map>; +} { + const activeProviders = new Set(); + const explicitProfileIds = new Set(); + const explicitProfileProviders = new Map>(); + + const models = isRecord(config.models) ? config.models : {}; + const providers = isRecord(models.providers) ? models.providers : {}; + for (const providerId of Object.keys(providers)) { + const normalized = normalizeProviderId(providerId); + if (normalized) { + activeProviders.add(normalized); + } + } + + for (const { value } of collectConfiguredModelRefs(config)) { + const { profile } = splitTrailingAuthProfile(value); + const provider = extractProviderFromModelRef(value); + if (profile) { + explicitProfileIds.add(profile); + if (provider) { + const providers = explicitProfileProviders.get(profile) ?? new Set(); + providers.add(provider); + explicitProfileProviders.set(profile, providers); + } + } + if (provider) { + activeProviders.add(provider); + } + } + + const auth = isRecord(config.auth) ? config.auth : {}; + const order = isRecord(auth.order) ? auth.order : {}; + for (const [providerId, profileIds] of Object.entries(order)) { + const provider = normalizeProviderId(providerId); + if (!provider || !activeProviders.has(provider) || !Array.isArray(profileIds)) { + continue; + } + for (const profileId of profileIds) { + const normalized = normalizeProfileId(profileId); + if (normalized) { + explicitProfileIds.add(normalized); + } + } + } + + return { activeProviders, explicitProfileIds, explicitProfileProviders }; +} + +function isValidProfileMetadata(value: unknown): value is AuthProfileConfig { + if (!isRecord(value)) { + return false; + } + return normalizeProviderId(value.provider) !== "" && normalizeMode(value.mode) !== null; +} + +function buildProfileMetadata(params: { + profileId: string; + before: unknown; + after: unknown; + providerHint?: string; +}): AuthProfileConfig | null { + const before = isRecord(params.before) ? params.before : {}; + const after = isRecord(params.after) ? params.after : {}; + const provider = + normalizeProviderId(after.provider) || + normalizeProviderId(before.provider) || + extractProviderFromProfileId(params.profileId) || + normalizeProviderId(params.providerHint); + if (!provider) { + return null; + } + const mode = normalizeMode(after.mode) ?? normalizeMode(before.mode) ?? "api_key"; + const repaired: AuthProfileConfig = { provider, mode }; + const email = normalizeOptionalString(after.email) ?? normalizeOptionalString(before.email); + const displayName = + normalizeOptionalString(after.displayName) ?? normalizeOptionalString(before.displayName); + if (email) { + repaired.email = email; + } + if (displayName) { + repaired.displayName = displayName; + } + return repaired; +} + +function ensureAuthProfiles(config: OpenClawConfig): Record { + const root = config as Record; + const auth: Record = isRecord(root.auth) ? root.auth : {}; + if (root.auth !== auth) { + root.auth = auth; + } + if (!isRecord(auth.profiles)) { + auth.profiles = {}; + } + return auth.profiles as Record; +} + +export function protectActiveAuthProfileConfig(params: { + before: OpenClawConfig; + after: OpenClawConfig; +}): AuthProfileConfigProtectionResult { + const { activeProviders, explicitProfileIds, explicitProfileProviders } = collectActiveAuthHints( + params.before, + ); + const beforeAuth = isRecord(params.before.auth) ? params.before.auth : {}; + const beforeProfiles = isRecord(beforeAuth.profiles) ? beforeAuth.profiles : {}; + if (Object.keys(beforeProfiles).length === 0) { + return { config: params.after, repairs: [], warnings: [] }; + } + + const config = structuredClone(params.after); + const afterAuth = isRecord(config.auth) ? config.auth : {}; + const afterProfiles = isRecord(afterAuth.profiles) ? afterAuth.profiles : {}; + const repairs: string[] = []; + const warnings: string[] = []; + + for (const [profileId, beforeProfile] of Object.entries(beforeProfiles)) { + const afterProfile = afterProfiles[profileId]; + const afterProfileRecord = isRecord(afterProfile) ? afterProfile : null; + const beforeProfileRecord = isRecord(beforeProfile) ? beforeProfile : null; + if (isValidProfileMetadata(afterProfile)) { + continue; + } + const provider = + normalizeProviderId(afterProfileRecord?.provider) || + normalizeProviderId(beforeProfileRecord?.provider) || + extractProviderFromProfileId(profileId); + const protectsActiveProvider = !!provider && activeProviders.has(provider); + const protectsExplicitProfile = explicitProfileIds.has(profileId); + if (!protectsActiveProvider && !protectsExplicitProfile) { + continue; + } + + const repaired = buildProfileMetadata({ + profileId, + before: beforeProfile, + after: afterProfile, + providerHint: + explicitProfileProviders.get(profileId)?.size === 1 + ? [...(explicitProfileProviders.get(profileId) ?? [])][0] + : undefined, + }); + if (!repaired) { + warnings.push( + `auth.profiles.${profileId}: active auth profile metadata could not be inferred; repair manually before running doctor --fix.`, + ); + continue; + } + const profiles = ensureAuthProfiles(config); + profiles[profileId] = repaired; + repairs.push( + `Repaired auth.profiles.${profileId} metadata for active ${repaired.provider} auth.`, + ); + } + + return { config, repairs, warnings }; +} diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 0f834da73d6..98443d2de10 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -257,10 +257,16 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { doctorFixCommand, }); ({ cfg, candidate, pendingChanges, fixHints } = unknownStep.state); - if (unknownStep.removed.length > 0) { - const lines = unknownStep.removed.map((path) => `- ${path}`).join("\n"); + if (unknownStep.removed.length > 0 || unknownStep.repairs.length > 0) { + const lines = [ + ...unknownStep.removed.map((path) => `- ${path}`), + ...unknownStep.repairs.map((change) => `- ${change}`), + ].join("\n"); note(lines, shouldRepair ? "Doctor changes" : "Unknown config keys"); } + if (unknownStep.warnings.length > 0) { + note(unknownStep.warnings.join("\n"), "Doctor warnings"); + } const finalized = await finalizeDoctorConfigFlow({ cfg, diff --git a/src/commands/doctor/shared/config-flow-steps.test.ts b/src/commands/doctor/shared/config-flow-steps.test.ts index 77f9e48c41c..5cefdfa1717 100644 --- a/src/commands/doctor/shared/config-flow-steps.test.ts +++ b/src/commands/doctor/shared/config-flow-steps.test.ts @@ -160,4 +160,332 @@ describe("doctor config flow steps", () => { expect(result.state.candidate).toEqual({}); expect(result.state.fixHints).toContain('Run "openclaw doctor --fix" to remove these keys.'); }); + + it("repairs active malformed auth profile metadata after unknown-key cleanup", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + "openai:default": {}, + }, + }, + models: { + providers: { + openai: { apiKey: "${OPENAI_API_KEY}" }, + }, + }, + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + }, + removed: ["auth.profiles.openai:default.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + "openai:default": { key: "sk-test" }, + }, + }, + models: { + providers: { + openai: { apiKey: "${OPENAI_API_KEY}" }, + }, + }, + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.repairs).toEqual([ + "Repaired auth.profiles.openai:default metadata for active openai auth.", + ]); + expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); + + it("keeps valid active auth profile metadata while stripping stale secret fields", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + "openai:default": { provider: "openai", mode: "api_key" }, + }, + }, + models: { + providers: { + openai: { apiKey: "${OPENAI_API_KEY}" }, + }, + }, + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + }, + removed: ["auth.profiles.openai:default.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + "openai:default": { + provider: "openai", + mode: "api_key", + key: "sk-test", + }, + }, + }, + models: { + providers: { + openai: { apiKey: "${OPENAI_API_KEY}" }, + }, + }, + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.repairs).toEqual([]); + expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); + + it("repairs non-default auth profiles for active providers", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + "openai:work": {}, + }, + }, + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + }, + removed: ["auth.profiles.openai:work.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + "openai:work": { key: "sk-test" }, + }, + }, + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.repairs).toEqual([ + "Repaired auth.profiles.openai:work metadata for active openai auth.", + ]); + expect(result.state.cfg.auth?.profiles?.["openai:work"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); + + it("preserves explicit model auth profile refs during unknown-key cleanup", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + "openai:default": {}, + }, + }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5@openai:default", + }, + }, + }, + }, + removed: ["auth.profiles.openai:default.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + "openai:default": { key: "sk-test" }, + }, + }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5@openai:default", + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); + + it("infers providers for bare auth profile suffixes", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + work: {}, + }, + }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5@work", + }, + }, + }, + }, + removed: ["auth.profiles.work.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + work: { key: "sk-test" }, + }, + }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5@work", + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.warnings).toEqual([]); + expect(result.state.cfg.auth?.profiles?.work).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); + + it("protects auth profiles referenced only by channel model overrides", () => { + stripUnknownConfigKeysMock.mockReturnValueOnce({ + config: { + auth: { + profiles: { + "openai:default": {}, + }, + }, + channels: { + modelByChannel: { + slack: { + C123: "openai/gpt-5.5@openai:default", + }, + }, + }, + }, + removed: ["auth.profiles.openai:default.key"], + }); + + const result = applyUnknownConfigKeyStep({ + state: { + cfg: {}, + candidate: { + auth: { + profiles: { + "openai:default": { key: "sk-test" }, + }, + }, + channels: { + modelByChannel: { + slack: { + C123: "openai/gpt-5.5@openai:default", + }, + }, + }, + } as unknown as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + shouldRepair: true, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({ + provider: "openai", + mode: "api_key", + }); + }); }); diff --git a/src/commands/doctor/shared/config-flow-steps.ts b/src/commands/doctor/shared/config-flow-steps.ts index 79f17abe604..3c73da4c0ea 100644 --- a/src/commands/doctor/shared/config-flow-steps.ts +++ b/src/commands/doctor/shared/config-flow-steps.ts @@ -1,4 +1,5 @@ import { formatConfigIssueLines } from "../../../config/issue-format.js"; +import { protectActiveAuthProfileConfig } from "../../doctor-auth-profile-config.js"; import { stripUnknownConfigKeys } from "../../doctor-config-analysis.js"; import type { DoctorConfigPreflightResult } from "../../doctor-config-preflight.js"; import type { DoctorConfigMutationState } from "./config-mutation-state.js"; @@ -75,21 +76,29 @@ export function applyUnknownConfigKeyStep(params: { }): { state: DoctorConfigMutationState; removed: string[]; + repairs: string[]; + warnings: string[]; } { const unknown = stripUnknownConfigKeys(params.state.candidate); if (unknown.removed.length === 0) { - return { state: params.state, removed: [] }; + return { state: params.state, removed: [], repairs: [], warnings: [] }; } + const protectedAuth = protectActiveAuthProfileConfig({ + before: params.state.candidate, + after: unknown.config, + }); return { state: { - cfg: params.shouldRepair ? unknown.config : params.state.cfg, - candidate: unknown.config, + cfg: params.shouldRepair ? protectedAuth.config : params.state.cfg, + candidate: protectedAuth.config, pendingChanges: true, fixHints: params.shouldRepair ? params.state.fixHints : [...params.state.fixHints, `Run "${params.doctorFixCommand}" to remove these keys.`], }, removed: unknown.removed, + repairs: protectedAuth.repairs, + warnings: protectedAuth.warnings, }; } diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index b1bfbf6894a..6d3ddb0133f 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -2,6 +2,7 @@ import { collectConfiguredAgentHarnessRuntimes } from "../../../agents/harness-r import { listPotentialConfiguredChannelPresenceSignals } from "../../../channels/config-presence.js"; import { normalizeChatChannelId } from "../../../channels/registry.js"; import { isChannelConfigured } from "../../../config/channel-configured.js"; +import { collectConfiguredModelRefs } from "../../../config/model-refs.js"; import { detectPluginAutoEnableCandidates } from "../../../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { compareOpenClawVersions } from "../../../config/version.js"; @@ -151,49 +152,13 @@ function collectConfiguredProviderIds(cfg: OpenClawConfig): Set { for (const providerId of Object.keys(asObjectRecord(cfg.models?.providers) ?? {})) { add(providerId); } - const collectModelRef = (value: unknown) => { - const ref = normalizeId(value); - const slash = ref?.indexOf("/") ?? -1; - if (ref && slash > 0) { - add(ref.slice(0, slash)); + for (const { value } of collectConfiguredModelRefs(cfg, { + includeChannelModelOverrides: false, + })) { + const slash = value.indexOf("/"); + if (slash > 0) { + add(value.slice(0, slash)); } - }; - const collectModelConfig = (value: unknown) => { - if (typeof value === "string") { - collectModelRef(value); - return; - } - const record = asObjectRecord(value); - if (!record) { - return; - } - collectModelRef(record.primary); - if (Array.isArray(record.fallbacks)) { - for (const fallback of record.fallbacks) { - collectModelRef(fallback); - } - } - }; - const collectAgent = (agent: unknown) => { - const record = asObjectRecord(agent); - if (!record) { - return; - } - for (const key of [ - "model", - "imageGenerationModel", - "videoGenerationModel", - "musicGenerationModel", - ]) { - collectModelConfig(record[key]); - } - for (const modelRef of Object.keys(asObjectRecord(record.models) ?? {})) { - collectModelRef(modelRef); - } - }; - collectAgent(cfg.agents?.defaults); - for (const agent of Array.isArray(cfg.agents?.list) ? cfg.agents.list : []) { - collectAgent(agent); } return ids; } diff --git a/src/config/model-refs.ts b/src/config/model-refs.ts new file mode 100644 index 00000000000..8008aa8b911 --- /dev/null +++ b/src/config/model-refs.ts @@ -0,0 +1,77 @@ +import { isRecord } from "../utils.js"; + +export type ConfiguredModelRef = { + path: string; + value: string; +}; + +export const AGENT_MODEL_CONFIG_KEYS = [ + "model", + "imageModel", + "imageGenerationModel", + "videoGenerationModel", + "musicGenerationModel", + "pdfModel", +] as const; + +export function collectConfiguredModelRefs( + config: unknown, + options: { includeChannelModelOverrides?: boolean } = {}, +): ConfiguredModelRef[] { + const refs: ConfiguredModelRef[] = []; + const pushModelRef = (path: string, value: unknown) => { + if (typeof value === "string" && value.trim()) { + refs.push({ path, value: value.trim() }); + } + }; + const collectModelConfig = (path: string, value: unknown) => { + if (typeof value === "string") { + pushModelRef(path, value); + return; + } + if (!isRecord(value)) { + return; + } + pushModelRef(`${path}.primary`, value.primary); + if (Array.isArray(value.fallbacks)) { + for (const [index, entry] of value.fallbacks.entries()) { + pushModelRef(`${path}.fallbacks.${index}`, entry); + } + } + }; + const collectFromAgent = (path: string, agent: unknown) => { + if (!isRecord(agent)) { + return; + } + for (const key of AGENT_MODEL_CONFIG_KEYS) { + collectModelConfig(`${path}.${key}`, agent[key]); + } + if (isRecord(agent.models)) { + for (const modelRef of Object.keys(agent.models)) { + pushModelRef(`${path}.models.${modelRef}`, modelRef); + } + } + }; + + const root = isRecord(config) ? config : {}; + const agents = isRecord(root.agents) ? root.agents : {}; + collectFromAgent("agents.defaults", agents.defaults); + if (Array.isArray(agents.list)) { + for (const [index, entry] of agents.list.entries()) { + collectFromAgent(`agents.list.${index}`, entry); + } + } + if (options.includeChannelModelOverrides !== false) { + const channels = isRecord(root.channels) ? root.channels : {}; + const modelByChannel = isRecord(channels.modelByChannel) ? channels.modelByChannel : {}; + for (const [channelId, channelMap] of Object.entries(modelByChannel)) { + if (!isRecord(channelMap)) { + continue; + } + for (const [targetId, modelRef] of Object.entries(channelMap)) { + pushModelRef(`channels.modelByChannel.${channelId}.${targetId}`, modelRef); + } + } + } + return refs; +} diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 745dff0b4c6..71148a45186 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -15,6 +15,7 @@ import { resolvePluginSetupAutoEnableReasons } from "../plugins/setup-registry.j import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { isRecord } from "../utils.js"; import { isChannelConfigured } from "./channel-configured.js"; +import { collectConfiguredModelRefs } from "./model-refs.js"; import { shouldSkipPreferredPluginAutoEnable } from "./plugin-auto-enable.prefer-over.js"; import type { PluginAutoEnableCandidate, @@ -47,61 +48,6 @@ function resolveAutoEnableProviderPluginIds( return Object.fromEntries(entries); } -function collectModelRefs(cfg: OpenClawConfig): string[] { - const refs: string[] = []; - const pushModelRef = (value: unknown) => { - if (typeof value === "string" && value.trim()) { - refs.push(value.trim()); - } - }; - const collectModelConfig = (value: unknown) => { - if (typeof value === "string") { - pushModelRef(value); - return; - } - if (!isRecord(value)) { - return; - } - pushModelRef(value.primary); - const fallbacks = value.fallbacks; - if (Array.isArray(fallbacks)) { - for (const entry of fallbacks) { - pushModelRef(entry); - } - } - }; - const collectFromAgent = (agent: Record | null | undefined) => { - if (!agent) { - return; - } - for (const key of [ - "model", - "imageGenerationModel", - "videoGenerationModel", - "musicGenerationModel", - ]) { - collectModelConfig(agent[key]); - } - const models = agent.models; - if (isRecord(models)) { - for (const key of Object.keys(models)) { - pushModelRef(key); - } - } - }; - - collectFromAgent(cfg.agents?.defaults as Record | undefined); - const list = cfg.agents?.list; - if (Array.isArray(list)) { - for (const entry of list) { - if (isRecord(entry)) { - collectFromAgent(entry); - } - } - } - return refs; -} - function extractProviderFromModelRef(value: string): string | null { const trimmed = value.trim(); const slash = trimmed.indexOf("/"); @@ -157,7 +103,9 @@ function isProviderConfigured(cfg: OpenClawConfig, providerId: string): boolean } } - for (const ref of collectModelRefs(cfg)) { + for (const { value: ref } of collectConfiguredModelRefs(cfg, { + includeChannelModelOverrides: false, + })) { const provider = extractProviderFromModelRef(ref); if (provider && provider === normalized) { return true; @@ -493,7 +441,7 @@ function hasConfiguredProviderModelOrHarness(cfg: OpenClawConfig, env: NodeJS.Pr if (cfg.models?.providers && Object.keys(cfg.models.providers).length > 0) { return true; } - if (collectModelRefs(cfg).length > 0) { + if (collectConfiguredModelRefs(cfg, { includeChannelModelOverrides: false }).length > 0) { return true; } return hasConfiguredEmbeddedHarnessRuntime(cfg, env); @@ -618,7 +566,9 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { } } - for (const modelRef of collectModelRefs(params.config)) { + for (const { value: modelRef } of collectConfiguredModelRefs(params.config, { + includeChannelModelOverrides: false, + })) { const owningPluginIds = resolveOwningPluginIdsForModelRef({ model: modelRef, config: params.config, diff --git a/src/config/validation.ts b/src/config/validation.ts index 60d4375cff7..ac60dfd8140 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -35,6 +35,7 @@ import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-value import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "./bundled-channel-config-metadata.generated.js"; import { collectChannelSchemaMetadata } from "./channel-config-metadata.js"; import { materializeRuntimeConfig } from "./materialize.js"; +import { collectConfiguredModelRefs } from "./model-refs.js"; import type { OpenClawConfig, ConfigValidationIssue } from "./types.js"; import { coerceSecretRef } from "./types.secrets.js"; import { OpenClawSchema } from "./zod-schema.js"; @@ -50,10 +51,6 @@ type AllowedValuesCollection = { hasValues: boolean; }; type JsonSchemaLike = Record; -type ConfiguredModelRef = { - path: string; - value: string; -}; function stripDeprecatedValidationKeys(raw: unknown): unknown { if (!isRecord(raw) || !isRecord(raw.commands) || !Object.hasOwn(raw.commands, "modelsWrite")) { @@ -1110,58 +1107,6 @@ function validateConfigObjectWithPluginsBase( issues.push(issue); }; - const collectConfiguredModelRefs = (): ConfiguredModelRef[] => { - const refs: ConfiguredModelRef[] = []; - const pushModelRef = (path: string, value: unknown) => { - if (typeof value === "string" && value.trim()) { - refs.push({ path, value: value.trim() }); - } - }; - const collectModelConfig = (path: string, value: unknown) => { - if (typeof value === "string") { - pushModelRef(path, value); - return; - } - if (!isRecord(value)) { - return; - } - pushModelRef(`${path}.primary`, value.primary); - if (Array.isArray(value.fallbacks)) { - for (const [index, entry] of value.fallbacks.entries()) { - pushModelRef(`${path}.fallbacks.${index}`, entry); - } - } - }; - const collectFromAgent = (path: string, agent: unknown) => { - if (!isRecord(agent)) { - return; - } - for (const key of [ - "model", - "imageModel", - "imageGenerationModel", - "videoGenerationModel", - "musicGenerationModel", - "pdfModel", - ]) { - collectModelConfig(`${path}.${key}`, agent[key]); - } - if (isRecord(agent.models)) { - for (const modelRef of Object.keys(agent.models)) { - pushModelRef(`${path}.models.${modelRef}`, modelRef); - } - } - }; - - collectFromAgent("agents.defaults", config.agents?.defaults); - if (Array.isArray(config.agents?.list)) { - for (const [index, entry] of config.agents.list.entries()) { - collectFromAgent(`agents.list.${index}`, entry); - } - } - return refs; - }; - const parseProviderModelRef = (value: string): { provider: string; model: string } | null => { const slashIndex = value.indexOf("/"); if (slashIndex <= 0 || slashIndex >= value.length - 1) { @@ -1173,7 +1118,7 @@ function validateConfigObjectWithPluginsBase( }; const validateConfiguredModelRefs = () => { - const configuredRefs = collectConfiguredModelRefs(); + const configuredRefs = collectConfiguredModelRefs(config); if (configuredRefs.length === 0) { return; } From 14aa98827ab6a03e407307665c89698cf769ac74 Mon Sep 17 00:00:00 2001 From: Chunyue Wang <80630709+openperf@users.noreply.github.com> Date: Tue, 5 May 2026 06:17:00 +0800 Subject: [PATCH 074/107] fix(codex/app-server): stable mirror idempotency to prevent transcript loss (#77046) * fix(codex/app-server): stable mirror idempotency to prevent transcript loss * Changelog: note codex/app-server transcript mirror dedupe stabilization (#77046) --- CHANGELOG.md | 1 + .../codex/src/app-server/event-projector.ts | 41 ++- .../codex/src/app-server/run-attempt.ts | 9 +- .../src/app-server/transcript-mirror.test.ts | 292 ++++++++++++++++-- .../codex/src/app-server/transcript-mirror.ts | 64 +++- 5 files changed, 368 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 722f0e6c56e..ed871041fbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -546,6 +546,7 @@ Docs: https://docs.openclaw.ai - Plugins/update: keep externalized bundled npm bridge updates on the normal plugin security scanner path instead of granting source-linked official trust without artifact provenance. (#76765) Thanks @Lucenx9. - Agents/reply context: label replied-to messages as the current user message target in model-visible metadata, so short replies are grounded to their explicit reply target instead of nearby chat history. (#76817) Thanks @obviyus. - Doctor/plugins: install configured missing official plugins such as Discord and Brave during doctor/update repair, auto-enable repaired provider plugins, preserve config when a download fails, and stop auto-enable from inventing plugin entries when no manifest declares a configured channel. Fixes #76872. Thanks @jack-stormentswe. +- Codex/app-server: stabilize transcript mirror dedupe across re-mirrored turns so reordered snapshots no longer drop reasoning entries or duplicate the assistant reply. Refs #77012. (#77046) Thanks @openperf. ## 2026.5.2 diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index bae62e0ed02..d0f79c6a55f 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -28,6 +28,7 @@ import { type JsonValue, } from "./protocol.js"; import { readCodexMirroredSessionHistoryMessages } from "./session-history.js"; +import { attachCodexMirrorIdentity } from "./transcript-mirror.js"; export type CodexAppServerToolTelemetry = { didSendViaMessagingTool: boolean; @@ -185,23 +186,47 @@ export class CodexAppServerEventProjector { assistantTexts.length > 0 ? this.createAssistantMessage(assistantTexts.join("\n\n")) : undefined; + // Each snapshot entry is tagged with a stable mirror identity of the + // shape `${turnId}:${kind}`. The mirror's idempotency key is derived + // from this identity rather than from snapshot position or content + // hash, so: + // - Re-mirror of the same turn (retry) → same identity → no-op. + // - Re-emit of a prior turn's entry into a later turn's snapshot + // (the cross-turn drift mode named in #77012) → original identity + // is preserved → on-disk key still matches → also a no-op. + // - Two distinct turns where the user repeats verbatim content → + // distinct turnIds → distinct identities → both kept. + const turnId = this.turnId; const messagesSnapshot: AgentMessage[] = [ - { - role: "user", - content: this.params.prompt, - timestamp: Date.now(), - }, + attachCodexMirrorIdentity( + { + role: "user", + content: this.params.prompt, + timestamp: Date.now(), + }, + `${turnId}:prompt`, + ), ]; // Codex owns the canonical thread. These mirror records keep enough local // context for OpenClaw history, search, and future harness switching. if (reasoningText) { - messagesSnapshot.push(this.createAssistantMirrorMessage("Codex reasoning", reasoningText)); + messagesSnapshot.push( + attachCodexMirrorIdentity( + this.createAssistantMirrorMessage("Codex reasoning", reasoningText), + `${turnId}:reasoning`, + ), + ); } if (planText) { - messagesSnapshot.push(this.createAssistantMirrorMessage("Codex plan", planText)); + messagesSnapshot.push( + attachCodexMirrorIdentity( + this.createAssistantMirrorMessage("Codex plan", planText), + `${turnId}:plan`, + ), + ); } if (lastAssistant) { - messagesSnapshot.push(lastAssistant); + messagesSnapshot.push(attachCodexMirrorIdentity(lastAssistant, `${turnId}:assistant`)); } const turnFailed = this.completedTurn?.status === "failed"; const turnInterrupted = this.completedTurn?.status === "interrupted"; diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index a082ecb7325..3850b072220 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -1822,7 +1822,14 @@ async function mirrorTranscriptBestEffort(params: { agentId: params.agentId, sessionKey: params.sessionKey, messages: params.result.messagesSnapshot, - idempotencyScope: `codex-app-server:${params.threadId}:${params.turnId}`, + // Scope is thread-stable. Each entry in `messagesSnapshot` is tagged + // with a per-turn `attachCodexMirrorIdentity` value carrying its own + // turnId, so distinct turns produce distinct dedupe keys via the + // identity (not via the scope). Dropping `turnId` from the scope + // here is what lets a re-emitted prior-turn entry — which still + // carries its original `${turnId}:${kind}` identity — collide with + // its existing on-disk key and be a true no-op. + idempotencyScope: `codex-app-server:${params.threadId}`, config: params.params.config, }); } catch (error) { diff --git a/extensions/codex/src/app-server/transcript-mirror.test.ts b/extensions/codex/src/app-server/transcript-mirror.test.ts index 9415891fa69..db2cd35268c 100644 --- a/extensions/codex/src/app-server/transcript-mirror.test.ts +++ b/extensions/codex/src/app-server/transcript-mirror.test.ts @@ -1,6 +1,8 @@ +import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; import { initializeGlobalHookRunner, resetGlobalHookRunner, @@ -12,7 +14,16 @@ import { makeAgentUserMessage, } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it } from "vitest"; -import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js"; +import { attachCodexMirrorIdentity, mirrorCodexAppServerTranscript } from "./transcript-mirror.js"; + +type MirroredAgentMessage = Extract; + +// Mirrors transcript-mirror.ts's fallback fingerprint exactly so test +// expectations stay in sync without exposing the helper publicly. +function expectedFingerprint(message: MirroredAgentMessage): string { + const payload = JSON.stringify({ role: message.role, content: message.content }); + return createHash("sha256").update(payload).digest("hex").slice(0, 16); +} const tempDirs: string[] = []; @@ -38,20 +49,19 @@ async function makeRoot(prefix: string): Promise { describe("mirrorCodexAppServerTranscript", () => { it("mirrors user and assistant messages into the Pi transcript", async () => { const sessionFile = await createTempSessionFile(); + const userMessage = makeAgentUserMessage({ + content: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + }); + const assistantMessage = makeAgentAssistantMessage({ + content: [{ type: "text", text: "hi there" }], + timestamp: Date.now() + 1, + }); await mirrorCodexAppServerTranscript({ sessionFile, sessionKey: "session-1", - messages: [ - makeAgentUserMessage({ - content: [{ type: "text", text: "hello" }], - timestamp: Date.now(), - }), - makeAgentAssistantMessage({ - content: [{ type: "text", text: "hi there" }], - timestamp: Date.now() + 1, - }), - ], + messages: [userMessage, assistantMessage], idempotencyScope: "scope-1", }); @@ -60,8 +70,10 @@ describe("mirrorCodexAppServerTranscript", () => { expect(raw).toContain('"content":[{"type":"text","text":"hello"}]'); expect(raw).toContain('"role":"assistant"'); expect(raw).toContain('"content":[{"type":"text","text":"hi there"}]'); - expect(raw).toContain('"idempotencyKey":"scope-1:user:0"'); - expect(raw).toContain('"idempotencyKey":"scope-1:assistant:1"'); + expect(raw).toContain(`"idempotencyKey":"scope-1:user:${expectedFingerprint(userMessage)}"`); + expect(raw).toContain( + `"idempotencyKey":"scope-1:assistant:${expectedFingerprint(assistantMessage)}"`, + ); }); it("creates the transcript directory on first mirror", async () => { @@ -134,22 +146,25 @@ describe("mirrorCodexAppServerTranscript", () => { ]), ); const sessionFile = await createTempSessionFile(); + const sourceMessage = makeAgentAssistantMessage({ + content: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + }); await mirrorCodexAppServerTranscript({ sessionFile, sessionKey: "session-1", - messages: [ - makeAgentAssistantMessage({ - content: [{ type: "text", text: "hello" }], - timestamp: Date.now(), - }), - ], + messages: [sourceMessage], idempotencyScope: "scope-1", }); const raw = await fs.readFile(sessionFile, "utf8"); expect(raw).toContain('"content":[{"type":"text","text":"hello [hooked]"}]'); - expect(raw).toContain('"idempotencyKey":"scope-1:assistant:0"'); + // The idempotency fingerprint is derived from the pre-hook message so a + // hook rewrite cannot bypass dedupe by reshaping content on every retry. + expect(raw).toContain( + `"idempotencyKey":"scope-1:assistant:${expectedFingerprint(sourceMessage)}"`, + ); }); it("preserves the computed idempotency key when hooks rewrite message keys", async () => { @@ -167,21 +182,22 @@ describe("mirrorCodexAppServerTranscript", () => { ]), ); const sessionFile = await createTempSessionFile(); + const sourceMessage = makeAgentAssistantMessage({ + content: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + }); await mirrorCodexAppServerTranscript({ sessionFile, sessionKey: "session-1", - messages: [ - makeAgentAssistantMessage({ - content: [{ type: "text", text: "hello" }], - timestamp: Date.now(), - }), - ], + messages: [sourceMessage], idempotencyScope: "scope-1", }); const raw = await fs.readFile(sessionFile, "utf8"); - expect(raw).toContain('"idempotencyKey":"scope-1:assistant:0"'); + expect(raw).toContain( + `"idempotencyKey":"scope-1:assistant:${expectedFingerprint(sourceMessage)}"`, + ); expect(raw).not.toContain("hook-rewritten-key"); }); @@ -262,4 +278,226 @@ describe("mirrorCodexAppServerTranscript", () => { expect(records[0]).toMatchObject({ id: "legacy-user", parentId: null }); expect(records[1]).toMatchObject({ parentId: "legacy-user" }); }); + + // Helpers for the identity-based regression tests below. + // + // The mirror dedupe key is now `${idempotencyScope}:${identity}`, where + // `identity` is either an explicit `attachCodexMirrorIdentity` tag (the + // production path; event-projector emits `${turnId}:${kind}`) or the + // role/content fingerprint fallback (legacy callers). + type FileMessage = { + type?: string; + message?: { role?: string; content?: Array<{ text?: string }> }; + }; + function readFileMessages(raw: string): Array<{ role?: string; text?: string }> { + return raw + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as FileMessage) + .filter((record) => record.type === "message") + .map((record) => ({ + role: record.message?.role, + text: record.message?.content?.[0]?.text, + })); + } + + // Regression for #77012 (within-turn snapshot reordering). When mirror is + // invoked twice under the same scope/turn but the second snapshot inserts + // a reasoning record between the user prompt and the assistant reply, + // every assistant-role record after the inserted slot shifts. With the + // previous `:role:index` key, the second call's reasoning record collided + // with the first call's assistant key (both `:assistant:1`) — the + // legitimately-new reasoning entry was silently dropped, and the + // assistant content was re-appended under `:assistant:2`, producing a + // duplicate assistant entry. The identity-based key (event-projector + // tags `${turnId}:reasoning` and `${turnId}:assistant`) makes each kind + // its own dedupe slot. + it("dedupes mirrored messages despite snapshot positional shifts", async () => { + const sessionFile = await createTempSessionFile(); + const userMessage = attachCodexMirrorIdentity( + makeAgentUserMessage({ + content: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + }), + "turn-1:prompt", + ); + const assistantMessage = attachCodexMirrorIdentity( + makeAgentAssistantMessage({ + content: [{ type: "text", text: "hi there" }], + timestamp: Date.now() + 1, + }), + "turn-1:assistant", + ); + + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [userMessage, assistantMessage], + idempotencyScope: "codex-app-server:thread-X", + }); + const reasoningMessage = attachCodexMirrorIdentity( + makeAgentAssistantMessage({ + content: [{ type: "text", text: "[Codex reasoning] thinking" }], + timestamp: Date.now() + 2, + }), + "turn-1:reasoning", + ); + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [userMessage, reasoningMessage, assistantMessage], + idempotencyScope: "codex-app-server:thread-X", + }); + + const messageTexts = readFileMessages(await fs.readFile(sessionFile, "utf8")).map( + (m) => m.text, + ); + expect(messageTexts).toEqual(["hello", "hi there", "[Codex reasoning] thinking"]); + }); + + // Two distinct turns where the user types the same thing must not collapse: + // each entry carries its own `${turnId}:${kind}` identity so the dedupe + // key differs even when role+content match. (Prior content-fingerprint-only + // designs would have collapsed the second user turn here.) + it("keeps repeated same-content turns distinct", async () => { + const sessionFile = await createTempSessionFile(); + const userTurn1 = attachCodexMirrorIdentity( + makeAgentUserMessage({ + content: [{ type: "text", text: "yes" }], + timestamp: Date.now(), + }), + "turn-1:prompt", + ); + const assistantTurn1 = attachCodexMirrorIdentity( + makeAgentAssistantMessage({ + content: [{ type: "text", text: "ok 1" }], + timestamp: Date.now() + 1, + }), + "turn-1:assistant", + ); + const userTurn2 = attachCodexMirrorIdentity( + makeAgentUserMessage({ + content: [{ type: "text", text: "yes" }], + timestamp: Date.now() + 2, + }), + "turn-2:prompt", + ); + const assistantTurn2 = attachCodexMirrorIdentity( + makeAgentAssistantMessage({ + content: [{ type: "text", text: "ok 2" }], + timestamp: Date.now() + 3, + }), + "turn-2:assistant", + ); + + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [userTurn1, assistantTurn1], + idempotencyScope: "codex-app-server:thread-X", + }); + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [userTurn2, assistantTurn2], + idempotencyScope: "codex-app-server:thread-X", + }); + + expect(readFileMessages(await fs.readFile(sessionFile, "utf8"))).toEqual([ + { role: "user", text: "yes" }, + { role: "assistant", text: "ok 1" }, + { role: "user", text: "yes" }, + { role: "assistant", text: "ok 2" }, + ]); + }); + + // Cross-turn re-emit: an entry first written under turn 1 may be re-emitted + // as part of a later turn's snapshot (e.g. a context-engine flow that + // bundles prior history). Because every entry carries its own original + // `${turnId}:${kind}` identity, the re-emitted entries collide with their + // existing on-disk keys and become true no-ops — instead of being + // appended again on a sibling branch (the on-disk symptom in #77012). + it("dedupes prior-turn entries re-emitted into a later turn's snapshot", async () => { + const sessionFile = await createTempSessionFile(); + const userTurn1 = attachCodexMirrorIdentity( + makeAgentUserMessage({ + content: [{ type: "text", text: "msg1" }], + timestamp: Date.now(), + }), + "turn-1:prompt", + ); + const assistantTurn1 = attachCodexMirrorIdentity( + makeAgentAssistantMessage({ + content: [{ type: "text", text: "reply1" }], + timestamp: Date.now() + 1, + }), + "turn-1:assistant", + ); + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [userTurn1, assistantTurn1], + idempotencyScope: "codex-app-server:thread-X", + }); + + const userTurn2 = attachCodexMirrorIdentity( + makeAgentUserMessage({ + content: [{ type: "text", text: "msg2" }], + timestamp: Date.now() + 2, + }), + "turn-2:prompt", + ); + const assistantTurn2 = attachCodexMirrorIdentity( + makeAgentAssistantMessage({ + content: [{ type: "text", text: "reply2" }], + timestamp: Date.now() + 3, + }), + "turn-2:assistant", + ); + // Buggy upstream: snapshot for turn 2 also includes the just-completed + // turn 1's entries (with their original identities preserved). + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [userTurn1, assistantTurn1, userTurn2, assistantTurn2], + idempotencyScope: "codex-app-server:thread-X", + }); + + expect(readFileMessages(await fs.readFile(sessionFile, "utf8"))).toEqual([ + { role: "user", text: "msg1" }, + { role: "assistant", text: "reply1" }, + { role: "user", text: "msg2" }, + { role: "assistant", text: "reply2" }, + ]); + }); + + // Backward-compat: callers that do not tag messages with a mirror identity + // (e.g. third-party harnesses or tests routed through the legacy path) + // still get the role/content fingerprint key. Distinct turns are then + // distinguished by the caller's idempotency scope. + it("falls back to the role+content fingerprint when no identity is attached", async () => { + const sessionFile = await createTempSessionFile(); + const userMessage = makeAgentUserMessage({ + content: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + }); + const assistantMessage = makeAgentAssistantMessage({ + content: [{ type: "text", text: "hi there" }], + timestamp: Date.now() + 1, + }); + + await mirrorCodexAppServerTranscript({ + sessionFile, + sessionKey: "session-1", + messages: [userMessage, assistantMessage], + idempotencyScope: "scope-1", + }); + + const raw = await fs.readFile(sessionFile, "utf8"); + expect(raw).toContain(`"idempotencyKey":"scope-1:user:${expectedFingerprint(userMessage)}"`); + expect(raw).toContain( + `"idempotencyKey":"scope-1:assistant:${expectedFingerprint(assistantMessage)}"`, + ); + }); }); diff --git a/extensions/codex/src/app-server/transcript-mirror.ts b/extensions/codex/src/app-server/transcript-mirror.ts index 4b445cda106..a96cd0c3782 100644 --- a/extensions/codex/src/app-server/transcript-mirror.ts +++ b/extensions/codex/src/app-server/transcript-mirror.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import { acquireSessionWriteLock, @@ -9,6 +10,61 @@ import { type SessionWriteLockAcquireTimeoutConfig, } from "openclaw/plugin-sdk/agent-harness-runtime"; +type MirroredAgentMessage = Extract; + +const MIRROR_IDENTITY_META_KEY = "mirrorIdentity" as const; + +/** + * Tag a message with a stable logical identity for mirror dedupe. Callers + * should use a value that is invariant for the same logical message across + * re-emits (e.g. `${turnId}:prompt`, `${turnId}:assistant`) but distinct + * for genuinely-distinct messages (different turns, different kinds). When + * present this identity replaces the role/content fingerprint in the + * idempotency key, so the dedupe survives caller-scope rotation without + * collapsing distinct same-content turns. + */ +export function attachCodexMirrorIdentity(message: T, identity: string): T { + const record = message as unknown as Record; + const existing = record.__openclaw; + const baseMeta = + existing && typeof existing === "object" && !Array.isArray(existing) + ? (existing as Record) + : {}; + return { + ...record, + __openclaw: { ...baseMeta, [MIRROR_IDENTITY_META_KEY]: identity }, + } as unknown as T; +} + +function readMirrorIdentity(message: MirroredAgentMessage): string | undefined { + const record = message as unknown as { __openclaw?: unknown }; + const meta = record.__openclaw; + if (!meta || typeof meta !== "object" || Array.isArray(meta)) { + return undefined; + } + const id = (meta as Record)[MIRROR_IDENTITY_META_KEY]; + return typeof id === "string" && id.length > 0 ? id : undefined; +} + +// Fallback content fingerprint for callers that did not tag the message +// with a stable mirror identity. Only role and content participate; volatile +// metadata (timestamps, usage, etc.) is intentionally excluded so the +// fingerprint survives snapshot reordering inside a fixed scope. Distinct +// same-content turns are still distinguished by the caller's idempotency +// scope when callers route through this fallback. +function fingerprintMirrorMessageContent(message: MirroredAgentMessage): string { + const payload = JSON.stringify({ role: message.role, content: message.content }); + return createHash("sha256").update(payload).digest("hex").slice(0, 16); +} + +function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string { + const explicit = readMirrorIdentity(message); + if (explicit) { + return explicit; + } + return `${message.role}:${fingerprintMirrorMessageContent(message)}`; +} + export async function mirrorCodexAppServerTranscript(params: { sessionFile: string; sessionKey?: string; @@ -18,7 +74,8 @@ export async function mirrorCodexAppServerTranscript(params: { config?: SessionWriteLockAcquireTimeoutConfig; }): Promise { const messages = params.messages.filter( - (message) => message.role === "user" || message.role === "assistant", + (message): message is MirroredAgentMessage => + message.role === "user" || message.role === "assistant", ); if (messages.length === 0) { return; @@ -30,9 +87,10 @@ export async function mirrorCodexAppServerTranscript(params: { }); try { const existingIdempotencyKeys = await readTranscriptIdempotencyKeys(params.sessionFile); - for (const [index, message] of messages.entries()) { + for (const message of messages) { + const dedupeIdentity = buildMirrorDedupeIdentity(message); const idempotencyKey = params.idempotencyScope - ? `${params.idempotencyScope}:${message.role}:${index}` + ? `${params.idempotencyScope}:${dedupeIdentity}` : undefined; if (idempotencyKey && existingIdempotencyKeys.has(idempotencyKey)) { continue; From b3e42bf32792d2a1140ac2cbd85e1fb6a2ec6ffb Mon Sep 17 00:00:00 2001 From: hcl Date: Tue, 5 May 2026 06:22:15 +0800 Subject: [PATCH 075/107] fix(plugins): emit actionable install hint for externalized channel plugins (#77502) Fixes #77483.\n\n- Suggest catalog-backed install commands for missing official external plugins in config validation.\n- Preserve stale/remove wording for non-catalog missing plugins.\n- Add regression coverage for plugins.entries and plugins.allow warnings.\n\nVerification:\n- pnpm exec oxfmt --check --threads=1 CHANGELOG.md src/config/validation.ts src/config/config.plugin-validation.test.ts\n- pnpm test src/config/config.plugin-validation.test.ts src/commands/doctor/shared/missing-configured-plugin-install.test.ts\n- pnpm crabbox:run -- --provider blacksmith-testbox ... pnpm check:changed\n- GitHub CI green on d1b1b1044403a2072fe10631a70fb13438990440 --- CHANGELOG.md | 1 + src/config/config.plugin-validation.test.ts | 40 ++++++++++++++++++++- src/config/validation.ts | 30 ++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed871041fbd..dae1b57fede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Plugins/migration: emit catalog-backed install hints when `plugins.entries` or `plugins.allow` references an official external plugin that is not installed, so upgraded configs point operators to `openclaw plugins install ` instead of telling them to remove valid plugin config. (#77483) Thanks @hclsys. - Dependencies: refresh runtime and provider packages including Pi 0.73.0, ACPX adapters, OpenAI, Anthropic, Slack, and TypeScript native preview, while keeping the Bedrock runtime installer override pinned below the Windows ARM Node 24 npm resolver failure. - Agents/performance: pass the resolved workspace through BTW, compaction, embedded-run model generation, and PDF model setup so explicit agent-dir model refreshes can reuse the current workspace-scoped plugin metadata snapshot instead of falling back to cold plugin metadata scans. (#77519, #77532) - Config/plugin auto-enable: prefer the claiming plugin manifest id over a built-in channel alias when auto-allowlisting a configured channel, so WeCom/Yuanbao-style aliases resolve to the installed plugin id. Thanks @Beandon13. diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 0201f010ca1..d10fc9e1013 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -253,6 +253,44 @@ describe("config plugin validation", () => { } }); + it("reports catalog install hints for missing configured official external plugins", async () => { + const res = validateConfigObjectWithPlugins( + { + agents: { list: [{ id: "pi" }] }, + plugins: { + entries: { brave: { enabled: true } }, + allow: ["brave"], + }, + }, + { + env: suiteEnv(), + pluginMetadataSnapshot: { + manifestRegistry: { + plugins: [], + diagnostics: [], + }, + }, + }, + ); + + expect(res.ok).toBe(true); + const message = + "plugin not installed: brave — install the official external plugin with: openclaw plugins install @openclaw/brave-plugin"; + expect(res.warnings ?? []).toEqual( + expect.arrayContaining([ + { path: "plugins.entries.brave", message }, + { path: "plugins.allow", message }, + ]), + ); + expect( + (res.warnings ?? []).some( + (warning) => + (warning.path === "plugins.entries.brave" || warning.path === "plugins.allow") && + warning.message.includes("remove it from plugins config"), + ), + ).toBe(false); + }); + it.runIf(process.platform !== "win32")( "reports configured blocked plugins without stale not-found wording", async () => { @@ -493,7 +531,7 @@ describe("config plugin validation", () => { expect(res.warnings ?? []).toContainEqual({ path: "plugins.allow", message: - "plugin not found: discord (stale config entry ignored; remove it from plugins config)", + "plugin not installed: discord — install the official external plugin with: openclaw plugins install @openclaw/discord", }); }); diff --git a/src/config/validation.ts b/src/config/validation.ts index ac60dfd8140..debc4f1526c 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -12,6 +12,10 @@ import { import { loadInstalledPluginIndexInstallRecordsSync } from "../plugins/installed-plugin-index-record-reader.js"; import { resolveManifestCommandAliasOwnerInRegistry } from "../plugins/manifest-command-aliases.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; +import { + getOfficialExternalPluginCatalogEntry, + resolveOfficialExternalPluginInstall, +} from "../plugins/official-external-plugin-catalog.js"; import { loadPluginMetadataSnapshot, type PluginMetadataSnapshot, @@ -93,6 +97,22 @@ function formatConfigPath(segments: readonly ConfigPathSegment[]): string { return segments.join("."); } +function formatMissingOfficialExternalPluginWarning(pluginId: string): string | null { + const catalogEntry = getOfficialExternalPluginCatalogEntry(pluginId); + if (!catalogEntry) { + return null; + } + const install = resolveOfficialExternalPluginInstall(catalogEntry); + const npmSpec = install?.npmSpec?.trim(); + const clawhubSpec = install?.clawhubSpec?.trim(); + const installSpec = + install?.defaultChoice === "clawhub" ? (clawhubSpec ?? npmSpec) : (npmSpec ?? clawhubSpec); + if (!installSpec) { + return null; + } + return `plugin not installed: ${pluginId} — install the official external plugin with: openclaw plugins install ${installSpec}`; +} + function asJsonSchemaLike(value: unknown): JsonSchemaLike | null { return value && typeof value === "object" ? (value as JsonSchemaLike) : null; } @@ -1441,6 +1461,16 @@ function validateConfigObjectWithPluginsBase( } return; } + if (opts?.warnOnly) { + const externalInstallWarning = formatMissingOfficialExternalPluginWarning(pluginId); + if (externalInstallWarning) { + warnings.push({ + path, + message: externalInstallWarning, + }); + return; + } + } if (opts?.warnOnly) { warnings.push({ path, From ac3cd1a0ca8c65a0a576c7f04072a63dd4912a95 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 15:23:41 -0700 Subject: [PATCH 076/107] Harden Codex harness control surfaces (#77459) * fix(scripts): find codex protocol source from worktrees * fix(test): keep codex harness docker caches writable * fix(test): relax live codex cache mount permissions * test(codex): add live docker harness debug output * fix(test): detect numeric ci env in codex docker harness * fix(codex): skip duplicate agent-command telemetry * fix(tooling): skip sparse-missing oxlint tsconfig * fix(tooling): route changed checks through testbox * fix(qa): keep coverage json source-clean * fix(test): preflight codex docker auth * fix(codex): validate bind option values * fix(codex): parse quoted command arguments * fix(codex): reject extra control args * fix(codex): use content for blank bound prompts * fix(codex): decode local image file urls * fix(codex): treat local media urls as images * fix(codex): keep windows media paths local * fix(codex): reject malformed diagnostics confirmations * fix(codex): reject malformed resume commands * fix(codex): reject malformed thread actions * fix(codex): reject malformed turn controls * fix(codex): reject malformed model controls * fix(codex): resolve empty user input prompts * fix(codex): enforce user input options * fix(codex): reject ambiguous computer-use actions * fix(codex): ignore stale bound turn notifications * test(gateway): close task registries in gateway harness * test(gateway): route cleanup through task seams * fix(codex): describe current permission approvals * fix(codex): disclose command approval amendments * fix(codex): preserve approval detail under truncation * fix(codex): propagate dynamic tool failures * test(codex): align dynamic tool block contract * fix(codex): reject extra read-only command operands * fix(codex): escape command readout fields * fix(codex): escape status probe errors * fix(codex): narrow formatted thread details * fix(codex): escape successful status summaries * fix(codex): escape bound control replies * fix(codex): escape user input prompts * fix(codex): escape control failure replies * fix(codex): escape approval prompt text * test(codex): narrow escaped reply assertions * test(codex): complete strict reply fixtures * test(codex): preserve account fixture literals * test(codex): align status probe fixtures * fix(codex): satisfy sanitizer regex lint * fix(codex): harden command readouts * fix(codex): harden bound image inputs * fix(codex): sanitize command failure replies * test(codex): complete rate limit fixture * test(tooling): isolate postinstall compile cache fixture * fix(codex): keep app-server event ownership explicit --------- Co-authored-by: pashpashpash --- CHANGELOG.md | 5 + .../src/app-server/approval-bridge.test.ts | 168 ++++ .../codex/src/app-server/approval-bridge.ts | 223 ++++- .../src/app-server/dynamic-tools.test.ts | 8 +- .../codex/src/app-server/dynamic-tools.ts | 5 +- .../src/app-server/elicitation-bridge.test.ts | 61 ++ .../src/app-server/elicitation-bridge.ts | 4 +- ...enclaw-owned-tool-runtime-contract.test.ts | 2 +- .../src/app-server/user-input-bridge.test.ts | 104 +++ .../codex/src/app-server/user-input-bridge.ts | 36 +- extensions/codex/src/command-formatters.ts | 154 +++- extensions/codex/src/command-handlers.ts | 231 ++++- extensions/codex/src/commands.test.ts | 834 +++++++++++++++++- extensions/codex/src/commands.ts | 10 +- .../codex/src/conversation-binding.test.ts | 83 +- extensions/codex/src/conversation-binding.ts | 5 +- .../codex/src/conversation-control.test.ts | 21 + extensions/codex/src/conversation-control.ts | 3 +- .../src/conversation-turn-collector.test.ts | 37 + .../codex/src/conversation-turn-collector.ts | 124 ++- .../codex/src/conversation-turn-input.test.ts | 97 ++ .../codex/src/conversation-turn-input.ts | 34 +- scripts/check-changed.mjs | 85 +- scripts/check-codex-app-server-protocol.ts | 22 +- .../lib/codex-app-server-protocol-source.ts | 74 ++ scripts/qa-coverage-report.ts | 56 ++ scripts/run-node.mjs | 33 + scripts/run-oxlint.mjs | 49 +- scripts/sync-codex-app-server-protocol.ts | 7 +- scripts/test-live-codex-harness-docker.sh | 45 +- src/agents/agent-command.ts | 8 - .../reply/agent-runner-execution.test.ts | 17 +- .../reply/agent-runner-execution.ts | 8 - src/auto-reply/reply/followup-runner.ts | 10 +- src/commands/agent.test.ts | 38 + src/gateway/test-helpers.server.ts | 6 + src/infra/run-node.test.ts | 40 + test/scripts/changed-lanes.test.ts | 40 + .../codex-app-server-protocol-source.test.ts | 58 ++ .../postinstall-bundled-plugins.test.ts | 10 +- test/scripts/run-oxlint.test.ts | 22 + .../test-live-codex-harness-docker.test.ts | 40 + 42 files changed, 2672 insertions(+), 245 deletions(-) create mode 100644 scripts/lib/codex-app-server-protocol-source.ts create mode 100644 scripts/qa-coverage-report.ts create mode 100644 test/scripts/codex-app-server-protocol-source.test.ts create mode 100644 test/scripts/test-live-codex-harness-docker.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dae1b57fede..6131b15b48f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - QA/Mantis: pass the runtime env through desktop-browser Crabbox and artifact-copy child commands, so embedded Mantis callers can provide Crabbox credentials without mutating the parent process. Thanks @vincentkoc. - QA/Mantis: return the copied Slack desktop screenshot path even when remote Slack QA fails, so the CLI still prints the failure screenshot artifact. Thanks @vincentkoc. - QA/Mantis: accept Blacksmith Testbox `tbx_...` lease ids from desktop smoke warmup, so provider overrides do not fail before inspect/run. Thanks @vincentkoc. +- QA/Codex harness: add targeted live Docker/Testbox diagnostics, auth preflight checks, cache mount fixes, and app-server protocol checkout discovery so maintainer harness failures are easier to reproduce. Thanks @vincentkoc. - Plugins/update: treat official externalized bundled npm migrations and ClawHub-to-npm fallbacks as trusted source-linked installs, so prerelease-only official plugin packages can migrate from bundled builds without being rejected as unsafe prerelease resolutions. Thanks @vincentkoc. - Plugins/update: move ClawHub-preferred externalized plugin installs back to ClawHub after an earlier npm fallback once the ClawHub package becomes available. Thanks @vincentkoc. - Plugins/update: clean stale bundled load paths for already-externalized pinned npm and ClawHub plugin installs, so release-channel sync does not leave removed bundled paths ahead of the installed external package. Thanks @vincentkoc. @@ -128,6 +129,9 @@ Docs: https://docs.openclaw.ai - Web search: scope explicit bundled `web_search` provider runtime loading through manifest ownership, so selecting DuckDuckGo/Gemini/etc. does not import unrelated bundled providers or log their optional dependency failures. Thanks @vincentkoc. - Plugins/discovery: demote the source-only TypeScript runtime check on already-installed `origin: "global"` plugin packages from a config-blocking error to a warning and let the runtime fall through to the TypeScript source via jiti, so a single broken installed package no longer blocks `plugins install` for unrelated plugins; install-time rejection of newly-installed source-only packages is unchanged. Thanks @romneyda. - Providers/OpenAI Codex: stop the OAuth progress spinner before showing the manual redirect paste prompt, so callback timeouts do not spam `Browser callback did not finish` across terminals. +- Providers/OpenAI Codex: fail closed on malformed `/codex` control commands and diagnostics confirmations before changing bindings, permissions, model overrides, active turns, or feedback uploads. Thanks @vincentkoc. +- Providers/OpenAI Codex: sanitize Codex app-server command readouts, failure replies, approval prompts, elicitation prompts, and `request_user_input` text before posting them back into chat. Thanks @vincentkoc. +- Providers/OpenAI Codex: preserve local bound-turn image paths, reject stale same-thread turn notifications, enforce option-only user input prompts, and return failed dynamic tool results to Codex as unsuccessful tool calls. Thanks @vincentkoc. - Providers/DeepSeek: expose DeepSeek V4 `xhigh` and `max` thinking levels through the lightweight provider-policy surface, so Control UI `/think` pickers keep showing the max reasoning options when the runtime plugin registry is not active. Fixes #77139. Thanks @bittoby. - Release/beta smoke: resolve the dispatched Telegram beta E2E run from `gh run list` when `gh workflow run` returns no run URL, so the maintainer helper does not fail immediately after dispatch. Thanks @vincentkoc. - Media/images: keep HEIC/HEIF attachments fail-closed when optional Sharp conversion is unavailable instead of sending originals that still need conversion. Thanks @vincentkoc. @@ -220,6 +224,7 @@ Docs: https://docs.openclaw.ai - OpenAI/Google Meet: fail realtime voice connection attempts when the socket closes before `session.updated`, avoiding stuck Meet joins waiting on a bridge that never became ready. Thanks @vincentkoc. - Google Meet: avoid treating repeated participant words as multiple assistant-overlap matches when suppressing realtime echo transcripts. Thanks @vincentkoc. - Google Meet: make `mode: "agent"` the default Chrome talk-back path, using realtime transcription for input and regular OpenClaw TTS for speech output, while keeping direct realtime voice answers available as `mode: "bidi"` and accepting `mode: "realtime"` as an agent-mode compatibility alias. +- Codex harness: keep `codex_app_server.*` telemetry publication owned by the harness instead of republishing the same callback event from core runners. Thanks @vincentkoc. - Slack/Discord: suppress standalone tool-progress chatter when partial preview streaming has `streaming.preview.toolProgress: false`, matching the documented quiet-preview behavior. Thanks @vincentkoc. - Matrix: bind native approval reaction targets before publishing option reactions, so fast approver reactions on threaded prompts are not dropped while the approval handler finishes setup. Thanks @vincentkoc. - Google Meet: make realtime talk-back agent-driven by default with `realtime.strategy: "agent"`, keep the previous direct bidirectional model behavior available as `realtime.strategy: "bidi"`, route the Meet tab speaker output to `BlackHole 2ch` automatically for local Chrome realtime joins, coalesce nearby speech transcript fragments before consulting the agent, and avoid cutting off agent speech from server VAD or stale playback pipe errors. diff --git a/extensions/codex/src/app-server/approval-bridge.test.ts b/extensions/codex/src/app-server/approval-bridge.test.ts index 1ef725f04bb..3276244b19d 100644 --- a/extensions/codex/src/app-server/approval-bridge.test.ts +++ b/extensions/codex/src/app-server/approval-bridge.test.ts @@ -118,6 +118,83 @@ describe("Codex app-server approval bridge", () => { ); }); + it("describes command approval permission and policy amendments", async () => { + const params = createParams(); + mockCallGatewayTool + .mockResolvedValueOnce({ id: "plugin:approval-command-permissions", status: "accepted" }) + .mockResolvedValueOnce({ + id: "plugin:approval-command-permissions", + decision: "allow-always", + }); + + const result = await handleCodexAppServerApprovalRequest({ + method: "item/commandExecution/requestApproval", + requestParams: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "cmd-permissions", + command: "npm install", + additionalPermissions: { + network: { enabled: true }, + fileSystem: { + write: ["/"], + }, + }, + proposedExecpolicyAmendment: ["npm install"], + proposedNetworkPolicyAmendments: [{ host: "registry.npmjs.org", action: "allow" }], + }, + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + }); + + expect(result).toEqual({ decision: "acceptForSession" }); + const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? []; + const description = (requestPayload as { description: string }).description; + expect(description).toContain("Command: npm install"); + expect(description).toContain("Additional permissions: network, fileSystem"); + expect(description).toContain("High-risk targets: network access, filesystem root"); + expect(description).toContain("Network enabled: true"); + expect(description).toContain("File system write: /"); + expect(description).toContain("Proposed exec policy: npm install"); + expect(description).toContain("Proposed network policy: allow registry.npmjs.org"); + }); + + it("keeps command approval permission details visible after long command previews", async () => { + const params = createParams(); + mockCallGatewayTool + .mockResolvedValueOnce({ id: "plugin:approval-long-command-permissions", status: "accepted" }) + .mockResolvedValueOnce({ + id: "plugin:approval-long-command-permissions", + decision: "allow-always", + }); + + await handleCodexAppServerApprovalRequest({ + method: "item/commandExecution/requestApproval", + requestParams: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "cmd-long-permissions", + command: `${"npm install ".repeat(500)} --unsafe-perm`, + additionalPermissions: { + network: { enabled: true }, + fileSystem: { + write: ["/"], + }, + }, + }, + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + }); + + const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? []; + const description = (requestPayload as { description: string }).description; + expect(description).toContain("[preview truncated or unsafe content omitted]"); + expect(description).toContain("Additional permissions: network, fileSystem"); + expect(description).toContain("High-risk targets: network access, filesystem root"); + }); + it("sanitizes command previews before forwarding approval text and events", async () => { const params = createParams(); mockCallGatewayTool @@ -155,6 +232,44 @@ describe("Codex app-server approval bridge", () => { ); }); + it("escapes command approval previews before forwarding approval text and events", async () => { + const params = createParams(); + mockCallGatewayTool + .mockResolvedValueOnce({ id: "plugin:approval-escaped-command", status: "accepted" }) + .mockResolvedValueOnce({ id: "plugin:approval-escaped-command", decision: "allow-once" }); + + await handleCodexAppServerApprovalRequest({ + method: "item/commandExecution/requestApproval", + requestParams: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "cmd-escaped", + command: "printf '<@U123> [trusted](https://evil) @here'", + }, + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + }); + + const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? []; + const description = (requestPayload as { description: string }).description; + expect(description).toContain( + "printf '<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here'", + ); + expect(description).not.toContain("<@U123>"); + expect(description).not.toContain("[trusted](https://evil)"); + expect(description).not.toContain("@here"); + expect(params.onAgentEvent).toHaveBeenCalledWith( + expect.objectContaining({ + stream: "approval", + data: expect.objectContaining({ + command: + "printf '<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here'", + }), + }), + ); + }); + it("preserves visible OSC-8 link labels in command previews", async () => { const params = createParams(); mockCallGatewayTool @@ -615,6 +730,59 @@ describe("Codex app-server approval bridge", () => { expect(description).toContain("readPaths: ~/.ssh/id_rsa, /etc/hosts"); }); + it("describes current protocol network and filesystem permission grants", async () => { + const params = createParams(); + mockCallGatewayTool + .mockResolvedValueOnce({ id: "plugin:approval-current-permissions", status: "accepted" }) + .mockResolvedValueOnce({ id: "plugin:approval-current-permissions", decision: "allow-once" }); + + const result = await handleCodexAppServerApprovalRequest({ + method: "item/permissions/requestApproval", + requestParams: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "perm-current", + permissions: { + network: { enabled: true }, + fileSystem: { + read: ["/Users/simone/.ssh/id_rsa"], + write: ["/"], + entries: [ + { path: "/workspace/project", access: "read" }, + { path: "/tmp/output", access: "write" }, + { path: "/ignored", access: "none" }, + ], + }, + }, + }, + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + }); + + expect(result).toEqual({ + permissions: { + network: { enabled: true }, + fileSystem: { + read: ["/Users/simone/.ssh/id_rsa"], + write: ["/"], + entries: [ + { path: "/workspace/project", access: "read" }, + { path: "/tmp/output", access: "write" }, + { path: "/ignored", access: "none" }, + ], + }, + }, + scope: "turn", + }); + const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? []; + const description = (requestPayload as { description: string }).description; + expect(description).toContain("Network enabled: true"); + expect(description).toContain("File system read: ~/.ssh/id_rsa; write: /"); + expect(description).toContain("entries: read /workspace/project, write /tmp/output (+1 more)"); + expect(description).toContain("High-risk targets: network access, filesystem root"); + }); + it("compacts Windows home paths in permission descriptions", async () => { const params = createParams(); mockCallGatewayTool diff --git a/extensions/codex/src/app-server/approval-bridge.ts b/extensions/codex/src/app-server/approval-bridge.ts index 7f9b278c10b..7c8fd2f4e9c 100644 --- a/extensions/codex/src/app-server/approval-bridge.ts +++ b/extensions/codex/src/app-server/approval-bridge.ts @@ -3,6 +3,7 @@ import { formatApprovalDisplayPath, type EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { formatCodexDisplayText } from "../command-formatters.js"; import { approvalRequestExplicitlyUnavailable, mapExecDecisionToOutcome, @@ -15,6 +16,7 @@ import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js"; const PERMISSION_DESCRIPTION_MAX_LENGTH = 700; const PERMISSION_SAMPLE_LIMIT = 2; const PERMISSION_VALUE_MAX_LENGTH = 48; +const COMMAND_PREVIEW_WITH_DETAILS_MAX_LENGTH = 80; const APPROVAL_PREVIEW_SCAN_MAX_LENGTH = 4096; const APPROVAL_PREVIEW_OMITTED = "[preview truncated or unsafe content omitted]"; const ANSI_OSC_SEQUENCE_RE = new RegExp( @@ -136,7 +138,9 @@ export async function handleCodexAppServerApprovalRequest(params: { ...approvalEventScope(params.method, cancelled ? "cancelled" : "denied"), message: cancelled ? "Codex app-server approval cancelled because the run stopped." - : `Codex app-server approval route failed: ${formatErrorMessage(error)}`, + : `Codex app-server approval route failed: ${formatCodexDisplayText( + formatErrorMessage(error), + )}`, }); return buildApprovalResponse( params.method, @@ -192,9 +196,13 @@ function buildApprovalContext(params: { readString(params.requestParams, "itemId") ?? readString(params.requestParams, "callId") ?? readString(params.requestParams, "approvalId"); + const commandDetailLines = + params.method === "item/commandExecution/requestApproval" + ? describeCommandApprovalDetails(params.requestParams) + : []; const commandPreview = sanitizeApprovalPreview( readDisplayCommandPreview(params.requestParams), - 180, + commandDetailLines.length > 0 ? COMMAND_PREVIEW_WITH_DETAILS_MAX_LENGTH : 180, ); const reasonPreview = sanitizeApprovalPreview( readStringPreview(params.requestParams, "reason"), @@ -229,7 +237,11 @@ function buildApprovalContext(params: { const description = permissionLines.length > 0 ? joinDescriptionLinesWithinLimit(permissionLines, PERMISSION_DESCRIPTION_MAX_LENGTH) - : [subject, params.paramsForRun.sessionKey && `Session: ${params.paramsForRun.sessionKey}`] + : [ + subject, + ...commandDetailLines, + params.paramsForRun.sessionKey && `Session: ${params.paramsForRun.sessionKey}`, + ] .filter(Boolean) .join("\n"); return { @@ -310,6 +322,35 @@ function unsupportedApprovalResponse(): JsonValue { function describeRequestedPermissions(requestParams: JsonObject | undefined): string[] { const permissions = requestedPermissions(requestParams); + return describePermissionProfile(permissions, "Permissions"); +} + +function describeCommandApprovalDetails(requestParams: JsonObject | undefined): string[] { + const lines: string[] = []; + const additionalPermissions = isJsonObject(requestParams?.additionalPermissions) + ? requestParams.additionalPermissions + : undefined; + if (additionalPermissions) { + lines.push(...describePermissionProfile(additionalPermissions, "Additional permissions")); + } + const execpolicySummary = summarizeStringArray( + requestParams?.proposedExecpolicyAmendment, + "Proposed exec policy", + sanitizePermissionScalar, + ); + if (execpolicySummary) { + lines.push(execpolicySummary); + } + const networkAmendmentSummary = summarizeNetworkPolicyAmendments( + requestParams?.proposedNetworkPolicyAmendments, + ); + if (networkAmendmentSummary) { + lines.push(networkAmendmentSummary); + } + return lines; +} + +function describePermissionProfile(permissions: JsonObject, label: string): string[] { const lines: string[] = []; const kinds: string[] = []; const risks = new Set(); @@ -320,41 +361,61 @@ function describeRequestedPermissions(requestParams: JsonObject | undefined): st kinds.push("fileSystem"); } if (kinds.length > 0) { - lines.push(`Permissions: ${kinds.join(", ")}`); + lines.push(`${label}: ${kinds.join(", ")}`); } let networkSummary: string | undefined; if (isJsonObject(permissions.network)) { - networkSummary = summarizePermissionRecord(permissions.network, risks, [ - { - key: "allowHosts", - label: "allowHosts", - sanitize: sanitizePermissionHostValue, - risksFor: permissionHostRisks, - }, - ]); + const summaries = [ + summarizeNetworkEnabledPermission(permissions.network, risks), + summarizePermissionRecord(permissions.network, risks, [ + { + key: "allowHosts", + label: "allowHosts", + sanitize: sanitizePermissionHostValue, + risksFor: permissionHostRisks, + }, + ]), + ].filter((summary): summary is string => Boolean(summary)); + networkSummary = summaries.length > 0 ? summaries.join("; ") : undefined; } let fileSystemSummary: string | undefined; if (isJsonObject(permissions.fileSystem)) { - fileSystemSummary = summarizePermissionRecord(permissions.fileSystem, risks, [ - { - key: "roots", - label: "roots", - sanitize: sanitizePermissionPathValue, - risksFor: permissionPathRisks, - }, - { - key: "readPaths", - label: "readPaths", - sanitize: sanitizePermissionPathValue, - risksFor: permissionPathRisks, - }, - { - key: "writePaths", - label: "writePaths", - sanitize: sanitizePermissionPathValue, - risksFor: permissionPathRisks, - }, - ]); + const summaries = [ + summarizePermissionRecord(permissions.fileSystem, risks, [ + { + key: "read", + label: "read", + sanitize: sanitizePermissionPathValue, + risksFor: permissionPathRisks, + }, + { + key: "write", + label: "write", + sanitize: sanitizePermissionPathValue, + risksFor: permissionPathRisks, + }, + { + key: "roots", + label: "roots", + sanitize: sanitizePermissionPathValue, + risksFor: permissionPathRisks, + }, + { + key: "readPaths", + label: "readPaths", + sanitize: sanitizePermissionPathValue, + risksFor: permissionPathRisks, + }, + { + key: "writePaths", + label: "writePaths", + sanitize: sanitizePermissionPathValue, + risksFor: permissionPathRisks, + }, + ]), + summarizeFileSystemEntries(permissions.fileSystem, risks), + ].filter((summary): summary is string => Boolean(summary)); + fileSystemSummary = summaries.length > 0 ? summaries.join("; ") : undefined; } if (risks.size > 0) { lines.push(`High-risk targets: ${[...risks].join(", ")}`); @@ -375,6 +436,55 @@ type PermissionArrayDescriptor = { risksFor: (value: string) => readonly string[]; }; +function summarizeNetworkEnabledPermission( + permission: JsonObject, + risks: Set, +): string | undefined { + const enabled = permission.enabled; + if (typeof enabled !== "boolean") { + return undefined; + } + if (enabled) { + risks.add("network access"); + } + return `enabled: ${enabled}`; +} + +function summarizeFileSystemEntries( + permission: JsonObject, + risks: Set, +): string | undefined { + const entries = permission.entries; + if (!Array.isArray(entries)) { + return undefined; + } + const samples: string[] = []; + let count = 0; + for (const entry of entries) { + const item = isJsonObject(entry) ? entry : undefined; + const path = typeof item?.path === "string" ? item.path.trim() : ""; + const access = typeof item?.access === "string" ? item.access.trim() : ""; + if (!path || !access) { + continue; + } + count += 1; + if (access !== "none") { + for (const risk of permissionPathRisks(path)) { + risks.add(risk); + } + } + if (samples.length < PERMISSION_SAMPLE_LIMIT) { + samples.push(`${sanitizePermissionScalar(access)} ${sanitizePermissionPathValue(path)}`); + } + } + if (count === 0) { + return undefined; + } + const remaining = count - samples.length; + const remainderSuffix = remaining > 0 ? ` (+${remaining} more)` : ""; + return `entries: ${samples.join(", ")}${remainderSuffix}`; +} + function summarizePermissionRecord( permission: JsonObject, risks: Set, @@ -416,6 +526,53 @@ function summarizePermissionArray( return `${descriptor.label}: ${sampleValues.join(", ")}${remainderSuffix}`; } +function summarizeStringArray( + value: JsonValue | undefined, + label: string, + sanitize: (value: string) => string, +): string | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const values = value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => sanitize(entry)) + .filter(Boolean); + if (values.length === 0) { + return undefined; + } + const samples = values.slice(0, PERMISSION_SAMPLE_LIMIT); + const remaining = values.length - samples.length; + const remainderSuffix = remaining > 0 ? ` (+${remaining} more)` : ""; + return `${label}: ${samples.join(", ")}${remainderSuffix}`; +} + +function summarizeNetworkPolicyAmendments(value: JsonValue | undefined): string | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const samples: string[] = []; + let count = 0; + for (const entry of value) { + const amendment = isJsonObject(entry) ? entry : undefined; + const host = typeof amendment?.host === "string" ? amendment.host : ""; + const action = typeof amendment?.action === "string" ? amendment.action : ""; + if (!host || !action) { + continue; + } + count += 1; + if (samples.length < PERMISSION_SAMPLE_LIMIT) { + samples.push(`${sanitizePermissionScalar(action)} ${sanitizePermissionHostValue(host)}`); + } + } + if (count === 0) { + return undefined; + } + const remaining = count - samples.length; + const remainderSuffix = remaining > 0 ? ` (+${remaining} more)` : ""; + return `Proposed network policy: ${samples.join(", ")}${remainderSuffix}`; +} + function readStringArray(record: JsonObject, key: string): string[] { const value = record[key]; return Array.isArray(value) @@ -693,7 +850,7 @@ function sanitizeApprovalPreview( if (!sanitized) { return { omitted: true }; } - return { text: truncate(sanitized, maxLength), omitted: source.clipped }; + return { text: formatCodexDisplayText(truncate(sanitized, maxLength)), omitted: source.clipped }; } function sanitizeVisibleScalar(value: string): string { diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index 14460326d06..9176c7f864f 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -314,7 +314,7 @@ describe("createCodexDynamicToolBridge", () => { details: { status: "failed", exitCode: 1 }, }); - await bridge.handleToolCall({ + const result = await bridge.handleToolCall({ threadId: "thread-1", turnId: "turn-1", callId: "call-1", @@ -323,6 +323,10 @@ describe("createCodexDynamicToolBridge", () => { arguments: { command: "false" }, }); + expect(result).toEqual({ + success: false, + contentItems: [{ type: "inputText", text: "failed output" }], + }); expect(handler).toHaveBeenCalledWith( expect.objectContaining({ isError: true }), expect.objectContaining({ runtime: "codex" }), @@ -641,7 +645,7 @@ describe("createCodexDynamicToolBridge", () => { }); expect(result).toEqual({ - success: true, + success: false, contentItems: [{ type: "inputText", text: "blocked by policy" }], }); expect(execute).not.toHaveBeenCalled(); diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 285fe2979e2..dfb02bd5e2d 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -119,13 +119,14 @@ export function createCodexDynamicToolBridge(params: { args, result: middlewareResult, }); + const resultIsError = rawIsError || isToolResultError(result); collectToolTelemetry({ toolName: tool.name, args, result, mediaTrustResult: rawResult, telemetry, - isError: rawIsError || isToolResultError(result), + isError: resultIsError, }); void runAgentHarnessAfterToolCallHook({ toolName: tool.name, @@ -140,7 +141,7 @@ export function createCodexDynamicToolBridge(params: { }); return { contentItems: result.content.flatMap(convertToolContent), - success: true, + success: !resultIsError, }; } catch (error) { collectToolTelemetry({ diff --git a/extensions/codex/src/app-server/elicitation-bridge.test.ts b/extensions/codex/src/app-server/elicitation-bridge.test.ts index d21f581d8e2..1139f2514b1 100644 --- a/extensions/codex/src/app-server/elicitation-bridge.test.ts +++ b/extensions/codex/src/app-server/elicitation-bridge.test.ts @@ -243,6 +243,67 @@ describe("Codex app-server elicitation bridge", () => { expect(approvalRequest.description).not.toContain("\u202e"); }); + it("escapes approval display text before forwarding approval prompts", async () => { + mockCallGatewayTool + .mockResolvedValueOnce({ id: "plugin:approval-escaped", status: "accepted" }) + .mockResolvedValueOnce({ id: "plugin:approval-escaped", decision: "allow-once" }); + + await handleCodexAppServerElicitationRequest({ + requestParams: { + ...buildCurrentCodexApprovalElicitation(), + message: "Approve <@U123>", + serverName: "server @here", + _meta: { + codex_approval_kind: "mcp_tool_call", + connector_name: "GitHub [trusted](https://evil)", + tool_title: "Create <@U123>", + tool_description: "Use @here", + tool_params_display: [ + { + name: "repo", + display_name: "Repository [trusted](https://evil)", + value: "<@U123>", + }, + ], + }, + requestedSchema: { + type: "object", + properties: { + approve: { + type: "boolean", + title: "Approve <@U123>", + description: "Confirm @here", + }, + }, + required: ["approve"], + }, + }, + paramsForRun: createParams(), + threadId: "thread-1", + turnId: "turn-1", + }); + + const approvalRequest = mockCallGatewayTool.mock.calls[0]?.[2] as { + title: string; + description: string; + }; + expect(approvalRequest.title).toBe("Approve <\uff20U123>"); + expect(approvalRequest.description).toContain( + "GitHub \uff3btrusted\uff3d\uff08https://evil\uff09", + ); + expect(approvalRequest.description).toContain("Tool: Create <\uff20U123>"); + expect(approvalRequest.description).toContain("MCP server: server \uff20here"); + expect(approvalRequest.description).toContain( + "Repository \uff3btrusted\uff3d\uff08https://evil\uff09: <\uff20U123>", + ); + expect(approvalRequest.description).toContain( + "- Approve <\uff20U123>: Confirm \uff20here", + ); + expect(approvalRequest.description).not.toContain("<@U123>"); + expect(approvalRequest.description).not.toContain("[trusted](https://evil)"); + expect(approvalRequest.description).not.toContain("@here"); + }); + it("falls back to stable names when display labels sanitize to empty", async () => { mockCallGatewayTool .mockResolvedValueOnce({ id: "plugin:approval-label-fallback", status: "accepted" }) diff --git a/extensions/codex/src/app-server/elicitation-bridge.ts b/extensions/codex/src/app-server/elicitation-bridge.ts index 017c3d7c4d9..a91aa8a4305 100644 --- a/extensions/codex/src/app-server/elicitation-bridge.ts +++ b/extensions/codex/src/app-server/elicitation-bridge.ts @@ -2,6 +2,7 @@ import { embeddedAgentLog, type EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { formatCodexDisplayText } from "../command-formatters.js"; import { approvalRequestExplicitlyUnavailable, mapExecDecisionToOutcome, @@ -283,7 +284,8 @@ function sanitizeDisplayText(value: string): string { .replace(CONTROL_CHARACTER_RE, " ") .replace(/\s+/g, " ") .trim(); - return clipped ? `${sanitized}...` : sanitized; + const escaped = sanitized ? formatCodexDisplayText(sanitized) : ""; + return clipped && escaped ? `${escaped}...` : escaped; } function truncateDisplayText(value: string, maxLength: number): string { diff --git a/extensions/codex/src/app-server/openclaw-owned-tool-runtime-contract.test.ts b/extensions/codex/src/app-server/openclaw-owned-tool-runtime-contract.test.ts index ad9bcbc6379..1961573d915 100644 --- a/extensions/codex/src/app-server/openclaw-owned-tool-runtime-contract.test.ts +++ b/extensions/codex/src/app-server/openclaw-owned-tool-runtime-contract.test.ts @@ -188,7 +188,7 @@ describe("OpenClaw-owned tool runtime contract — Codex app-server adapter", () }); expect(result).toEqual({ - success: true, + success: false, contentItems: [{ type: "inputText", text: "blocked by policy" }], }); expect(execute).not.toHaveBeenCalled(); diff --git a/extensions/codex/src/app-server/user-input-bridge.test.ts b/extensions/codex/src/app-server/user-input-bridge.test.ts index ec39264cbba..7ef88c1cc93 100644 --- a/extensions/codex/src/app-server/user-input-bridge.test.ts +++ b/extensions/codex/src/app-server/user-input-bridge.test.ts @@ -98,6 +98,87 @@ describe("Codex app-server user input bridge", () => { }); }); + it("rejects free-form option replies when Other is disabled", async () => { + const params = createParams(); + const bridge = createCodexUserInputBridge({ + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + }); + + const response = bridge.handleRequest({ + id: "input-options", + params: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "tool-1", + questions: [ + { + id: "mode", + header: "Mode", + question: "Pick a mode", + isOther: false, + isSecret: false, + options: [{ label: "Fast", description: "Use less reasoning" }], + }, + ], + }, + }); + + await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1)); + expect(bridge.handleQueuedMessage("banana")).toBe(true); + + await expect(response).resolves.toEqual({ + answers: { mode: { answers: [] } }, + }); + }); + + it("escapes prompt question and option text before chat display", async () => { + const params = createParams(); + const bridge = createCodexUserInputBridge({ + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + }); + + const response = bridge.handleRequest({ + id: "input-escaped", + params: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "tool-1", + questions: [ + { + id: "mode", + header: "Mode <@U123>", + question: "Pick [trusted](https://evil) @here", + isOther: false, + isSecret: false, + options: [{ label: "Fast <@U123>", description: "Use [less](https://evil)" }], + }, + ], + }, + }); + + await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1)); + const payload = vi.mocked(params.onBlockReply!).mock.calls[0]?.[0]; + expect(payload).toEqual(expect.objectContaining({ text: expect.any(String) })); + const text = payload?.text ?? ""; + expect(text).toContain("Mode <\uff20U123>"); + expect(text).toContain("Pick \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here"); + expect(text).toContain( + "Fast <\uff20U123> - Use \uff3bless\uff3d\uff08https://evil\uff09", + ); + expect(text).not.toContain("<@U123>"); + expect(text).not.toContain("[trusted](https://evil)"); + expect(text).not.toContain("@here"); + + expect(bridge.handleQueuedMessage("1")).toBe(true); + await expect(response).resolves.toEqual({ + answers: { mode: { answers: ["Fast <@U123>"] } }, + }); + }); + it("clears pending prompts when Codex resolves the server request itself", async () => { const params = createParams(); const bridge = createCodexUserInputBridge({ @@ -134,4 +215,27 @@ describe("Codex app-server user input bridge", () => { await expect(response).resolves.toEqual({ answers: {} }); expect(bridge.handleQueuedMessage("too late")).toBe(false); }); + + it("resolves malformed empty question prompts without waiting for chat input", async () => { + const params = createParams(); + const bridge = createCodexUserInputBridge({ + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + }); + + await expect( + bridge.handleRequest({ + id: "input-empty", + params: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "tool-1", + questions: [], + }, + }), + ).resolves.toEqual({ answers: {} }); + expect(params.onBlockReply).not.toHaveBeenCalled(); + expect(bridge.handleQueuedMessage("late answer")).toBe(false); + }); }); diff --git a/extensions/codex/src/app-server/user-input-bridge.ts b/extensions/codex/src/app-server/user-input-bridge.ts index 360c46651ee..880308ab6ba 100644 --- a/extensions/codex/src/app-server/user-input-bridge.ts +++ b/extensions/codex/src/app-server/user-input-bridge.ts @@ -2,6 +2,7 @@ import { embeddedAgentLog, type EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { formatCodexDisplayText } from "../command-formatters.js"; import { isJsonObject, type CodexServerNotification, @@ -70,6 +71,9 @@ export function createCodexUserInputBridge(params: { if (requestParams.threadId !== params.threadId || requestParams.turnId !== params.turnId) { return undefined; } + if (requestParams.questions.length === 0) { + return emptyUserInputResponse(); + } resolvePending(emptyUserInputResponse()); @@ -205,16 +209,26 @@ function formatUserInputPrompt(questions: UserInputQuestion[]): string { const lines = ["Codex needs input:"]; questions.forEach((question, index) => { if (questions.length > 1) { - lines.push("", `${index + 1}. ${question.header}`, question.question); + lines.push( + "", + `${index + 1}. ${formatCodexDisplayText(question.header)}`, + formatCodexDisplayText(question.question), + ); } else { - lines.push("", question.header, question.question); + lines.push( + "", + formatCodexDisplayText(question.header), + formatCodexDisplayText(question.question), + ); } if (question.isSecret) { lines.push("This channel may show your reply to other participants."); } question.options?.forEach((option, optionIndex) => { lines.push( - `${optionIndex + 1}. ${option.label}${option.description ? ` - ${option.description}` : ""}`, + `${optionIndex + 1}. ${formatCodexDisplayText(option.label)}${ + option.description ? ` - ${formatCodexDisplayText(option.description)}` : "" + }`, ); }); if (question.isOther) { @@ -229,7 +243,8 @@ function buildUserInputResponse(questions: UserInputQuestion[], inputText: strin if (questions.length === 1) { const question = questions[0]; if (question) { - answers[question.id] = { answers: [normalizeAnswer(inputText, question)] }; + const answer = normalizeAnswer(inputText, question); + answers[question.id] = { answers: answer ? [answer] : [] }; } return { answers }; } @@ -246,12 +261,13 @@ function buildUserInputResponse(questions: UserInputQuestion[], inputText: strin keyed.get(question.question.toLowerCase()) ?? keyed.get(String(index + 1)); const answer = key ?? fallbackLines[index] ?? ""; - answers[question.id] = { answers: answer ? [normalizeAnswer(answer, question)] : [] }; + const normalized = answer ? normalizeAnswer(answer, question) : undefined; + answers[question.id] = { answers: normalized ? [normalized] : [] }; }); return { answers }; } -function normalizeAnswer(answer: string, question: UserInputQuestion): string { +function normalizeAnswer(answer: string, question: UserInputQuestion): string | undefined { const trimmed = answer.trim(); const options = question.options ?? []; const optionIndex = /^\d+$/.test(trimmed) ? Number(trimmed) - 1 : -1; @@ -260,7 +276,13 @@ function normalizeAnswer(answer: string, question: UserInputQuestion): string { return indexed.label; } const exact = options.find((option) => option.label.toLowerCase() === trimmed.toLowerCase()); - return exact?.label ?? trimmed; + if (exact) { + return exact.label; + } + if (options.length > 0 && !question.isOther) { + return undefined; + } + return trimmed || undefined; } function parseKeyedAnswers(inputText: string): Map { diff --git a/extensions/codex/src/command-formatters.ts b/extensions/codex/src/command-formatters.ts index a6f935dd429..bf8855e5598 100644 --- a/extensions/codex/src/command-formatters.ts +++ b/extensions/codex/src/command-formatters.ts @@ -19,25 +19,41 @@ export function formatCodexStatus(probes: CodexStatusProbes): string { lines.push( `Models: ${ probes.models.value.models - .map((model) => model.id) + .map((model) => formatCodexDisplayText(model.id)) .slice(0, 8) .join(", ") || "none" }`, ); } else { - lines.push(`Models: ${probes.models.error}`); + lines.push(`Models: ${formatCodexDisplayText(probes.models.error)}`); } lines.push( - `Account: ${probes.account.ok ? summarizeAccount(probes.account.value) : probes.account.error}`, + `Account: ${ + probes.account.ok + ? formatCodexAccountSummary(probes.account.value) + : formatCodexDisplayText(probes.account.error) + }`, ); lines.push( - `Rate limits: ${probes.limits.ok ? summarizeArrayLike(probes.limits.value) : probes.limits.error}`, + `Rate limits: ${ + probes.limits.ok + ? summarizeRateLimits(probes.limits.value) + : formatCodexDisplayText(probes.limits.error) + }`, ); lines.push( - `MCP servers: ${probes.mcps.ok ? summarizeArrayLike(probes.mcps.value) : probes.mcps.error}`, + `MCP servers: ${ + probes.mcps.ok + ? summarizeArrayLike(probes.mcps.value) + : formatCodexDisplayText(probes.mcps.error) + }`, ); lines.push( - `Skills: ${probes.skills.ok ? summarizeArrayLike(probes.skills.value) : probes.skills.error}`, + `Skills: ${ + probes.skills.ok + ? summarizeArrayLike(probes.skills.value) + : formatCodexDisplayText(probes.skills.error) + }`, ); return lines.join("\n"); } @@ -48,7 +64,9 @@ export function formatModels(result: CodexAppServerModelListResult): string { } const lines = [ "Codex models:", - ...result.models.map((model) => `- ${model.id}${model.isDefault ? " (default)" : ""}`), + ...result.models.map( + (model) => `- ${formatCodexDisplayText(model.id)}${model.isDefault ? " (default)" : ""}`, + ), ]; if (result.truncated) { lines.push("- More models available; output truncated."); @@ -72,10 +90,10 @@ export function formatThreads(response: JsonValue | undefined): string { readString(record, "model"), readString(record, "cwd"), readString(record, "updatedAt") ?? readString(record, "lastUpdatedAt"), - ].filter(Boolean); - return `- ${id}${title ? ` - ${title}` : ""}${ - details.length > 0 ? ` (${details.join(", ")})` : "" - }\n Resume: /codex resume ${id}`; + ].filter((value): value is string => Boolean(value)); + return `- ${formatCodexDisplayText(id)}${title ? ` - ${formatCodexDisplayText(title)}` : ""}${ + details.length > 0 ? ` (${details.map(formatCodexDisplayText).join(", ")})` : "" + }\n Resume: ${formatCodexResumeHint(id)}`; }), ].join("\n"); } @@ -85,8 +103,8 @@ export function formatAccount( limits: SafeValue, ): string { return [ - `Account: ${account.ok ? summarizeAccount(account.value) : account.error}`, - `Rate limits: ${limits.ok ? summarizeArrayLike(limits.value) : limits.error}`, + `Account: ${account.ok ? formatCodexAccountSummary(account.value) : formatCodexDisplayText(account.error)}`, + `Rate limits: ${limits.ok ? summarizeRateLimits(limits.value) : formatCodexDisplayText(limits.error)}`, ].join("\n"); } @@ -94,19 +112,21 @@ export function formatComputerUseStatus(status: CodexComputerUseStatus): string const lines = [ `Computer Use: ${status.ready ? "ready" : status.enabled ? "not ready" : "disabled"}`, ]; - lines.push(`Plugin: ${status.pluginName} (${computerUsePluginState(status)})`); lines.push( - `MCP server: ${status.mcpServerName}${ + `Plugin: ${formatCodexDisplayText(status.pluginName)} (${computerUsePluginState(status)})`, + ); + lines.push( + `MCP server: ${formatCodexDisplayText(status.mcpServerName)}${ status.mcpServerAvailable ? ` (${status.tools.length} tools)` : " (unavailable)" }`, ); if (status.marketplaceName) { - lines.push(`Marketplace: ${status.marketplaceName}`); + lines.push(`Marketplace: ${formatCodexDisplayText(status.marketplaceName)}`); } if (status.tools.length > 0) { - lines.push(`Tools: ${status.tools.slice(0, 8).join(", ")}`); + lines.push(`Tools: ${status.tools.slice(0, 8).map(formatCodexDisplayText).join(", ")}`); } - lines.push(status.message); + lines.push(formatCodexDisplayText(status.message)); return lines.join("\n"); } @@ -126,11 +146,85 @@ export function formatList(response: JsonValue | undefined, label: string): stri `${label}:`, ...entries.slice(0, 25).map((entry) => { const record = isJsonObject(entry) ? entry : {}; - return `- ${readString(record, "name") ?? readString(record, "id") ?? JSON.stringify(entry)}`; + return `- ${formatCodexDisplayText( + readString(record, "name") ?? readString(record, "id") ?? JSON.stringify(entry), + )}`; }), ].join("\n"); } +const CODEX_RESUME_SAFE_THREAD_ID_PATTERN = /^[A-Za-z0-9._:-]+$/; + +function formatCodexResumeHint(threadId: string): string { + const safe = formatCodexTextForDisplay(threadId); + if (!CODEX_RESUME_SAFE_THREAD_ID_PATTERN.test(safe)) { + return "copy the thread id above and run /codex resume "; + } + return `/codex resume ${safe}`; +} + +export function formatCodexDisplayText(value: string): string { + return escapeCodexChatText(formatCodexTextForDisplay(value)); +} + +function formatCodexAccountSummary(value: JsonValue | undefined): string { + const safe = formatCodexTextForDisplay(summarizeAccount(value)); + return isLikelyEmailAddress(safe) + ? escapeCodexChatTextPreservingAt(safe) + : escapeCodexChatText(safe); +} + +function formatCodexTextForDisplay(value: string): string { + let safe = ""; + for (const character of value) { + const codePoint = character.codePointAt(0); + safe += codePoint != null && isUnsafeDisplayCodePoint(codePoint) ? "?" : character; + } + safe = safe.trim(); + return safe || ""; +} + +function escapeCodexChatText(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("@", "\uff20") + .replaceAll("`", "\uff40") + .replaceAll("[", "\uff3b") + .replaceAll("]", "\uff3d") + .replaceAll("(", "\uff08") + .replaceAll(")", "\uff09") + .replaceAll("*", "\u2217") + .replaceAll("_", "\uff3f") + .replaceAll("~", "\uff5e") + .replaceAll("|", "\uff5c"); +} + +function escapeCodexChatTextPreservingAt(value: string): string { + return escapeCodexChatText(value).replaceAll("\uff20", "@"); +} + +function isLikelyEmailAddress(value: string): boolean { + return /^[^\s@<>()[\]`]+@[^\s@<>()[\]`]+\.[^\s@<>()[\]`]+$/.test(value); +} + +function isUnsafeDisplayCodePoint(codePoint: number): boolean { + return ( + codePoint <= 0x001f || + (codePoint >= 0x007f && codePoint <= 0x009f) || + codePoint === 0x00ad || + codePoint === 0x061c || + codePoint === 0x180e || + (codePoint >= 0x200b && codePoint <= 0x200f) || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2060 && codePoint <= 0x206f) || + codePoint === 0xfeff || + (codePoint >= 0xfff9 && codePoint <= 0xfffb) || + (codePoint >= 0xe0000 && codePoint <= 0xe007f) + ); +} + export function buildHelp(): string { return [ "Codex commands:", @@ -182,6 +276,28 @@ function summarizeArrayLike(value: JsonValue | undefined): string { return `${entries.length}`; } +function summarizeRateLimits(value: JsonValue | undefined): string { + const entries = extractArray(value); + if (entries.length > 0) { + return `${entries.length}`; + } + if (!isJsonObject(value)) { + return "none returned"; + } + const keyed = value.rateLimitsByLimitId; + if (isJsonObject(keyed)) { + const count = Object.values(keyed).filter(isMeaningfulRateLimitSnapshot).length; + if (count > 0) { + return `${count}`; + } + } + return isMeaningfulRateLimitSnapshot(value.rateLimits) ? "1" : "none returned"; +} + +function isMeaningfulRateLimitSnapshot(value: JsonValue | undefined): boolean { + return isJsonObject(value) && Object.values(value).some((entry) => entry != null); +} + function extractArray(value: JsonValue | undefined): JsonValue[] { if (Array.isArray(value)) { return value; diff --git a/extensions/codex/src/command-handlers.ts b/extensions/codex/src/command-handlers.ts index 761b6ac1813..b6d141859c2 100644 --- a/extensions/codex/src/command-handlers.ts +++ b/extensions/codex/src/command-handlers.ts @@ -18,6 +18,7 @@ import { buildHelp, formatAccount, formatComputerUseStatus, + formatCodexDisplayText, formatCodexStatus, formatList, formatModels, @@ -120,7 +121,8 @@ type ParsedComputerUseArgs = { type ParsedDiagnosticsArgs = | { action: "request"; note: string } | { action: "confirm"; token: string } - | { action: "cancel"; token: string }; + | { action: "cancel"; token: string } + | { action: "usage" }; type CodexDiagnosticsTarget = { threadId: string; @@ -185,11 +187,17 @@ export async function handleCodexSubcommand( return { text: buildHelp() }; } if (normalized === "status") { + if (rest.length > 0) { + return { text: "Usage: /codex status" }; + } return { text: formatCodexStatus(await deps.readCodexStatusProbes(options.pluginConfig, ctx.config)), }; } if (normalized === "models") { + if (rest.length > 0) { + return { text: "Usage: /codex models" }; + } return { text: formatModels( await deps.listCodexAppServerModels( @@ -202,31 +210,40 @@ export async function handleCodexSubcommand( return { text: await buildThreads(deps, options.pluginConfig, rest.join(" ")) }; } if (normalized === "resume") { - return { text: await resumeThread(deps, ctx, options.pluginConfig, rest[0]) }; + return { text: await resumeThread(deps, ctx, options.pluginConfig, rest) }; } if (normalized === "bind") { return await bindConversation(deps, ctx, options.pluginConfig, rest); } if (normalized === "detach" || normalized === "unbind") { + if (rest.length > 0) { + return { text: "Usage: /codex detach" }; + } return { text: await detachConversation(deps, ctx) }; } if (normalized === "binding") { + if (rest.length > 0) { + return { text: "Usage: /codex binding" }; + } return { text: await describeConversationBinding(deps, ctx) }; } if (normalized === "stop") { + if (rest.length > 0) { + return { text: "Usage: /codex stop" }; + } return { text: await stopConversationTurn(deps, ctx, options.pluginConfig) }; } if (normalized === "steer") { return { text: await steerConversationTurn(deps, ctx, options.pluginConfig, rest.join(" ")) }; } if (normalized === "model") { - return { text: await setConversationModel(deps, ctx, options.pluginConfig, rest.join(" ")) }; + return { text: await setConversationModel(deps, ctx, options.pluginConfig, rest) }; } if (normalized === "fast") { - return { text: await setConversationFastMode(deps, ctx, options.pluginConfig, rest[0]) }; + return { text: await setConversationFastMode(deps, ctx, options.pluginConfig, rest) }; } if (normalized === "permissions") { - return { text: await setConversationPermissions(deps, ctx, options.pluginConfig, rest[0]) }; + return { text: await setConversationPermissions(deps, ctx, options.pluginConfig, rest) }; } if (normalized === "compact") { return { @@ -236,6 +253,7 @@ export async function handleCodexSubcommand( options.pluginConfig, CODEX_CONTROL_METHODS.compact, "compaction", + rest, ), }; } @@ -247,6 +265,7 @@ export async function handleCodexSubcommand( options.pluginConfig, CODEX_CONTROL_METHODS.review, "review", + rest, ), }; } @@ -265,6 +284,9 @@ export async function handleCodexSubcommand( }; } if (normalized === "mcp") { + if (rest.length > 0) { + return { text: "Usage: /codex mcp" }; + } return { text: formatList( await deps.codexControlRequest(options.pluginConfig, CODEX_CONTROL_METHODS.listMcpServers, { @@ -275,6 +297,9 @@ export async function handleCodexSubcommand( }; } if (normalized === "skills") { + if (rest.length > 0) { + return { text: "Usage: /codex skills" }; + } return { text: formatList( await deps.codexControlRequest(options.pluginConfig, CODEX_CONTROL_METHODS.listSkills, {}), @@ -283,6 +308,9 @@ export async function handleCodexSubcommand( }; } if (normalized === "account") { + if (rest.length > 0) { + return { text: "Usage: /codex account" }; + } const [account, limits] = await Promise.all([ deps.safeCodexControlRequest(options.pluginConfig, CODEX_CONTROL_METHODS.account, { refreshToken: false, @@ -295,7 +323,7 @@ export async function handleCodexSubcommand( ]); return { text: formatAccount(account, limits) }; } - return { text: `Unknown Codex command: ${subcommand}\n\n${buildHelp()}` }; + return { text: `Unknown Codex command: ${formatCodexDisplayText(subcommand)}\n\n${buildHelp()}` }; } async function handleComputerUseCommand( @@ -327,17 +355,17 @@ async function bindConversation( pluginConfig: unknown, args: string[], ): Promise { - if (!ctx.sessionFile) { - return { - text: "Cannot bind Codex because this command did not include an OpenClaw session file.", - }; - } const parsed = parseBindArgs(args); if (parsed.help) { return { text: "Usage: /codex bind [thread-id] [--cwd ] [--model ] [--provider ]", }; } + if (!ctx.sessionFile) { + return { + text: "Cannot bind Codex because this command did not include an OpenClaw session file.", + }; + } const workspaceDir = parsed.cwd ?? deps.resolveCodexDefaultWorkspaceDir(pluginConfig); const existingBinding = await deps.readCodexAppServerBinding(ctx.sessionFile); const authProfileId = existingBinding?.authProfileId; @@ -356,7 +384,7 @@ async function bindConversation( const data = await deps.startCodexConversationThread(startParams); const binding = await deps.readCodexAppServerBinding(ctx.sessionFile); const threadId = binding?.threadId ?? parsed.threadId ?? "new thread"; - const summary = `Codex app-server thread ${threadId} in ${workspaceDir}`; + const summary = `Codex app-server thread ${formatCodexDisplayText(threadId)} in ${formatCodexDisplayText(workspaceDir)}`; let request: Awaited>; try { request = await ctx.requestConversationBinding({ @@ -369,13 +397,17 @@ async function bindConversation( throw error; } if (request.status === "bound") { - return { text: `Bound this conversation to Codex thread ${threadId} in ${workspaceDir}.` }; + return { + text: `Bound this conversation to Codex thread ${formatCodexDisplayText( + threadId, + )} in ${formatCodexDisplayText(workspaceDir)}.`, + }; } if (request.status === "pending") { return request.reply; } await deps.clearCodexAppServerBinding(ctx.sessionFile); - return { text: request.message }; + return { text: formatCodexDisplayText(request.message) }; } async function detachConversation( @@ -408,13 +440,13 @@ async function describeConversationBinding( const active = deps.readCodexConversationActiveTurn(data.sessionFile); return [ "Codex conversation binding:", - `- Thread: ${threadBinding?.threadId ?? "unknown"}`, - `- Workspace: ${data.workspaceDir}`, - `- Model: ${threadBinding?.model ?? "default"}`, + `- Thread: ${formatCodexDisplayText(threadBinding?.threadId ?? "unknown")}`, + `- Workspace: ${formatCodexDisplayText(data.workspaceDir)}`, + `- Model: ${formatCodexDisplayText(threadBinding?.model ?? "default")}`, `- Fast: ${threadBinding?.serviceTier === "fast" ? "on" : "off"}`, `- Permissions: ${threadBinding ? formatPermissionsMode(threadBinding) : "default"}`, - `- Active run: ${active ? active.turnId : "none"}`, - `- Session: ${data.sessionFile}`, + `- Active run: ${formatCodexDisplayText(active ? active.turnId : "none")}`, + `- Session: ${formatCodexDisplayText(data.sessionFile)}`, ].join("\n"); } @@ -434,10 +466,11 @@ async function resumeThread( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, - threadId: string | undefined, + args: string[], ): Promise { + const [threadId] = args; const normalizedThreadId = threadId?.trim(); - if (!normalizedThreadId) { + if (!normalizedThreadId || args.length !== 1) { return "Usage: /codex resume "; } if (!ctx.sessionFile) { @@ -459,7 +492,9 @@ async function resumeThread( model: isJsonObject(response) ? readString(response, "model") : undefined, modelProvider: isJsonObject(response) ? readString(response, "modelProvider") : undefined, }); - return `Attached this OpenClaw session to Codex thread ${effectiveThreadId}.`; + return `Attached this OpenClaw session to Codex thread ${formatCodexDisplayText( + effectiveThreadId, + )}.`; } async function stopConversationTurn( @@ -497,16 +532,22 @@ async function setConversationModel( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, - model: string, + args: string[], ): Promise { + if (args.length > 1) { + return "Usage: /codex model "; + } const sessionFile = await resolveControlSessionFile(ctx); if (!sessionFile) { return "Cannot set Codex model because this command did not include an OpenClaw session file."; } + const [model = ""] = args; const normalized = model.trim(); if (!normalized) { const binding = await deps.readCodexAppServerBinding(sessionFile); - return binding?.model ? `Codex model: ${binding.model}` : "Usage: /codex model "; + return binding?.model + ? `Codex model: ${formatCodexDisplayText(binding.model)}` + : "Usage: /codex model "; } return await deps.setCodexConversationModel({ sessionFile, @@ -519,12 +560,16 @@ async function setConversationFastMode( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, - value: string | undefined, + args: string[], ): Promise { + if (args.length > 1) { + return "Usage: /codex fast [on|off|status]"; + } const sessionFile = await resolveControlSessionFile(ctx); if (!sessionFile) { return "Cannot set Codex fast mode because this command did not include an OpenClaw session file."; } + const value = args[0]; const parsed = parseCodexFastModeArg(value); if (value && parsed == null && value.trim().toLowerCase() !== "status") { return "Usage: /codex fast [on|off|status]"; @@ -540,12 +585,16 @@ async function setConversationPermissions( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, - value: string | undefined, + args: string[], ): Promise { + if (args.length > 1) { + return "Usage: /codex permissions [default|yolo|status]"; + } const sessionFile = await resolveControlSessionFile(ctx); if (!sessionFile) { return "Cannot set Codex permissions because this command did not include an OpenClaw session file."; } + const value = args[0]; const parsed = parseCodexPermissionsModeArg(value); if (value && !parsed && value.trim().toLowerCase() !== "status") { return "Usage: /codex permissions [default|yolo|status]"; @@ -573,6 +622,9 @@ async function handleCodexDiagnosticsFeedback( return { text: "Only an owner can send Codex diagnostics." }; } const parsed = parseDiagnosticsArgs(args); + if (parsed.action === "usage") { + return { text: formatDiagnosticsUsage(commandPrefix) }; + } if (parsed.action === "confirm") { return { text: await confirmCodexDiagnosticsFeedback(deps, ctx, pluginConfig, parsed.token), @@ -998,17 +1050,41 @@ function normalizeDiagnosticsReason(note: string): string | undefined { } function parseDiagnosticsArgs(args: string): ParsedDiagnosticsArgs { - const [action, token] = splitArgs(args); + const [action, token, ...extra] = splitArgs(args); const normalizedAction = action?.toLowerCase(); - if ((normalizedAction === "confirm" || normalizedAction === "--confirm") && token) { + if ( + (normalizedAction === "confirm" || normalizedAction === "--confirm") && + token && + extra.length === 0 + ) { return { action: "confirm", token }; } - if ((normalizedAction === "cancel" || normalizedAction === "--cancel") && token) { + if ( + (normalizedAction === "cancel" || normalizedAction === "--cancel") && + token && + extra.length === 0 + ) { return { action: "cancel", token }; } + if ( + normalizedAction === "confirm" || + normalizedAction === "--confirm" || + normalizedAction === "cancel" || + normalizedAction === "--cancel" + ) { + return { action: "usage" }; + } return { action: "request", note: args }; } +function formatDiagnosticsUsage(commandPrefix: string): string { + return [ + `Usage: ${commandPrefix} [note]`, + `Usage: ${commandPrefix} confirm `, + `Usage: ${commandPrefix} cancel `, + ].join("\n"); +} + function createCodexDiagnosticsConfirmation(params: { targets: CodexDiagnosticsTarget[]; note?: string; @@ -1396,7 +1472,11 @@ async function startThreadAction( pluginConfig: unknown, method: typeof CODEX_CONTROL_METHODS.compact | typeof CODEX_CONTROL_METHODS.review, label: string, + args: string[], ): Promise { + if (args.length > 0) { + return `Usage: /codex ${label === "compaction" ? "compact" : label}`; + } const sessionFile = await resolveControlSessionFile(ctx); if (!sessionFile) { return `Cannot start Codex ${label} because this command did not include an OpenClaw session file.`; @@ -1413,11 +1493,60 @@ async function startThreadAction( } else { await deps.codexControlRequest(pluginConfig, method, { threadId: binding.threadId }); } - return `Started Codex ${label} for thread ${binding.threadId}.`; + return `Started Codex ${label} for thread ${formatCodexDisplayText(binding.threadId)}.`; } function splitArgs(value: string | undefined): string[] { - return (value ?? "").trim().split(/\s+/).filter(Boolean); + const input = value ?? ""; + const args: string[] = []; + let current = ""; + let quote: '"' | "'" | undefined; + let escaping = false; + let tokenStarted = false; + for (const char of input) { + if (escaping) { + current += char; + escaping = false; + tokenStarted = true; + continue; + } + if (char === "\\" && quote !== "'") { + escaping = true; + tokenStarted = true; + continue; + } + if (quote) { + if (char === quote) { + quote = undefined; + } else { + current += char; + } + tokenStarted = true; + continue; + } + if (char === '"' || char === "'") { + quote = char; + tokenStarted = true; + continue; + } + if (/\s/.test(char)) { + if (tokenStarted) { + args.push(current); + current = ""; + tokenStarted = false; + } + continue; + } + current += char; + tokenStarted = true; + } + if (escaping) { + current += "\\"; + } + if (tokenStarted) { + args.push(current); + } + return args; } function parseBindArgs(args: string[]): ParsedBindArgs { @@ -1429,17 +1558,32 @@ function parseBindArgs(args: string[]): ParsedBindArgs { continue; } if (arg === "--cwd") { - parsed.cwd = args[index + 1]; + const value = readRequiredOptionValue(args, index); + if (!value || parsed.cwd !== undefined) { + parsed.help = true; + continue; + } + parsed.cwd = value; index += 1; continue; } if (arg === "--model") { - parsed.model = args[index + 1]; + const value = readRequiredOptionValue(args, index); + if (!value || parsed.model !== undefined) { + parsed.help = true; + continue; + } + parsed.model = value; index += 1; continue; } if (arg === "--provider" || arg === "--model-provider") { - parsed.provider = args[index + 1]; + const value = readRequiredOptionValue(args, index); + if (!value || parsed.provider !== undefined) { + parsed.help = true; + continue; + } + parsed.provider = value; index += 1; continue; } @@ -1462,6 +1606,7 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { overrides: {}, hasOverrides: false, }; + let sawAction = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--help" || arg === "-h") { @@ -1469,12 +1614,17 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { continue; } if (arg === "status" || arg === "install") { + if (sawAction) { + parsed.help = true; + continue; + } + sawAction = true; parsed.action = arg; continue; } if (arg === "--source" || arg === "--marketplace-source") { const value = readRequiredOptionValue(args, index); - if (!value) { + if (!value || parsed.overrides.marketplaceSource !== undefined) { parsed.help = true; continue; } @@ -1484,7 +1634,7 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { } if (arg === "--marketplace-path" || arg === "--path") { const value = readRequiredOptionValue(args, index); - if (!value) { + if (!value || parsed.overrides.marketplacePath !== undefined) { parsed.help = true; continue; } @@ -1494,7 +1644,7 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { } if (arg === "--marketplace") { const value = readRequiredOptionValue(args, index); - if (!value) { + if (!value || parsed.overrides.marketplaceName !== undefined) { parsed.help = true; continue; } @@ -1504,7 +1654,7 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { } if (arg === "--plugin") { const value = readRequiredOptionValue(args, index); - if (!value) { + if (!value || parsed.overrides.pluginName !== undefined) { parsed.help = true; continue; } @@ -1514,7 +1664,7 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { } if (arg === "--server" || arg === "--mcp-server") { const value = readRequiredOptionValue(args, index); - if (!value) { + if (!value || parsed.overrides.mcpServerName !== undefined) { parsed.help = true; continue; } @@ -1531,7 +1681,8 @@ function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { function readRequiredOptionValue(args: string[], index: number): string | undefined { const value = args[index + 1]; - if (!value || value.startsWith("-")) { + const normalized = value?.trim(); + if (!normalized || normalized.startsWith("-")) { return undefined; } return value; diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index ab09902df6b..e669ae7ed1b 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -105,6 +105,13 @@ describe("codex command", () => { await fs.rm(tempDir, { recursive: true, force: true }); }); + it("escapes unknown subcommands before chat display", async () => { + const result = await handleCodexCommand(createContext("<@U123> [trusted](https://evil) @here")); + + expect(result.text).toContain("Unknown Codex command: <\uff20U123>"); + expect(result.text).not.toContain("<@U123>"); + }); + 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 }> = []; @@ -138,6 +145,42 @@ describe("codex command", () => { ); }); + it("rejects malformed resume commands before attaching a Codex thread", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const codexControlRequest = vi.fn(); + const writeCodexAppServerBinding = vi.fn(); + + await expect( + handleCodexCommand(createContext("resume thread-123 extra", sessionFile), { + deps: createDeps({ codexControlRequest, writeCodexAppServerBinding }), + }), + ).resolves.toEqual({ + text: "Usage: /codex resume ", + }); + expect(codexControlRequest).not.toHaveBeenCalled(); + expect(writeCodexAppServerBinding).not.toHaveBeenCalled(); + }); + + it("escapes resumed Codex thread ids before chat display", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const unsafe = "thread-123 <@U123> [trusted](https://evil)"; + const deps = createDeps({ + codexControlRequest: vi.fn(async () => ({ + thread: { id: unsafe, cwd: "/repo" }, + })), + }); + + const result = await handleCodexCommand(createContext("resume thread-123", sessionFile), { + deps, + }); + + expect(result.text).toContain( + "thread-123 <\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + }); + it("shows model ids from Codex app-server", async () => { const config = { auth: { order: { "openai-codex": ["openai-codex:work"] } } }; const deps = createDeps({ @@ -183,6 +226,49 @@ describe("codex command", () => { }); }); + it("escapes Codex app-server model ids before chat display", async () => { + const deps = createDeps({ + listCodexAppServerModels: vi.fn(async () => ({ + models: [ + { + id: "gpt-5.4 <@U123> [trusted](https://evil)", + model: "gpt-5.4", + inputModalities: ["text"], + supportedReasoningEfforts: ["medium"], + }, + ], + })), + }); + + const result = await handleCodexCommand(createContext("models"), { deps }); + + expect(result.text).toContain( + "gpt-5.4 <\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + }); + + it("escapes markdown underscores in Codex app-server readouts", async () => { + const deps = createDeps({ + listCodexAppServerModels: vi.fn(async () => ({ + models: [ + { + id: "unsafe_model_name", + model: "unsafe_model_name", + inputModalities: ["text"], + supportedReasoningEfforts: ["medium"], + }, + ], + })), + }); + + const result = await handleCodexCommand(createContext("models"), { deps }); + + expect(result.text).toContain("unsafe\uff3fmodel\uff3fname"); + expect(result.text).not.toContain("unsafe_model_name"); + }); + it("reports status unavailable when every Codex probe fails", async () => { const config = { auth: { order: { "openai-codex": ["openai-codex:work"] } } }; const offline = { ok: false as const, error: "offline" }; @@ -211,6 +297,184 @@ describe("codex command", () => { expect(deps.readCodexStatusProbes).toHaveBeenCalledWith(undefined, config); }); + it("escapes Codex status probe errors before chat display", async () => { + const unsafe = "<@U123> [trusted](https://evil) @here"; + const offline = { ok: false as const, error: unsafe }; + const deps = createDeps({ + readCodexStatusProbes: vi.fn(async () => ({ + models: offline, + account: offline, + limits: offline, + mcps: offline, + skills: offline, + })), + }); + + const result = await handleCodexCommand(createContext("status"), { deps }); + + expect(result.text).toContain( + "<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + expect(result.text).not.toContain("@here"); + }); + + it("escapes successful Codex status model ids and account summaries", async () => { + const unsafe = "<@U123> [trusted](https://evil) @here"; + const deps = createDeps({ + readCodexStatusProbes: vi.fn(async () => ({ + models: { + ok: true as const, + value: { + models: [ + { + id: unsafe, + model: unsafe, + inputModalities: ["text"], + supportedReasoningEfforts: ["medium"], + }, + ], + }, + }, + account: { + ok: true as const, + value: { + account: { + type: "chatgpt" as const, + email: unsafe, + planType: "plus" as const, + }, + requiresOpenaiAuth: false, + }, + }, + limits: { + ok: true as const, + value: { + rateLimits: { + limitId: null, + limitName: null, + primary: null, + secondary: null, + credits: null, + planType: null, + rateLimitReachedType: null, + }, + rateLimitsByLimitId: null, + }, + }, + mcps: { ok: true as const, value: { data: [], nextCursor: null } }, + skills: { ok: true as const, value: { data: [] } }, + })), + }); + + const result = await handleCodexCommand(createContext("status"), { deps }); + + expect(result.text).toContain( + "<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + expect(result.text).not.toContain("@here"); + }); + + it("summarizes generated Codex rate-limit payloads", async () => { + const limits = { + ok: true as const, + value: { + rateLimits: { + limitId: "codex", + limitName: "Codex", + primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: null }, + secondary: null, + credits: null, + planType: null, + rateLimitReachedType: null, + }, + rateLimitsByLimitId: { + codex: { + limitId: "codex", + limitName: "Codex", + primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: null }, + secondary: null, + credits: null, + planType: null, + rateLimitReachedType: null, + }, + }, + }, + }; + const deps = createDeps({ + readCodexStatusProbes: vi.fn(async () => ({ + models: { ok: false as const, error: "offline" }, + account: { ok: false as const, error: "offline" }, + limits, + mcps: { ok: true as const, value: { data: [], nextCursor: null } }, + skills: { ok: true as const, value: { data: [] } }, + })), + safeCodexControlRequest: vi + .fn() + .mockResolvedValueOnce({ + ok: true as const, + value: { account: { email: "codex@example.com" } }, + }) + .mockResolvedValueOnce(limits), + }); + + await expect(handleCodexCommand(createContext("status"), { deps })).resolves.toMatchObject({ + text: expect.stringContaining("Rate limits: 1"), + }); + await expect(handleCodexCommand(createContext("account"), { deps })).resolves.toMatchObject({ + text: expect.stringContaining("Rate limits: 1"), + }); + }); + + it("rejects extra operands for read-only Codex commands", async () => { + const readCodexStatusProbes = vi.fn(); + const listCodexAppServerModels = vi.fn(); + const safeCodexControlRequest = vi.fn(); + const codexControlRequest = vi.fn(); + const getCurrentConversationBinding = vi.fn(); + const deps = createDeps({ + codexControlRequest, + listCodexAppServerModels, + readCodexStatusProbes, + safeCodexControlRequest, + }); + + await expect(handleCodexCommand(createContext("status now"), { deps })).resolves.toEqual({ + text: "Usage: /codex status", + }); + await expect(handleCodexCommand(createContext("models all"), { deps })).resolves.toEqual({ + text: "Usage: /codex models", + }); + await expect(handleCodexCommand(createContext("account refresh"), { deps })).resolves.toEqual({ + text: "Usage: /codex account", + }); + await expect(handleCodexCommand(createContext("mcp list"), { deps })).resolves.toEqual({ + text: "Usage: /codex mcp", + }); + await expect(handleCodexCommand(createContext("skills list"), { deps })).resolves.toEqual({ + text: "Usage: /codex skills", + }); + await expect( + handleCodexCommand( + createContext("binding current", undefined, { + getCurrentConversationBinding, + }), + { deps }, + ), + ).resolves.toEqual({ + text: "Usage: /codex binding", + }); + + expect(readCodexStatusProbes).not.toHaveBeenCalled(); + expect(listCodexAppServerModels).not.toHaveBeenCalled(); + expect(safeCodexControlRequest).not.toHaveBeenCalled(); + expect(codexControlRequest).not.toHaveBeenCalled(); + expect(getCurrentConversationBinding).not.toHaveBeenCalled(); + }); + it("formats generated account/read responses", async () => { const safeCodexControlRequest = vi .fn() @@ -235,6 +499,44 @@ describe("codex command", () => { }); }); + it("escapes Codex account probe errors before chat display", async () => { + const unsafe = "<@U123> [trusted](https://evil) @here"; + const safeCodexControlRequest = vi + .fn() + .mockResolvedValueOnce({ ok: false as const, error: unsafe }) + .mockResolvedValueOnce({ ok: false as const, error: unsafe }); + + const result = await handleCodexCommand(createContext("account"), { + deps: createDeps({ safeCodexControlRequest }), + }); + + expect(result.text).toContain( + "<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + expect(result.text).not.toContain("@here"); + }); + + it("escapes successful Codex account fallback summaries before chat display", async () => { + const unsafe = "<@U123> [trusted](https://evil) @here"; + const safeCodexControlRequest = vi + .fn() + .mockResolvedValueOnce({ ok: true as const, value: { account: { id: unsafe } } }) + .mockResolvedValueOnce({ ok: true as const, value: [] }); + + const result = await handleCodexCommand(createContext("account"), { + deps: createDeps({ safeCodexControlRequest }), + }); + + expect(result.text).toContain( + "<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + expect(result.text).not.toContain("@here"); + }); + it("formats generated Amazon Bedrock account responses", async () => { const safeCodexControlRequest = vi .fn() @@ -295,6 +597,43 @@ describe("codex command", () => { }); }); + it("rejects malformed compact and review commands before starting thread actions", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const codexControlRequest = vi.fn(); + + await expect( + handleCodexCommand(createContext("compact now", sessionFile), { + deps: createDeps({ codexControlRequest }), + }), + ).resolves.toEqual({ + text: "Usage: /codex compact", + }); + await expect( + handleCodexCommand(createContext("review staged", sessionFile), { + deps: createDeps({ codexControlRequest }), + }), + ).resolves.toEqual({ + text: "Usage: /codex review", + }); + expect(codexControlRequest).not.toHaveBeenCalled(); + }); + + it("escapes started thread-action ids before chat display", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-123 <@U123>", cwd: "/repo" }), + ); + const codexControlRequest = vi.fn(async () => ({})); + + const result = await handleCodexCommand(createContext("compact", sessionFile), { + deps: createDeps({ codexControlRequest }), + }); + + expect(result.text).toContain("thread-123 <\uff20U123>"); + expect(result.text).not.toContain("<@U123>"); + }); + it("checks Codex Computer Use setup", async () => { const readCodexComputerUseStatus = vi.fn(async () => computerUseReadyStatus()); @@ -308,7 +647,7 @@ describe("codex command", () => { "Plugin: computer-use (installed)", "MCP server: computer-use (1 tools)", "Marketplace: desktop-tools", - "Tools: list_apps", + "Tools: list\uff3fapps", "Computer Use is ready.", ].join("\n"), }); @@ -318,6 +657,34 @@ describe("codex command", () => { }); }); + it("escapes Codex Computer Use status fields before chat display", async () => { + const readCodexComputerUseStatus = vi.fn(async () => ({ + ...computerUseReadyStatus(), + pluginName: "<@U123>", + mcpServerName: "computer-use [server](https://evil)", + marketplaceName: "desktop_tools", + tools: ["list_apps", "[click](https://evil)"], + message: "Computer Use is ready @here.", + })); + + const result = await handleCodexCommand(createContext("computer-use status"), { + deps: createDeps({ readCodexComputerUseStatus }), + }); + + expect(result.text).toContain("Plugin: <\uff20U123> (installed)"); + expect(result.text).toContain( + "MCP server: computer-use \uff3bserver\uff3d\uff08https://evil\uff09 (2 tools)", + ); + expect(result.text).toContain("Marketplace: desktop\uff3ftools"); + expect(result.text).toContain( + "Tools: list\uff3fapps, \uff3bclick\uff3d\uff08https://evil\uff09", + ); + expect(result.text).toContain("Computer Use is ready \uff20here."); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[click](https://evil)"); + expect(result.text).not.toContain("@here"); + }); + it("formats disabled installed Codex Computer Use plugins", async () => { const readCodexComputerUseStatus = vi.fn(async () => ({ ...computerUseReadyStatus(), @@ -377,6 +744,21 @@ describe("codex command", () => { expect(installCodexComputerUse).not.toHaveBeenCalled(); }); + it("rejects ambiguous Computer Use actions before setup checks", async () => { + const readCodexComputerUseStatus = vi.fn(async () => computerUseReadyStatus()); + const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus()); + + await expect( + handleCodexCommand(createContext("computer-use status install"), { + deps: createDeps({ readCodexComputerUseStatus, installCodexComputerUse }), + }), + ).resolves.toEqual({ + text: expect.stringContaining("Usage: /codex computer-use"), + }); + expect(readCodexComputerUseStatus).not.toHaveBeenCalled(); + expect(installCodexComputerUse).not.toHaveBeenCalled(); + }); + it("explains compaction when no Codex thread is attached", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); @@ -481,6 +863,53 @@ describe("codex command", () => { ); }); + it("rejects malformed diagnostics confirmation commands without consuming the token", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-confirm-args", cwd: "/repo" }), + ); + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: { threadId: "thread-confirm-args" }, + })); + const deps = createDeps({ safeCodexControlRequest }); + + const request = await handleCodexCommand(createContext("diagnostics", sessionFile), { deps }); + const token = readDiagnosticsConfirmationToken(request); + + await expect( + handleCodexCommand(createContext(`diagnostics confirm ${token} extra`, sessionFile), { + deps, + }), + ).resolves.toEqual({ + text: [ + "Usage: /codex diagnostics [note]", + "Usage: /codex diagnostics confirm ", + "Usage: /codex diagnostics cancel ", + ].join("\n"), + }); + await expect( + handleCodexCommand(createContext(`diagnostics cancel ${token} extra`, sessionFile), { + deps, + }), + ).resolves.toEqual({ + text: [ + "Usage: /codex diagnostics [note]", + "Usage: /codex diagnostics confirm ", + "Usage: /codex diagnostics cancel ", + ].join("\n"), + }); + expect(safeCodexControlRequest).not.toHaveBeenCalled(); + + await expect( + handleCodexCommand(createContext(`diagnostics confirm ${token}`, sessionFile), { deps }), + ).resolves.toMatchObject({ + text: expect.stringContaining("Codex diagnostics sent to OpenAI servers:"), + }); + expect(safeCodexControlRequest).toHaveBeenCalledTimes(1); + }); + it("previews exec-approved diagnostics upload without exposing Codex ids", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); await fs.writeFile( @@ -1386,6 +1815,86 @@ describe("codex command", () => { }); }); + it("escapes Codex thread fields and avoids unsafe resume commands", async () => { + const codexControlRequest = vi.fn(async () => ({ + data: [ + { + id: "thread-123\n`bad`", + title: "<@U123> [trusted](https://evil) @here", + model: "gpt_5", + cwd: "/repo_(x)", + }, + ], + })); + const deps = createDeps({ codexControlRequest }); + + const result = await handleCodexCommand(createContext("threads"), { deps }); + + expect(result.text).toContain("thread-123?\uff40bad\uff40"); + expect(result.text).toContain( + "<\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here", + ); + expect(result.text).toContain("(gpt\uff3f5, /repo\uff3f\uff08x\uff09)"); + expect(result.text).toContain( + "Resume: copy the thread id above and run /codex resume ", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + expect(result.text).not.toContain("Resume: /codex resume thread-123"); + }); + + it("escapes Codex MCP and skill list entries before chat display", async () => { + const codexControlRequest = vi + .fn() + .mockResolvedValueOnce({ data: [{ name: "<@U123> [mcp](https://evil)" }] }) + .mockResolvedValueOnce({ data: [{ id: "skill_1 @here" }] }); + const deps = createDeps({ codexControlRequest }); + + const mcp = await handleCodexCommand(createContext("mcp"), { deps }); + const skills = await handleCodexCommand(createContext("skills"), { deps }); + + expect(mcp.text).toContain("<\uff20U123> \uff3bmcp\uff3d\uff08https://evil\uff09"); + expect(skills.text).toContain("skill\uff3f1 \uff20here"); + expect(`${mcp.text}\n${skills.text}`).not.toContain("<@U123>"); + expect(`${mcp.text}\n${skills.text}`).not.toContain("[mcp](https://evil)"); + expect(`${mcp.text}\n${skills.text}`).not.toContain("@here"); + }); + + it("returns sanitized command failures instead of leaking app-server errors", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-123", cwd: "/repo" }), + ); + const failure = () => { + throw new Error("app-server failed <@U123> [trusted](https://evil) @here"); + }; + const expectSanitizedFailure = (result: PluginCommandResult) => { + expect(result.text).toContain( + "Codex command failed: app-server failed <\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + expect(result.text).not.toContain("@here"); + }; + + for (const [args, deps] of [ + ["models", createDeps({ listCodexAppServerModels: vi.fn(failure) })], + ["threads", createDeps({ codexControlRequest: vi.fn(failure) })], + ["mcp", createDeps({ codexControlRequest: vi.fn(failure) })], + ["skills", createDeps({ codexControlRequest: vi.fn(failure) })], + ["resume thread-123", createDeps({ codexControlRequest: vi.fn(failure) })], + ["compact", createDeps({ codexControlRequest: vi.fn(failure) })], + ["review", createDeps({ codexControlRequest: vi.fn(failure) })], + ["bind", createDeps({ startCodexConversationThread: vi.fn(failure) })], + ["stop", createDeps({ stopCodexConversationTurn: vi.fn(failure) })], + ["steer keep going", createDeps({ steerCodexConversationTurn: vi.fn(failure) })], + ["model gpt-5.4", createDeps({ setCodexConversationModel: vi.fn(failure) })], + ] as const) { + expectSanitizedFailure(await handleCodexCommand(createContext(args, sessionFile), { deps })); + } + }); + it("binds the current conversation to a Codex app-server thread", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); await fs.writeFile( @@ -1458,6 +1967,170 @@ describe("codex command", () => { }); }); + it("binds quoted workspace paths that contain spaces", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const startCodexConversationThread = vi.fn(async () => ({ + kind: "codex-app-server-session" as const, + version: 1 as const, + sessionFile, + workspaceDir: "/repo with space", + })); + const requestConversationBinding = vi.fn(async () => ({ + status: "bound" as const, + binding: { + bindingId: "binding-1", + pluginId: "codex", + pluginRoot: "/plugin", + channel: "test", + accountId: "default", + conversationId: "conversation", + boundAt: 1, + }, + })); + + await expect( + handleCodexCommand( + createContext('bind thread-123 --cwd "/repo with space"', sessionFile, { + requestConversationBinding, + }), + { + deps: createDeps({ + startCodexConversationThread, + resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"), + }), + }, + ), + ).resolves.toEqual({ + text: "Bound this conversation to Codex thread thread-123 in /repo with space.", + }); + expect(startCodexConversationThread).toHaveBeenCalledWith({ + pluginConfig: undefined, + config: {}, + sessionFile, + workspaceDir: "/repo with space", + threadId: "thread-123", + model: undefined, + modelProvider: undefined, + }); + }); + + it("escapes bound Codex thread ids and workspace paths before chat display", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const unsafeThread = "thread-123 <@U123>"; + const unsafeWorkspace = "/repo [trusted](https://evil)"; + const startCodexConversationThread = vi.fn(async () => ({ + kind: "codex-app-server-session" as const, + version: 1 as const, + sessionFile, + workspaceDir: unsafeWorkspace, + })); + const requestConversationBinding = vi.fn(async () => ({ + status: "bound" as const, + binding: { + bindingId: "binding-1", + pluginId: "codex", + pluginRoot: "/plugin", + channel: "test", + accountId: "default", + conversationId: "conversation", + boundAt: 1, + }, + })); + + const result = await handleCodexCommand( + createContext(`bind "${unsafeThread}" --cwd "${unsafeWorkspace}"`, sessionFile, { + requestConversationBinding, + }), + { + deps: createDeps({ + startCodexConversationThread, + resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"), + }), + }, + ); + + expect(result.text).toContain("thread-123 <\uff20U123>"); + expect(result.text).toContain("/repo \uff3btrusted\uff3d\uff08https://evil\uff09"); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + expect(requestConversationBinding).toHaveBeenCalledWith( + expect.objectContaining({ + summary: + "Codex app-server thread thread-123 <\uff20U123> in /repo \uff3btrusted\uff3d\uff08https://evil\uff09", + }), + ); + }); + + it("rejects bind options with missing, blank, or repeated values before starting Codex", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const startCodexConversationThread = vi.fn(); + const requestConversationBinding = vi.fn(); + + await expect( + handleCodexCommand( + createContext("bind thread-123 --cwd --model gpt-5.4", sessionFile, { + requestConversationBinding, + }), + { + deps: createDeps({ + startCodexConversationThread, + resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"), + }), + }, + ), + ).resolves.toEqual({ + text: "Usage: /codex bind [thread-id] [--cwd ] [--model ] [--provider ]", + }); + await expect( + handleCodexCommand( + createContext('bind thread-123 --cwd ""', sessionFile, { + requestConversationBinding, + }), + { + deps: createDeps({ + startCodexConversationThread, + resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"), + }), + }, + ), + ).resolves.toEqual({ + text: "Usage: /codex bind [thread-id] [--cwd ] [--model ] [--provider ]", + }); + await expect( + handleCodexCommand( + createContext("bind thread-123 --cwd /repo --cwd /other", sessionFile, { + requestConversationBinding, + }), + { + deps: createDeps({ + startCodexConversationThread, + resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"), + }), + }, + ), + ).resolves.toEqual({ + text: "Usage: /codex bind [thread-id] [--cwd ] [--model ] [--provider ]", + }); + expect(startCodexConversationThread).not.toHaveBeenCalled(); + expect(requestConversationBinding).not.toHaveBeenCalled(); + }); + + it("rejects malformed bind arguments before requiring a session file", async () => { + const startCodexConversationThread = vi.fn(); + + await expect( + handleCodexCommand(createContext("bind thread-123 --cwd", undefined), { + deps: createDeps({ + startCodexConversationThread, + resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"), + }), + }), + ).resolves.toEqual({ + text: "Usage: /codex bind [thread-id] [--cwd ] [--model ] [--provider ]", + }); + expect(startCodexConversationThread).not.toHaveBeenCalled(); + }); + it("returns the binding approval reply when conversation bind needs approval", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const reply = { text: "Approve this?" }; @@ -1494,7 +2167,7 @@ describe("codex command", () => { createContext("bind", sessionFile, { requestConversationBinding: async () => ({ status: "error", - message: "binding unsupported", + message: "binding unsupported <@U123> [trusted](https://evil)", }), }), { @@ -1510,7 +2183,9 @@ describe("codex command", () => { }), }, ), - ).resolves.toEqual({ text: "binding unsupported" }); + ).resolves.toEqual({ + text: "binding unsupported <\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09", + }); expect(clearCodexAppServerBinding).toHaveBeenCalledWith(sessionFile); }); @@ -1548,6 +2223,25 @@ describe("codex command", () => { expect(clearCodexAppServerBinding).toHaveBeenCalledWith(sessionFile); }); + it("rejects malformed detach commands before clearing bindings", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const clearCodexAppServerBinding = vi.fn(); + const detachConversationBinding = vi.fn(); + + await expect( + handleCodexCommand( + createContext("detach now", sessionFile, { + detachConversationBinding, + }), + { deps: createDeps({ clearCodexAppServerBinding }) }, + ), + ).resolves.toEqual({ + text: "Usage: /codex detach", + }); + expect(detachConversationBinding).not.toHaveBeenCalled(); + expect(clearCodexAppServerBinding).not.toHaveBeenCalled(); + }); + it("stops the active bound Codex turn", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const stopCodexConversationTurn = vi.fn(async () => ({ @@ -1566,6 +2260,18 @@ describe("codex command", () => { }); }); + it("rejects malformed stop commands before interrupting Codex", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const stopCodexConversationTurn = vi.fn(); + + await expect( + handleCodexCommand(createContext("stop now", sessionFile), { + deps: createDeps({ stopCodexConversationTurn }), + }), + ).resolves.toEqual({ text: "Usage: /codex stop" }); + expect(stopCodexConversationTurn).not.toHaveBeenCalled(); + }); + it("steers the active bound Codex turn", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const steerCodexConversationTurn = vi.fn(async () => ({ @@ -1625,6 +2331,86 @@ describe("codex command", () => { }); }); + it("escapes current bound model status before chat display", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ + schemaVersion: 1, + threadId: "thread-model", + cwd: "/repo", + model: "model_<@U123>_[trusted](https://evil)", + }), + ); + + const result = await handleCodexCommand(createContext("model", sessionFile), { + deps: createDeps(), + }); + + expect(result.text).toContain( + "model\uff3f<\uff20U123>\uff3f\uff3btrusted\uff3d\uff08https://evil\uff09", + ); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + }); + + it("rejects malformed model commands before persisting the model", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const setCodexConversationModel = vi.fn(); + + await expect( + handleCodexCommand(createContext("model gpt-5.4 extra", sessionFile), { + deps: createDeps({ setCodexConversationModel }), + }), + ).resolves.toEqual({ text: "Usage: /codex model " }); + expect(setCodexConversationModel).not.toHaveBeenCalled(); + }); + + it("rejects extra fast and permissions arguments", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const setCodexConversationFastMode = vi.fn(); + const setCodexConversationPermissions = vi.fn(); + const deps = createDeps({ + setCodexConversationFastMode, + setCodexConversationPermissions, + }); + + await expect( + handleCodexCommand(createContext("fast on now", sessionFile), { deps }), + ).resolves.toEqual({ text: "Usage: /codex fast [on|off|status]" }); + await expect( + handleCodexCommand(createContext("permissions yolo now", sessionFile), { deps }), + ).resolves.toEqual({ text: "Usage: /codex permissions [default|yolo|status]" }); + + expect(setCodexConversationFastMode).not.toHaveBeenCalled(); + expect(setCodexConversationPermissions).not.toHaveBeenCalled(); + }); + + it("rejects malformed control arguments before requiring a session file", async () => { + const deps = createDeps({ + setCodexConversationModel: vi.fn(), + setCodexConversationFastMode: vi.fn(), + setCodexConversationPermissions: vi.fn(), + }); + + await expect( + handleCodexCommand(createContext("model gpt-5.4 extra"), { deps }), + ).resolves.toEqual({ + text: "Usage: /codex model ", + }); + await expect(handleCodexCommand(createContext("fast on now"), { deps })).resolves.toEqual({ + text: "Usage: /codex fast [on|off|status]", + }); + await expect( + handleCodexCommand(createContext("permissions yolo now"), { deps }), + ).resolves.toEqual({ + text: "Usage: /codex permissions [default|yolo|status]", + }); + expect(deps.setCodexConversationModel).not.toHaveBeenCalled(); + expect(deps.setCodexConversationFastMode).not.toHaveBeenCalled(); + expect(deps.setCodexConversationPermissions).not.toHaveBeenCalled(); + }); + it("uses current plugin binding data for follow-up control commands", async () => { const hostSessionFile = path.join(tempDir, "host-session.jsonl"); const pluginSessionFile = path.join(tempDir, "plugin-session.jsonl"); @@ -1717,10 +2503,50 @@ describe("codex command", () => { "- Fast: on", "- Permissions: full access", "- Active run: turn-1", - `- Session: ${sessionFile}`, + `- Session: ${sessionFile.replaceAll("_", "\uff3f")}`, ].join("\n"), }); }); + + it("escapes active binding fields before chat display", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ + schemaVersion: 1, + threadId: "thread-123 <@U123>", + cwd: "/repo", + model: "gpt [trusted](https://evil)", + }), + ); + + const result = await handleCodexCommand( + createContext("binding", sessionFile, { + getCurrentConversationBinding: async () => ({ + bindingId: "binding-1", + pluginId: "codex", + pluginRoot: "/plugin", + channel: "test", + accountId: "default", + conversationId: "conversation", + boundAt: 1, + data: { + kind: "codex-app-server-session", + version: 1, + sessionFile, + workspaceDir: "/repo <@U123>", + }, + }), + }), + { deps: createDeps() }, + ); + + expect(result.text).toContain("Thread: thread-123 <\uff20U123>"); + expect(result.text).toContain("Workspace: /repo <\uff20U123>"); + expect(result.text).toContain("Model: gpt \uff3btrusted\uff3d\uff08https://evil\uff09"); + expect(result.text).not.toContain("<@U123>"); + expect(result.text).not.toContain("[trusted](https://evil)"); + }); }); function computerUseReadyStatus(): CodexComputerUseStatus { diff --git a/extensions/codex/src/commands.ts b/extensions/codex/src/commands.ts index e5dc83023a3..8fb715bdd3f 100644 --- a/extensions/codex/src/commands.ts +++ b/extensions/codex/src/commands.ts @@ -3,6 +3,8 @@ import type { PluginCommandContext, PluginCommandResult, } from "openclaw/plugin-sdk/plugin-entry"; +import { describeControlFailure } from "./app-server/capabilities.js"; +import { formatCodexDisplayText } from "./command-formatters.js"; import type { CodexCommandDeps } from "./command-handlers.js"; export function createCodexCommand(options: { @@ -28,5 +30,11 @@ export async function handleCodexCommand( options: { pluginConfig?: unknown; deps?: Partial } = {}, ): Promise { const { handleCodexSubcommand } = await import("./command-handlers.js"); - return await handleCodexSubcommand(ctx, options); + try { + return await handleCodexSubcommand(ctx, options); + } catch (error) { + return { + text: `Codex command failed: ${formatCodexDisplayText(describeControlFailure(error))}`, + }; + } } diff --git a/extensions/codex/src/conversation-binding.test.ts b/extensions/codex/src/conversation-binding.test.ts index 5339145b8f7..675d3463050 100644 --- a/extensions/codex/src/conversation-binding.test.ts +++ b/extensions/codex/src/conversation-binding.test.ts @@ -240,7 +240,7 @@ describe("codex conversation binding", () => { request: vi.fn(async (method: string) => { if (method === "turn/start") { throw new Error( - "unexpected status 401 Unauthorized: Missing bearer or basic authentication in header", + "unexpected status 401 Unauthorized: Missing bearer <@U123> [trusted](https://evil) @here", ); } throw new Error(`unexpected method: ${method}`); @@ -283,12 +283,91 @@ describe("codex conversation binding", () => { expect(result).toEqual({ handled: true, reply: { - text: "Codex app-server turn failed: unexpected status 401 Unauthorized: Missing bearer or basic authentication in header", + text: "Codex app-server turn failed: unexpected status 401 Unauthorized: Missing bearer <\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here", }, }); + const replyText = result?.reply?.text ?? ""; + expect(replyText).not.toContain("<@U123>"); + expect(replyText).not.toContain("[trusted](https://evil)"); + expect(replyText).not.toContain("@here"); expect(unhandledRejections).toEqual([]); } finally { process.off("unhandledRejection", onUnhandledRejection); } }); + + it("falls back to content when the channel body for agent is blank", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ + schemaVersion: 1, + threadId: "thread-1", + cwd: tempDir, + }), + ); + let notificationHandler: ((notification: unknown) => void) | undefined; + const turnStartParams: Record[] = []; + sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ + request: vi.fn(async (method: string, requestParams: Record) => { + if (method === "turn/start") { + turnStartParams.push(requestParams); + setImmediate(() => + notificationHandler?.({ + method: "turn/completed", + params: { + threadId: "thread-1", + turn: { + id: "turn-1", + status: "completed", + items: [{ type: "agentMessage", id: "item-1", text: "done" }], + }, + }, + }), + ); + return { turn: { id: "turn-1" } }; + } + throw new Error(`unexpected method: ${method}`); + }), + addNotificationHandler: vi.fn((handler: (notification: unknown) => void) => { + notificationHandler = handler; + return () => undefined; + }), + addRequestHandler: vi.fn(() => () => undefined), + }); + + const result = await handleCodexConversationInboundClaim( + { + content: "use the fallback prompt", + bodyForAgent: "", + channel: "telegram", + isGroup: false, + commandAuthorized: true, + }, + { + channelId: "telegram", + pluginBinding: { + bindingId: "binding-1", + pluginId: "codex", + pluginRoot: tempDir, + channel: "telegram", + accountId: "default", + conversationId: "5185575566", + boundAt: Date.now(), + data: { + kind: "codex-app-server-session", + version: 1, + sessionFile, + workspaceDir: tempDir, + }, + }, + }, + { timeoutMs: 50 }, + ); + + expect(result).toEqual({ handled: true, reply: { text: "done" } }); + expect(turnStartParams[0]?.input).toMatchObject([ + { type: "text", text: "use the fallback prompt" }, + ]); + }); }); diff --git a/extensions/codex/src/conversation-binding.ts b/extensions/codex/src/conversation-binding.ts index a0e0a17b197..c8919e8f1b0 100644 --- a/extensions/codex/src/conversation-binding.ts +++ b/extensions/codex/src/conversation-binding.ts @@ -26,6 +26,7 @@ import { type CodexAppServerAuthProfileLookup, } from "./app-server/session-binding.js"; import { getSharedCodexAppServerClient } from "./app-server/shared-client.js"; +import { formatCodexDisplayText } from "./command-formatters.js"; import { createCodexConversationBindingData, readCodexConversationBindingData, @@ -130,7 +131,7 @@ export async function handleCodexConversationInboundClaim( if (event.commandAuthorized !== true) { return { handled: true }; } - const prompt = (event.bodyForAgent ?? event.content ?? "").trim(); + const prompt = event.bodyForAgent?.trim() || event.content?.trim() || ""; if (!prompt) { return { handled: true }; } @@ -149,7 +150,7 @@ export async function handleCodexConversationInboundClaim( return { handled: true, reply: { - text: `Codex app-server turn failed: ${formatErrorMessage(error)}`, + text: `Codex app-server turn failed: ${formatCodexDisplayText(formatErrorMessage(error))}`, }, }; } diff --git a/extensions/codex/src/conversation-control.test.ts b/extensions/codex/src/conversation-control.test.ts index 0b33fefa35f..e5e9a7fced7 100644 --- a/extensions/codex/src/conversation-control.test.ts +++ b/extensions/codex/src/conversation-control.test.ts @@ -102,4 +102,25 @@ describe("codex conversation controls", () => { }); expect(binding?.modelProvider).toBeUndefined(); }); + + it("escapes model names returned from Codex before chat display", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-1", + cwd: tempDir, + model: "gpt-5.4", + modelProvider: "openai", + }); + sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ + request: vi.fn(async () => ({ + thread: { id: "thread-1", cwd: tempDir }, + model: "gpt-5.5 <@U123> [trusted](https://evil)", + modelProvider: "openai", + })), + }); + + await expect(setCodexConversationModel({ sessionFile, model: "gpt-5.5" })).resolves.toBe( + "Codex model set to gpt-5.5 <\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09.", + ); + }); }); diff --git a/extensions/codex/src/conversation-control.ts b/extensions/codex/src/conversation-control.ts index 04e0b7f76ea..8fedfb295d0 100644 --- a/extensions/codex/src/conversation-control.ts +++ b/extensions/codex/src/conversation-control.ts @@ -10,6 +10,7 @@ import { writeCodexAppServerBinding, } from "./app-server/session-binding.js"; import { getSharedCodexAppServerClient } from "./app-server/shared-client.js"; +import { formatCodexDisplayText } from "./command-formatters.js"; type ActiveTurn = { sessionFile: string; @@ -128,7 +129,7 @@ export async function setCodexConversationModel(params: { sandbox: binding.sandbox, serviceTier: binding.serviceTier ?? runtime.serviceTier, }); - return `Codex model set to ${response.model ?? model}.`; + return `Codex model set to ${formatCodexDisplayText(response.model ?? model)}.`; } export async function setCodexConversationFastMode(params: { diff --git a/extensions/codex/src/conversation-turn-collector.test.ts b/extensions/codex/src/conversation-turn-collector.test.ts index 49b61f652ab..47f4018dea0 100644 --- a/extensions/codex/src/conversation-turn-collector.test.ts +++ b/extensions/codex/src/conversation-turn-collector.test.ts @@ -23,6 +23,43 @@ describe("codex conversation turn collector", () => { await expect(completion).resolves.toEqual({ replyText: "hello world" }); }); + it("buffers pre-start notifications and replays only the selected turn", async () => { + const collector = createCodexConversationTurnCollector("thread-1"); + + collector.handleNotification({ + method: "turn/completed", + params: { + threadId: "thread-1", + turn: { + id: "turn-stale", + status: "completed", + items: [{ type: "agentMessage", id: "wrong", text: "stale answer" }], + }, + }, + }); + collector.handleNotification({ + method: "item/agentMessage/delta", + params: { threadId: "thread-1", turnId: "turn-1", itemId: "right", delta: "fresh " }, + }); + collector.handleNotification({ + method: "turn/completed", + params: { + threadId: "thread-1", + turn: { + id: "turn-1", + status: "completed", + items: [{ type: "agentMessage", id: "right", text: "fresh answer" }], + }, + }, + }); + + collector.setTurnId("turn-1"); + + await expect(collector.wait({ timeoutMs: 1_000 })).resolves.toEqual({ + replyText: "fresh answer", + }); + }); + it("uses completed agent message items when deltas are absent", async () => { const collector = createCodexConversationTurnCollector("thread-1"); collector.setTurnId("turn-1"); diff --git a/extensions/codex/src/conversation-turn-collector.ts b/extensions/codex/src/conversation-turn-collector.ts index b9cc4e7a548..ba82956b1ca 100644 --- a/extensions/codex/src/conversation-turn-collector.ts +++ b/extensions/codex/src/conversation-turn-collector.ts @@ -4,6 +4,8 @@ import { type JsonObject, } from "./app-server/protocol.js"; +const MAX_PENDING_NOTIFICATIONS_PER_TURN = 100; + export function createCodexConversationTurnCollector(threadId: string) { let turnId: string | undefined; let completed = false; @@ -11,6 +13,7 @@ export function createCodexConversationTurnCollector(threadId: string) { let timeout: ReturnType | undefined; const assistantTextByItem = new Map(); const assistantOrder: string[] = []; + const pendingNotificationsByTurnId = new Map(); let resolveCompletion: ((value: { replyText: string }) => void) | undefined; let rejectCompletion: ((error: Error) => void) | undefined; @@ -46,59 +49,80 @@ export function createCodexConversationTurnCollector(threadId: string) { clearWaitState(); }; + const handleNotification = (notification: CodexServerNotification) => { + const params = isJsonObject(notification.params) ? notification.params : undefined; + if (!params || readString(params, "threadId") !== threadId) { + return; + } + if (!turnId) { + const pendingTurnId = readNotificationTurnId(params); + if (pendingTurnId) { + const pending = pendingNotificationsByTurnId.get(pendingTurnId) ?? []; + if (pending.length < MAX_PENDING_NOTIFICATIONS_PER_TURN) { + pending.push(notification); + pendingNotificationsByTurnId.set(pendingTurnId, pending); + } + } + return; + } + if (!isNotificationForTurn(params, threadId, turnId)) { + return; + } + if (notification.method === "item/agentMessage/delta") { + const itemId = readString(params, "itemId") ?? readString(params, "id") ?? "assistant"; + const delta = readTextString(params, "delta"); + if (!delta) { + return; + } + rememberItem(itemId); + assistantTextByItem.set(itemId, `${assistantTextByItem.get(itemId) ?? ""}${delta}`); + return; + } + if (notification.method === "item/completed") { + const item = isJsonObject(params.item) ? params.item : undefined; + if (item?.type === "agentMessage") { + const itemId = readString(item, "id") ?? readString(params, "itemId") ?? "assistant"; + const text = readTextString(item, "text"); + if (text) { + rememberItem(itemId); + assistantTextByItem.set(itemId, text); + } + } + return; + } + if (notification.method === "turn/completed") { + const turn = isJsonObject(params.turn) ? params.turn : undefined; + const status = readString(turn, "status"); + if (status === "failed") { + failedError = + readString(readRecord(turn?.error), "message") ?? "codex app-server turn failed"; + } + const items = Array.isArray(turn?.items) ? turn.items : []; + for (const item of items) { + if (!isJsonObject(item) || item.type !== "agentMessage") { + continue; + } + const itemId = readString(item, "id") ?? `assistant-${assistantOrder.length + 1}`; + const text = readTextString(item, "text"); + if (text) { + rememberItem(itemId); + assistantTextByItem.set(itemId, text); + } + } + finish(); + } + }; + return { setTurnId(nextTurnId: string) { turnId = nextTurnId; - }, - handleNotification(notification: CodexServerNotification) { - const params = isJsonObject(notification.params) ? notification.params : undefined; - if (!params || !isNotificationForTurn(params, threadId, turnId)) { - return; - } - if (notification.method === "item/agentMessage/delta") { - const itemId = readString(params, "itemId") ?? readString(params, "id") ?? "assistant"; - const delta = readTextString(params, "delta"); - if (!delta) { - return; - } - rememberItem(itemId); - assistantTextByItem.set(itemId, `${assistantTextByItem.get(itemId) ?? ""}${delta}`); - return; - } - if (notification.method === "item/completed") { - const item = isJsonObject(params.item) ? params.item : undefined; - if (item?.type === "agentMessage") { - const itemId = readString(item, "id") ?? readString(params, "itemId") ?? "assistant"; - const text = readTextString(item, "text"); - if (text) { - rememberItem(itemId); - assistantTextByItem.set(itemId, text); - } - } - return; - } - if (notification.method === "turn/completed") { - const turn = isJsonObject(params.turn) ? params.turn : undefined; - const status = readString(turn, "status"); - if (status === "failed") { - failedError = - readString(readRecord(turn?.error), "message") ?? "codex app-server turn failed"; - } - const items = Array.isArray(turn?.items) ? turn.items : []; - for (const item of items) { - if (!isJsonObject(item) || item.type !== "agentMessage") { - continue; - } - const itemId = readString(item, "id") ?? `assistant-${assistantOrder.length + 1}`; - const text = readTextString(item, "text"); - if (text) { - rememberItem(itemId); - assistantTextByItem.set(itemId, text); - } - } - finish(); + const pending = pendingNotificationsByTurnId.get(nextTurnId) ?? []; + pendingNotificationsByTurnId.clear(); + for (const notification of pending) { + handleNotification(notification); } }, + handleNotification, wait(params: { timeoutMs: number }): Promise<{ replyText: string }> { if (completed) { return failedError @@ -141,6 +165,10 @@ function isNotificationForTurn( return readString(turn, "id") === turnId; } +function readNotificationTurnId(params: JsonObject): string | undefined { + return readString(params, "turnId") ?? readString(readRecord(params.turn), "id"); +} + function readRecord(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) diff --git a/extensions/codex/src/conversation-turn-input.test.ts b/extensions/codex/src/conversation-turn-input.test.ts index f5e86691ba9..917a4ca8434 100644 --- a/extensions/codex/src/conversation-turn-input.test.ts +++ b/extensions/codex/src/conversation-turn-input.test.ts @@ -41,4 +41,101 @@ describe("codex conversation turn input", () => { { type: "image", url: "https://example.test/photo.webp?sig=1" }, ]); }); + + it("keeps protocol-relative image urls remote", () => { + expect( + buildCodexConversationTurnInput({ + prompt: "look", + event: { + content: "look", + channel: "webchat", + isGroup: false, + metadata: { + mediaUrl: "//cdn.example.test/photo.webp", + }, + }, + }), + ).toEqual([ + { type: "text", text: "look", text_elements: [] }, + { type: "image", url: "//cdn.example.test/photo.webp" }, + ]); + }); + + it("decodes local file URLs for Codex local image input", () => { + expect( + buildCodexConversationTurnInput({ + prompt: "look", + event: { + content: "look", + channel: "webchat", + isGroup: false, + metadata: { + mediaPath: "file:///tmp/OpenClaw%20QA/photo.png", + mediaType: "image/png", + }, + }, + }), + ).toEqual([ + { type: "text", text: "look", text_elements: [] }, + { type: "localImage", path: "/tmp/OpenClaw QA/photo.png" }, + ]); + }); + + it("drops malformed local file URLs instead of throwing", () => { + expect( + buildCodexConversationTurnInput({ + prompt: "look", + event: { + content: "look", + channel: "webchat", + isGroup: false, + metadata: { + mediaPath: "file:///tmp/%zz/photo.png", + mediaType: "image/png", + }, + }, + }), + ).toEqual([{ type: "text", text: "look", text_elements: [] }]); + }); + + it("treats local media URLs as Codex local image input", () => { + expect( + buildCodexConversationTurnInput({ + prompt: "look", + event: { + content: "look", + channel: "webchat", + isGroup: false, + metadata: { + mediaUrls: ["/tmp/staged-photo.png", "file:///tmp/OpenClaw%20QA/second.jpg"], + mediaTypes: ["image/png", "image/jpeg"], + }, + }, + }), + ).toEqual([ + { type: "text", text: "look", text_elements: [] }, + { type: "localImage", path: "/tmp/staged-photo.png" }, + { type: "localImage", path: "/tmp/OpenClaw QA/second.jpg" }, + ]); + }); + + it("treats Windows media paths as Codex local image input", () => { + expect( + buildCodexConversationTurnInput({ + prompt: "look", + event: { + content: "look", + channel: "webchat", + isGroup: false, + metadata: { + mediaUrl: "C:\\OpenClaw QA\\photo.png", + mediaType: "image/png", + }, + }, + }), + ).toEqual([ + { type: "text", text: "look", text_elements: [] }, + { type: "localImage", path: "C:\\OpenClaw QA\\photo.png" }, + ]); + }); }); diff --git a/extensions/codex/src/conversation-turn-input.ts b/extensions/codex/src/conversation-turn-input.ts index aaff58453e1..0f30bafcee1 100644 --- a/extensions/codex/src/conversation-turn-input.ts +++ b/extensions/codex/src/conversation-turn-input.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { fileURLToPath } from "node:url"; import type { PluginHookInboundClaimEvent } from "openclaw/plugin-sdk/plugin-entry"; import type { CodexUserInput } from "./app-server/protocol.js"; @@ -48,8 +49,10 @@ function toCodexImageInput(media: InboundMedia): CodexUserInput | undefined { if (!isImageMedia(media)) { return undefined; } - if (media.path) { - return { type: "localImage", path: normalizeFileUrl(media.path) }; + const localPath = media.path ?? readLocalMediaPath(media.url); + if (localPath) { + const normalized = normalizeFileUrl(localPath); + return normalized ? { type: "localImage", path: normalized } : undefined; } return media.url ? { type: "image", url: media.url } : undefined; } @@ -65,8 +68,31 @@ function isImageMedia(media: InboundMedia): boolean { return IMAGE_EXTENSIONS.has(path.extname(candidate.split(/[?#]/, 1)[0] ?? "").toLowerCase()); } -function normalizeFileUrl(value: string): string { - return value.startsWith("file://") ? new URL(value).pathname : value; +function normalizeFileUrl(value: string): string | undefined { + if (!value.startsWith("file://")) { + return value; + } + try { + return fileURLToPath(value); + } catch { + return undefined; + } +} + +function readLocalMediaPath(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + if (value.startsWith("file://")) { + return value; + } + if (value.startsWith("//")) { + return undefined; + } + if (path.isAbsolute(value) || path.win32.isAbsolute(value)) { + return value; + } + return /^[a-z][a-z0-9+.-]*:/i.test(value) ? undefined : value; } function readStringArray(value: unknown): string[] { diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 144a2aaa72d..54f89c7bab9 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -33,6 +33,52 @@ export function createChangedCheckChildEnv(baseEnv = process.env) { }; } +function isTruthyEnvFlag(value) { + const normalized = String(value ?? "") + .trim() + .toLowerCase(); + return normalized !== "" && normalized !== "0" && normalized !== "false" && normalized !== "no"; +} + +export function shouldDelegateChangedCheckToTestbox(argv = [], env = process.env) { + if (!isTruthyEnvFlag(env.OPENCLAW_TESTBOX)) { + return false; + } + if (isTruthyEnvFlag(env.OPENCLAW_TESTBOX_REMOTE_RUN)) { + return false; + } + if (isTruthyEnvFlag(env.CI) || isTruthyEnvFlag(env.GITHUB_ACTIONS)) { + return false; + } + if (argv.includes("--dry-run")) { + return false; + } + return true; +} + +export function buildChangedCheckTestboxArgs(argv = []) { + return [ + "testbox:run", + "--", + "OPENCLAW_TESTBOX=1", + "OPENCLAW_TESTBOX_REMOTE_RUN=1", + "pnpm", + "check:changed", + ...argv, + ]; +} + +export async function runChangedCheckViaTestbox(argv = [], env = process.env) { + console.error( + "[check:changed] OPENCLAW_TESTBOX=1 set; delegating to Blacksmith Testbox via `pnpm testbox:run`.", + ); + return await runManagedCommand({ + bin: "pnpm", + args: buildChangedCheckTestboxArgs(argv), + env, + }); +} + export function createChangedCheckPlan(result, options = {}) { const commands = []; const baseEnv = createChangedCheckChildEnv(options.env ?? process.env); @@ -283,21 +329,26 @@ function isDirectRun() { } if (isDirectRun()) { - const args = parseArgs(process.argv.slice(2)); - const paths = - args.paths.length > 0 - ? args.paths - : args.staged - ? listStagedChangedPaths() - : listChangedPathsFromGit({ base: args.base, head: args.head }); - const result = detectChangedLanesForPaths({ - paths, - base: args.base, - head: args.head, - staged: args.staged, - }); - process.exitCode = await runChangedCheck(result, { - ...args, - explicitPaths: args.paths.length > 0, - }); + const argv = process.argv.slice(2); + if (shouldDelegateChangedCheckToTestbox(argv, process.env)) { + process.exitCode = await runChangedCheckViaTestbox(argv, process.env); + } else { + const args = parseArgs(argv); + const paths = + args.paths.length > 0 + ? args.paths + : args.staged + ? listStagedChangedPaths() + : listChangedPathsFromGit({ base: args.base, head: args.head }); + const result = detectChangedLanesForPaths({ + paths, + base: args.base, + head: args.head, + staged: args.staged, + }); + process.exitCode = await runChangedCheck(result, { + ...args, + explicitPaths: args.paths.length > 0, + }); + } } diff --git a/scripts/check-codex-app-server-protocol.ts b/scripts/check-codex-app-server-protocol.ts index e543e5a281e..65431874ef7 100644 --- a/scripts/check-codex-app-server-protocol.ts +++ b/scripts/check-codex-app-server-protocol.ts @@ -1,11 +1,9 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { resolveCodexAppServerProtocolSource } from "./lib/codex-app-server-protocol-source.js"; -const codexRepo = process.env.OPENCLAW_CODEX_REPO - ? path.resolve(process.env.OPENCLAW_CODEX_REPO) - : path.resolve(process.cwd(), "../codex"); -const schemaRoot = path.join(codexRepo, "codex-rs/app-server-protocol/schema/typescript"); -const sourceSchemaRoot = path.join(codexRepo, "codex-rs/app-server-protocol/schema"); +const { sourceRoot: sourceSchemaRoot } = await resolveCodexAppServerProtocolSource(process.cwd()); +const schemaRoot = path.join(sourceSchemaRoot, "typescript"); const generatedRoot = path.resolve( process.cwd(), "extensions/codex/src/app-server/protocol-generated", @@ -104,12 +102,14 @@ if (failures.length > 0) { for (const failure of failures) { console.error(`- ${failure}`); } - console.error("Run `pnpm codex-app-server:protocol:sync` after refreshing ../codex."); + console.error( + `Run \`pnpm codex-app-server:protocol:sync\` after refreshing the Codex checkout at ${path.resolve(sourceSchemaRoot, "../../..")}.`, + ); process.exit(1); } console.log( - `Codex app-server generated protocol matches OpenClaw bridge assumptions: ${schemaRoot}`, + `Codex app-server generated protocol matches OpenClaw bridge assumptions: ${sourceSchemaRoot}`, ); async function compareGeneratedProtocolMirror(): Promise { @@ -130,14 +130,12 @@ async function compareGeneratedProtocolMirror(): Promise { ); const target = await fs.readFile(path.join(targetTsRoot, file), "utf8"); if (source !== target) { - failures.push( - `protocol-generated/typescript/${file}: differs from normalized ../codex schema`, - ); + failures.push(`protocol-generated/typescript/${file}: differs from normalized source schema`); } } for (const file of targetFiles) { if (!sourceSet.has(file)) { - failures.push(`protocol-generated/typescript/${file}: no longer present in ../codex schema`); + failures.push(`protocol-generated/typescript/${file}: no longer present in source schema`); } } @@ -161,7 +159,7 @@ async function compareGeneratedProtocolMirror(): Promise { continue; } if (source !== target) { - failures.push(`protocol-generated/json/${schema}: differs from ../codex schema`); + failures.push(`protocol-generated/json/${schema}: differs from source schema`); } } } diff --git a/scripts/lib/codex-app-server-protocol-source.ts b/scripts/lib/codex-app-server-protocol-source.ts new file mode 100644 index 00000000000..1c13c50e161 --- /dev/null +++ b/scripts/lib/codex-app-server-protocol-source.ts @@ -0,0 +1,74 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +const PROTOCOL_SCHEMA_RELATIVE_PATH = "codex-rs/app-server-protocol/schema"; + +export async function resolveCodexAppServerProtocolSource(repoRoot: string): Promise<{ + codexRepo: string; + sourceRoot: string; +}> { + const candidates = await collectCodexRepoCandidates(repoRoot); + const checked: string[] = []; + + for (const candidate of candidates) { + const codexRepo = path.resolve(candidate); + if (checked.includes(codexRepo)) { + continue; + } + checked.push(codexRepo); + const sourceRoot = path.join(codexRepo, PROTOCOL_SCHEMA_RELATIVE_PATH); + if (await isDirectory(path.join(sourceRoot, "typescript"))) { + return { codexRepo, sourceRoot }; + } + } + + throw new Error( + [ + "Codex app-server protocol schema not found.", + "Set OPENCLAW_CODEX_REPO to a checkout of openai/codex, or keep a sibling `codex` checkout next to the primary OpenClaw checkout.", + `Checked: ${checked.join(", ") || ""}`, + ].join("\n"), + ); +} + +async function collectCodexRepoCandidates(repoRoot: string): Promise { + const candidates = [ + process.env.OPENCLAW_CODEX_REPO, + path.resolve(repoRoot, "../codex"), + await resolvePrimaryWorktreeSiblingCodex(repoRoot), + ]; + return candidates.filter((candidate): candidate is string => Boolean(candidate)); +} + +async function resolvePrimaryWorktreeSiblingCodex(repoRoot: string): Promise { + const gitFilePath = path.join(repoRoot, ".git"); + let gitFile: string; + try { + gitFile = await fs.readFile(gitFilePath, "utf8"); + } catch { + return undefined; + } + + const match = /^gitdir:\s*(.+)$/m.exec(gitFile); + if (!match) { + return undefined; + } + + const gitDir = path.resolve(repoRoot, match[1].trim()); + const worktreeMarker = `${path.sep}.git${path.sep}worktrees${path.sep}`; + const markerIndex = gitDir.indexOf(worktreeMarker); + if (markerIndex < 0) { + return undefined; + } + + const primaryWorktreeRoot = gitDir.slice(0, markerIndex); + return path.join(path.dirname(primaryWorktreeRoot), "codex"); +} + +async function isDirectory(candidate: string): Promise { + try { + return (await fs.stat(candidate)).isDirectory(); + } catch { + return false; + } +} diff --git a/scripts/qa-coverage-report.ts b/scripts/qa-coverage-report.ts new file mode 100644 index 00000000000..dfe9bcba67e --- /dev/null +++ b/scripts/qa-coverage-report.ts @@ -0,0 +1,56 @@ +import { runQaCoverageReportCommand } from "../extensions/qa-lab/src/cli.runtime.ts"; + +type Options = { + json?: boolean; + output?: string; + repoRoot?: string; +}; + +function takeValue(args: string[], index: number, flag: string): string { + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + throw new Error(`${flag} requires a value.`); + } + return value; +} + +function parseArgs(args: string[]): Options { + const opts: Options = {}; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + switch (arg) { + case "--help": + case "-h": + process.stdout.write(`Usage: openclaw qa coverage [options] + +Options: + --json Print machine-readable JSON + --output Write the report to a file + --repo-root Repository root to target + -h, --help Display help +`); + process.exit(0); + case "--json": + opts.json = true; + break; + case "--output": + opts.output = takeValue(args, index, arg); + index += 1; + break; + case "--repo-root": + opts.repoRoot = takeValue(args, index, arg); + index += 1; + break; + default: + throw new Error(`Unknown qa coverage option: ${arg}`); + } + } + return opts; +} + +const opts = parseArgs(process.argv.slice(2)); +await runQaCoverageReportCommand({ + ...(opts.json ? { json: true } : {}), + ...(opts.output ? { output: opts.output } : {}), + ...(opts.repoRoot ? { repoRoot: opts.repoRoot } : {}), +}); diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 7b485805527..46226255357 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -796,6 +796,7 @@ const shouldUseExistingDistForGatewayClient = (deps, buildRequirement) => statMtime(deps.distEntry, deps.fs) != null; const isQaParityReportCommand = (args) => args[0] === "qa" && args[1] === "parity-report"; +const isQaCoverageReportCommand = (args) => args[0] === "qa" && args[1] === "coverage"; const shouldRunQaParityReportFromSource = (deps, buildRequirement) => buildRequirement.reason === "missing_private_qa_dist" && @@ -803,6 +804,12 @@ const shouldRunQaParityReportFromSource = (deps, buildRequirement) => deps.env.OPENCLAW_FORCE_BUILD !== "1" && statMtime(path.join(deps.cwd, "extensions", "qa-lab", "src", "cli.runtime.ts"), deps.fs) != null; +const shouldRunQaCoverageReportFromSource = (deps, buildRequirement) => + buildRequirement.reason === "missing_private_qa_dist" && + isQaCoverageReportCommand(deps.args) && + deps.env.OPENCLAW_FORCE_BUILD !== "1" && + statMtime(path.join(deps.cwd, "extensions", "qa-lab", "src", "cli.runtime.ts"), deps.fs) != null; + const runQaParityReportFromSource = async (deps) => { const sourceEntrypoint = path.join(deps.cwd, "scripts", "qa-parity-report.ts"); const nodeProcess = deps.spawn( @@ -823,6 +830,26 @@ const runQaParityReportFromSource = async (deps) => { return res.exitCode ?? 1; }; +const runQaCoverageReportFromSource = async (deps) => { + const sourceEntrypoint = path.join(deps.cwd, "scripts", "qa-coverage-report.ts"); + const nodeProcess = deps.spawn( + deps.execPath, + ["--import", "tsx", sourceEntrypoint, ...deps.args.slice(2)], + { + cwd: deps.cwd, + env: deps.env, + stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit", + }, + ); + pipeSpawnedOutput(nodeProcess, deps); + const res = await waitForSpawnedProcess(nodeProcess, deps); + const interruptedExitCode = getInterruptedSpawnExitCode(res); + if (interruptedExitCode !== null) { + return interruptedExitCode; + } + return res.exitCode ?? 1; +}; + export async function runNodeMain(params = {}) { const deps = { spawn: params.spawn ?? spawn, @@ -862,6 +889,7 @@ export async function runNodeMain(params = {}) { buildRequirement, ); const useQaParityReportSource = shouldRunQaParityReportFromSource(deps, buildRequirement); + const useQaCoverageReportSource = shouldRunQaCoverageReportFromSource(deps, buildRequirement); if (useExistingGatewayClientDist) { buildRequirement = { shouldBuild: false, reason: "gateway_client_existing_dist" }; } @@ -870,6 +898,11 @@ export async function runNodeMain(params = {}) { exitCode = await runQaParityReportFromSource(deps); return await closeRunNodeOutputTee(deps, exitCode); } + if (useQaCoverageReportSource) { + logRunner("Running QA coverage report from source without rebuilding private QA dist.", deps); + exitCode = await runQaCoverageReportFromSource(deps); + return await closeRunNodeOutputTee(deps, exitCode); + } if (!buildRequirement.shouldBuild) { if (!useExistingGatewayClientDist) { const runtimePostBuildRequirement = resolveRuntimePostBuildRequirement(deps); diff --git a/scripts/run-oxlint.mjs b/scripts/run-oxlint.mjs index e89b87768d2..924fb02ebdb 100644 --- a/scripts/run-oxlint.mjs +++ b/scripts/run-oxlint.mjs @@ -52,16 +52,24 @@ export function filterSparseMissingOxlintTargets( } = {}, ) { if (!isSparseCheckoutEnabled({ cwd })) { - return { args, hadExplicitTargets: false, remainingExplicitTargets: 0, skippedTargets: [] }; + return { + args, + hadExplicitTargets: false, + remainingExplicitTargets: 0, + skippedTargets: [], + skippedConfigs: [], + }; } const filteredArgs = []; const skippedTargets = []; + const skippedConfigs = []; let hadExplicitTargets = false; let remainingExplicitTargets = 0; let consumeNextValue = false; - for (const arg of args) { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; if (consumeNextValue) { filteredArgs.push(arg); consumeNextValue = false; @@ -74,6 +82,29 @@ export function filterSparseMissingOxlintTargets( } if (arg.startsWith("--")) { + if (arg === "--tsconfig") { + const value = args[index + 1]; + if (value !== undefined) { + index += 1; + if (!fileExists(path.resolve(cwd, value)) && isTrackedPath({ cwd, target: value })) { + skippedConfigs.push(value); + continue; + } + filteredArgs.push(arg, value); + continue; + } + } + if (arg.startsWith("--tsconfig=")) { + const value = arg.slice("--tsconfig=".length); + if ( + value && + !fileExists(path.resolve(cwd, value)) && + isTrackedPath({ cwd, target: value }) + ) { + skippedConfigs.push(value); + continue; + } + } filteredArgs.push(arg); if (!arg.includes("=") && OXLINT_VALUE_FLAGS.has(arg)) { consumeNextValue = true; @@ -97,7 +128,13 @@ export function filterSparseMissingOxlintTargets( filteredArgs.push(arg); } - return { args: filteredArgs, hadExplicitTargets, remainingExplicitTargets, skippedTargets }; + return { + args: filteredArgs, + hadExplicitTargets, + remainingExplicitTargets, + skippedTargets, + skippedConfigs, + }; } function getSparseCheckoutEnabled({ cwd }) { @@ -159,6 +196,12 @@ export async function main(argv = process.argv.slice(2), runtimeEnv = process.en `[oxlint] sparse checkout is missing tracked target(s); skipping ${sparseTargets.skippedTargets.join(", ")}`, ); } + if (sparseTargets.skippedConfigs.length > 0) { + console.error( + `[oxlint] sparse checkout is missing tracked config(s); skipping oxlint: ${sparseTargets.skippedConfigs.join(", ")}`, + ); + return; + } if (sparseTargets.hadExplicitTargets && sparseTargets.remainingExplicitTargets === 0) { console.error("[oxlint] no present sparse-checkout targets remain; skipping oxlint."); return; diff --git a/scripts/sync-codex-app-server-protocol.ts b/scripts/sync-codex-app-server-protocol.ts index 526bc3b7bde..379d150d7bc 100644 --- a/scripts/sync-codex-app-server-protocol.ts +++ b/scripts/sync-codex-app-server-protocol.ts @@ -1,11 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { resolveCodexAppServerProtocolSource } from "./lib/codex-app-server-protocol-source.js"; -const codexRepo = process.env.OPENCLAW_CODEX_REPO - ? path.resolve(process.env.OPENCLAW_CODEX_REPO) - : path.resolve(process.cwd(), "../codex"); - -const sourceRoot = path.join(codexRepo, "codex-rs/app-server-protocol/schema"); +const { sourceRoot } = await resolveCodexAppServerProtocolSource(process.cwd()); const targetRoot = path.resolve( process.cwd(), "extensions/codex/src/app-server/protocol-generated", diff --git a/scripts/test-live-codex-harness-docker.sh b/scripts/test-live-codex-harness-docker.sh index b7cc1406672..7d322c1fe33 100644 --- a/scripts/test-live-codex-harness-docker.sh +++ b/scripts/test-live-codex-harness-docker.sh @@ -22,9 +22,15 @@ DOCKER_USER="${OPENCLAW_DOCKER_USER:-node}" DOCKER_HOME_MOUNT=() DOCKER_TRUSTED_HARNESS_MOUNT=() DOCKER_TRUSTED_HARNESS_CONTAINER_DIR="" +DOCKER_CACHE_CONTAINER_DIR="/tmp/openclaw-cache" +DOCKER_CLI_TOOLS_CONTAINER_DIR="/tmp/openclaw-npm-global" DOCKER_EXTRA_ENV_FILES=() DOCKER_AUTH_PRESTAGED=0 +openclaw_live_codex_harness_is_ci() { + [[ -n "${CI:-}" && "${CI:-}" != "false" ]] || [[ -n "${GITHUB_ACTIONS:-}" && "${GITHUB_ACTIONS:-}" != "false" ]] +} + openclaw_live_codex_harness_append_build_extension() { local extension="${1:?extension required}" local current="${OPENCLAW_DOCKER_BUILD_EXTENSIONS:-${OPENCLAW_EXTENSIONS:-}}" @@ -50,6 +56,13 @@ if [[ "$CODEX_HARNESS_AUTH_MODE" == "api-key" && -z "${OPENAI_API_KEY:-}" ]]; th echo "ERROR: OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key requires OPENAI_API_KEY." >&2 exit 1 fi +if [[ "$CODEX_HARNESS_AUTH_MODE" != "api-key" && ! -s "$HOME/.codex/auth.json" ]]; then + echo "ERROR: OPENCLAW_LIVE_CODEX_HARNESS_AUTH=codex-auth requires ~/.codex/auth.json before building the live Docker image." >&2 + if [[ -n "${OPENAI_API_KEY:-}" ]]; then + echo "If this is a Testbox/API-key run, set OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key and run through openclaw-testbox-env." >&2 + fi + exit 1 +fi cleanup_temp_dirs() { if ((${#TEMP_DIRS[@]} > 0)); then @@ -60,7 +73,7 @@ trap cleanup_temp_dirs EXIT if [[ -n "${OPENCLAW_DOCKER_CLI_TOOLS_DIR:-}" ]]; then CLI_TOOLS_DIR="${OPENCLAW_DOCKER_CLI_TOOLS_DIR}" -elif [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then +elif openclaw_live_codex_harness_is_ci; then CLI_TOOLS_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-cli-tools.XXXXXX")" TEMP_DIRS+=("$CLI_TOOLS_DIR") else @@ -68,7 +81,7 @@ else fi if [[ -n "${OPENCLAW_DOCKER_CACHE_HOME_DIR:-}" ]]; then CACHE_HOME_DIR="${OPENCLAW_DOCKER_CACHE_HOME_DIR}" -elif [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then +elif openclaw_live_codex_harness_is_ci; then CACHE_HOME_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-cache.XXXXXX")" TEMP_DIRS+=("$CACHE_HOME_DIR") else @@ -77,7 +90,10 @@ fi mkdir -p "$CLI_TOOLS_DIR" mkdir -p "$CACHE_HOME_DIR" -if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then +if openclaw_live_codex_harness_is_ci; then + chmod 0777 "$CLI_TOOLS_DIR" "$CACHE_HOME_DIR" || true +fi +if openclaw_live_codex_harness_is_ci; then DOCKER_USER="$(id -u):$(id -g)" DOCKER_HOME_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-home.XXXXXX")" TEMP_DIRS+=("$DOCKER_HOME_DIR") @@ -146,6 +162,11 @@ export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" export COREPACK_HOME="${COREPACK_HOME:-$XDG_CACHE_HOME/node/corepack}" export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-$XDG_CACHE_HOME/npm}" export npm_config_cache="$NPM_CONFIG_CACHE" +if [ "${OPENCLAW_LIVE_CODEX_HARNESS_DEBUG:-}" = "1" ]; then + id + mount | grep -E 'openclaw-cache|openclaw-npm|/home/node' || true + ls -ld "$HOME" "$XDG_CACHE_HOME" "$NPM_CONFIG_PREFIX" 2>/dev/null || true +fi # Force the Codex harness to use the staged `~/.codex` auth files. This lane # is not meant to exercise raw OpenAI API-key routing unless the lane # explicitly opts into API-key auth for CI. @@ -254,6 +275,12 @@ DOCKER_RUN_ARGS=(docker run --rm -t \ --entrypoint bash \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e HOME=/home/node \ + -e NPM_CONFIG_PREFIX="$DOCKER_CLI_TOOLS_CONTAINER_DIR" \ + -e npm_config_prefix="$DOCKER_CLI_TOOLS_CONTAINER_DIR" \ + -e XDG_CACHE_HOME="$DOCKER_CACHE_CONTAINER_DIR" \ + -e COREPACK_HOME="$DOCKER_CACHE_CONTAINER_DIR/node/corepack" \ + -e NPM_CONFIG_CACHE="$DOCKER_CACHE_CONTAINER_DIR/npm" \ + -e npm_config_cache="$DOCKER_CACHE_CONTAINER_DIR/npm" \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_AGENT_HARNESS_FALLBACK=none \ -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ @@ -287,14 +314,22 @@ openclaw_live_append_array DOCKER_RUN_ARGS DOCKER_EXTRA_ENV_FILES openclaw_live_append_array DOCKER_RUN_ARGS DOCKER_HOME_MOUNT openclaw_live_append_array DOCKER_RUN_ARGS DOCKER_TRUSTED_HARNESS_MOUNT DOCKER_RUN_ARGS+=(\ - -v "$CACHE_HOME_DIR":/home/node/.cache \ + -v "$CACHE_HOME_DIR":"$DOCKER_CACHE_CONTAINER_DIR" \ -v "$ROOT_DIR":/src:ro \ -v "$CONFIG_DIR":/home/node/.openclaw \ -v "$WORKSPACE_DIR":/home/node/.openclaw/workspace \ - -v "$CLI_TOOLS_DIR":/home/node/.npm-global) + -v "$CLI_TOOLS_DIR":"$DOCKER_CLI_TOOLS_CONTAINER_DIR") openclaw_live_append_array DOCKER_RUN_ARGS EXTERNAL_AUTH_MOUNTS openclaw_live_append_array DOCKER_RUN_ARGS PROFILE_MOUNT DOCKER_RUN_ARGS+=(\ "$LIVE_IMAGE_NAME" \ -lc "$LIVE_TEST_CMD") +if [[ "${OPENCLAW_LIVE_CODEX_HARNESS_DEBUG:-}" == "1" ]]; then + echo "==> Docker debug: host ids and mounted dirs" + id + ls -ld "$CACHE_HOME_DIR" "$CLI_TOOLS_DIR" "${DOCKER_HOME_DIR:-$HOME}" 2>/dev/null || true + printf '==> Docker debug args:' + printf ' %q' "${DOCKER_RUN_ARGS[@]}" + printf '\n' +fi "${DOCKER_RUN_ARGS[@]}" diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index e47193dedc6..d2b0ae4f488 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -1031,14 +1031,6 @@ async function agentCommandInternal( currentTurnUserMessagePersisted = true; }, onAgentEvent: (evt) => { - if (evt.stream.startsWith("codex_app_server.")) { - emitAgentEvent({ - runId, - stream: evt.stream, - data: evt.data ?? {}, - ...(evt.sessionKey ? { sessionKey: evt.sessionKey } : {}), - }); - } if ( evt.stream === "lifecycle" && typeof evt.data?.phase === "string" && diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 0274293bab8..cdcc4b29b71 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -1176,7 +1176,7 @@ describe("runAgentTurnWithFallback", () => { }); }); - it("publishes Codex app-server telemetry to agent event subscribers", async () => { + it("leaves Codex app-server telemetry publication to the harness", async () => { const agentEvents = await import("../../infra/agent-events.js"); const emitAgentEvent = vi.mocked(agentEvents.emitAgentEvent); state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { @@ -1217,15 +1217,12 @@ describe("runAgentTurnWithFallback", () => { }); expect(result.kind).toBe("success"); - expect(emitAgentEvent).toHaveBeenCalledWith({ - runId: "run-codex", - stream: "codex_app_server.guardian", - sessionKey: "agent:main:subagent:codex-child", - data: { - phase: "blocked", - message: "command requires approval", - }, - }); + expect(emitAgentEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ + runId: "run-codex", + stream: "codex_app_server.guardian", + }), + ); }); it("emits an embedded lifecycle terminal backstop when the runner returns without one", async () => { diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index f80869bed86..984e41bc05d 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -1509,14 +1509,6 @@ export async function runAgentTurnWithFallback(params: { onReasoningEnd: params.opts?.onReasoningEnd, onAgentEvent: async (evt) => { lifecycleBackstop.note(evt); - if (evt.stream.startsWith("codex_app_server.")) { - emitAgentEvent({ - runId, - stream: evt.stream, - data: evt.data, - ...(evt.sessionKey ? { sessionKey: evt.sessionKey } : {}), - }); - } // Signal run start only after the embedded agent emits real activity. const hasLifecyclePhase = evt.stream === "lifecycle" && typeof evt.data.phase === "string"; diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 7df8162bf08..cc75197816e 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -15,7 +15,7 @@ import { import type { SessionEntry } from "../../config/sessions.js"; import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; -import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js"; +import { registerAgentRunContext } from "../../infra/agent-events.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { defaultRuntime } from "../../runtime.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; @@ -332,14 +332,6 @@ export function createFollowupRunner(params: { bootstrapPromptWarningSignaturesSeen.length - 1 ], onAgentEvent: (evt) => { - if (evt.stream.startsWith("codex_app_server.")) { - emitAgentEvent({ - runId, - stream: evt.stream, - data: evt.data, - ...(evt.sessionKey ? { sessionKey: evt.sessionKey } : {}), - }); - } if (evt.stream !== "compaction") { return; } diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index ab8f3d9bf66..74eabf2ea52 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -588,6 +588,44 @@ describe("agentCommand", () => { }); }); + it("does not publish Codex app-server events from the core command callback", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store); + + const codexEvents: Array<{ runId: string; phase?: string }> = []; + const stop = onAgentEvent((evt) => { + if (evt.stream !== "codex_app_server.lifecycle") { + return; + } + codexEvents.push({ + runId: evt.runId, + phase: typeof evt.data?.phase === "string" ? evt.data.phase : undefined, + }); + }); + + vi.mocked(runEmbeddedPiAgent).mockImplementationOnce(async (params) => { + ( + params as { + onAgentEvent?: (evt: { stream: string; data: Record }) => void; + } + ).onAgentEvent?.({ + stream: "codex_app_server.lifecycle", + data: { phase: "startup" }, + }); + return { + payloads: [{ text: "hello" }], + meta: { agentMeta: { provider: "p", model: "m" } }, + } as never; + }); + + await agentCommand({ message: "hi", to: "+1555", thinking: "low" }, runtime); + stop(); + + expect(codexEvents).toHaveLength(0); + }); + }); + it("uses default fallback list for auto session model overrides", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index e604300dd6a..c47d9b7bf32 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -28,6 +28,8 @@ import { } from "../routing/session-key.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { resetTaskRegistryForTests } from "../tasks/runtime-internal.js"; +import { resetTaskFlowRegistryForTests } from "../tasks/task-flow-runtime-internal.js"; import { captureEnv } from "../test-utils/env.js"; import { getDeterministicFreePortBlock } from "../test-utils/ports.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -254,6 +256,8 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { } applyGatewaySkipEnv(); delete process.env.OPENCLAW_GATEWAY_TOKEN; + resetTaskRegistryForTests({ persist: false }); + resetTaskFlowRegistryForTests({ persist: false }); const stateDir = process.env.OPENCLAW_STATE_DIR; if (stateDir) { await fs.rm(stateDir, { @@ -365,6 +369,8 @@ async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) { vi.useRealTimers(); clearGatewaySubagentRuntime(); resetLogger(); + resetTaskRegistryForTests({ persist: false }); + resetTaskFlowRegistryForTests({ persist: false }); if (options.restoreEnv) { gatewayEnvSnapshot?.restore(); gatewayEnvSnapshot = undefined; diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index b621158853b..d4bac6516ae 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -834,6 +834,46 @@ describe("run-node script", () => { }); }); + it("runs QA coverage report from source without rebuilding private QA dist", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + "extensions/qa-lab/src/cli.runtime.ts": "export {};\n", + }, + buildPaths: [DIST_ENTRY, BUILD_STAMP], + }); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["qa", "coverage", "--json"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + [ + process.execPath, + "--import", + "tsx", + path.join(tmp, "scripts", "qa-coverage-report.ts"), + "--json", + ], + ]); + }); + }); + it("skips runtime postbuild restaging when the runtime stamp is current", async () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp, { diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index b3034f6dcb1..89dcc202a2a 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -8,8 +8,10 @@ import { isPackageScriptOnlyChange, } from "../../scripts/changed-lanes.mjs"; import { + buildChangedCheckTestboxArgs, createChangedCheckChildEnv, createChangedCheckPlan, + shouldDelegateChangedCheckToTestbox, } from "../../scripts/check-changed.mjs"; import { cleanupTempDirs, makeTempRepoRoot } from "../helpers/temp-repo.js"; @@ -215,6 +217,44 @@ describe("scripts/changed-lanes", () => { }); }); + it("delegates local Testbox-mode changed gates before running locally", () => { + expect( + shouldDelegateChangedCheckToTestbox(["--base", "origin/main"], { + OPENCLAW_TESTBOX: "1", + PATH: "/usr/bin", + }), + ).toBe(true); + + expect(buildChangedCheckTestboxArgs(["--base", "origin/main", "--head", "HEAD"])).toEqual([ + "testbox:run", + "--", + "OPENCLAW_TESTBOX=1", + "OPENCLAW_TESTBOX_REMOTE_RUN=1", + "pnpm", + "check:changed", + "--base", + "origin/main", + "--head", + "HEAD", + ]); + }); + + it("does not delegate dry-run, CI, or already-remote changed gates", () => { + expect(shouldDelegateChangedCheckToTestbox(["--dry-run"], { OPENCLAW_TESTBOX: "1" })).toBe( + false, + ); + expect( + shouldDelegateChangedCheckToTestbox([], { OPENCLAW_TESTBOX: "1", GITHUB_ACTIONS: "true" }), + ).toBe(false); + expect(shouldDelegateChangedCheckToTestbox([], { OPENCLAW_TESTBOX: "1", CI: "1" })).toBe(false); + expect( + shouldDelegateChangedCheckToTestbox([], { + OPENCLAW_TESTBOX: "1", + OPENCLAW_TESTBOX_REMOTE_RUN: "1", + }), + ).toBe(false); + }); + it("runs changed-check lint lanes under the parent heavy-check lock", () => { const result = detectChangedLanes(["extensions/discord/src/index.ts"]); const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } }); diff --git a/test/scripts/codex-app-server-protocol-source.test.ts b/test/scripts/codex-app-server-protocol-source.test.ts new file mode 100644 index 00000000000..d9c47e93a42 --- /dev/null +++ b/test/scripts/codex-app-server-protocol-source.test.ts @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveCodexAppServerProtocolSource } from "../../scripts/lib/codex-app-server-protocol-source.js"; +import { createScriptTestHarness } from "./test-helpers.js"; + +const { createTempDir } = createScriptTestHarness(); +const originalOpenClawCodexRepo = process.env.OPENCLAW_CODEX_REPO; + +afterEach(() => { + if (originalOpenClawCodexRepo === undefined) { + delete process.env.OPENCLAW_CODEX_REPO; + } else { + process.env.OPENCLAW_CODEX_REPO = originalOpenClawCodexRepo; + } +}); + +describe("codex app-server protocol source resolver", () => { + it("uses OPENCLAW_CODEX_REPO when provided", async () => { + const root = createTempDir("openclaw-protocol-source-root-"); + const codexRepo = createTempDir("openclaw-protocol-source-codex-"); + createProtocolSchema(codexRepo); + process.env.OPENCLAW_CODEX_REPO = codexRepo; + + await expect(resolveCodexAppServerProtocolSource(root)).resolves.toEqual({ + codexRepo, + sourceRoot: path.join(codexRepo, "codex-rs/app-server-protocol/schema"), + }); + }); + + it("finds the primary checkout sibling from a git worktree", async () => { + const parentDir = createTempDir("openclaw-protocol-source-parent-"); + const primaryOpenClaw = path.join(parentDir, "openclaw"); + const codexRepo = path.join(parentDir, "codex"); + const worktreeRoot = createTempDir("openclaw-protocol-source-worktree-"); + fs.mkdirSync(path.join(primaryOpenClaw, ".git", "worktrees", "codex-harness"), { + recursive: true, + }); + fs.mkdirSync(worktreeRoot, { recursive: true }); + fs.writeFileSync( + path.join(worktreeRoot, ".git"), + `gitdir: ${path.join(primaryOpenClaw, ".git", "worktrees", "codex-harness")}\n`, + ); + createProtocolSchema(codexRepo); + delete process.env.OPENCLAW_CODEX_REPO; + + await expect(resolveCodexAppServerProtocolSource(worktreeRoot)).resolves.toMatchObject({ + codexRepo, + sourceRoot: path.join(codexRepo, "codex-rs/app-server-protocol/schema"), + }); + }); +}); + +function createProtocolSchema(codexRepo: string): void { + fs.mkdirSync(path.join(codexRepo, "codex-rs/app-server-protocol/schema/typescript"), { + recursive: true, + }); +} diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index 0184e6d0f62..744fea8792c 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -1,4 +1,4 @@ -import { readFileSync as readFileSyncOriginal } from "node:fs"; +import { existsSync as existsSyncOriginal, readFileSync as readFileSyncOriginal } from "node:fs"; import fs from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -51,6 +51,13 @@ async function writePluginPackage( } describe("bundled plugin postinstall", () => { + function existsSyncWithoutGlobalCompileCache(value: string) { + if (path.resolve(value) === path.join(tmpdir(), "node-compile-cache")) { + return false; + } + return existsSyncOriginal(value); + } + it("recognizes direct invocation through symlinked temp prefixes", () => { const realpathSync = vi.fn((value: string) => value.replace(/^\/var\/folders\//u, "/private/var/folders/"), @@ -448,6 +455,7 @@ describe("bundled plugin postinstall", () => { STATE_DIRECTORY: systemState, }, packageRoot, + existsSync: existsSyncWithoutGlobalCompileCache, log, }); diff --git a/test/scripts/run-oxlint.test.ts b/test/scripts/run-oxlint.test.ts index c8fe9daeb42..319dd3d3203 100644 --- a/test/scripts/run-oxlint.test.ts +++ b/test/scripts/run-oxlint.test.ts @@ -49,6 +49,27 @@ describe("run-oxlint", () => { hadExplicitTargets: true, remainingExplicitTargets: 1, skippedTargets: ["ui", "packages"], + skippedConfigs: [], + }); + }); + + it("filters tracked tsconfig files missing from sparse checkouts", () => { + const result = filterSparseMissingOxlintTargets( + ["--tsconfig", "config/tsconfig/oxlint.core.json", "src"], + { + fileExists: (target: string) => target.endsWith("/src"), + isSparseCheckoutEnabled: () => true, + isTrackedPath: ({ target }: { target: string }) => + target === "config/tsconfig/oxlint.core.json", + }, + ); + + expect(result).toEqual({ + args: ["src"], + hadExplicitTargets: true, + remainingExplicitTargets: 1, + skippedTargets: [], + skippedConfigs: ["config/tsconfig/oxlint.core.json"], }); }); @@ -63,6 +84,7 @@ describe("run-oxlint", () => { args: ["src", "typo"], remainingExplicitTargets: 2, skippedTargets: [], + skippedConfigs: [], }); }); }); diff --git a/test/scripts/test-live-codex-harness-docker.test.ts b/test/scripts/test-live-codex-harness-docker.test.ts new file mode 100644 index 00000000000..ff1ab0da32f --- /dev/null +++ b/test/scripts/test-live-codex-harness-docker.test.ts @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const SCRIPT_PATH = path.resolve( + import.meta.dirname, + "../../scripts/test-live-codex-harness-docker.sh", +); + +describe("scripts/test-live-codex-harness-docker.sh", () => { + it("mounts cache and npm tool dirs outside the bind-mounted Docker home", () => { + const script = fs.readFileSync(SCRIPT_PATH, "utf8"); + + expect(script).toContain('DOCKER_CACHE_CONTAINER_DIR="/tmp/openclaw-cache"'); + expect(script).toContain('DOCKER_CLI_TOOLS_CONTAINER_DIR="/tmp/openclaw-npm-global"'); + expect(script).toContain("openclaw_live_codex_harness_is_ci()"); + expect(script).toContain('[[ -n "${CI:-}" && "${CI:-}" != "false" ]]'); + expect(script).toContain('-e XDG_CACHE_HOME="$DOCKER_CACHE_CONTAINER_DIR"'); + expect(script).toContain('-e NPM_CONFIG_PREFIX="$DOCKER_CLI_TOOLS_CONTAINER_DIR"'); + expect(script).toContain('chmod 0777 "$CLI_TOOLS_DIR" "$CACHE_HOME_DIR" || true'); + expect(script).toContain('-v "$CACHE_HOME_DIR":"$DOCKER_CACHE_CONTAINER_DIR"'); + expect(script).toContain('-v "$CLI_TOOLS_DIR":"$DOCKER_CLI_TOOLS_CONTAINER_DIR"'); + expect(script).not.toContain('-v "$CACHE_HOME_DIR":/home/node/.cache'); + expect(script).not.toContain('-v "$CLI_TOOLS_DIR":/home/node/.npm-global'); + }); + + it("fails before Docker build when codex-auth has no host auth file", () => { + const script = fs.readFileSync(SCRIPT_PATH, "utf8"); + + expect(script).toContain( + "OPENCLAW_LIVE_CODEX_HARNESS_AUTH=codex-auth requires ~/.codex/auth.json before building the live Docker image", + ); + expect(script).toContain( + "If this is a Testbox/API-key run, set OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key and run through openclaw-testbox-env.", + ); + expect(script.indexOf("requires ~/.codex/auth.json before building")).toBeLessThan( + script.indexOf('OPENCLAW_LIVE_DOCKER_REPO_ROOT="$ROOT_DIR"'), + ); + }); +}); From 358cd87ff300fd515bf35f9725dd59198fb9c416 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:24:43 +0100 Subject: [PATCH 077/107] ci(release): split release soak validation --- .github/workflows/full-release-validation.yml | 40 ++++++++++++- .github/workflows/openclaw-release-checks.yml | 33 +++++++++-- docs/ci.md | 10 ++-- docs/help/testing.md | 6 +- docs/reference/RELEASING.md | 37 +++++++----- docs/reference/full-release-validation.md | 58 ++++++++++--------- 6 files changed, 131 insertions(+), 53 deletions(-) diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index bbf39f3d773..ac58b612c8a 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -35,6 +35,11 @@ on: - minimum - stable - full + run_release_soak: + description: Run exhaustive live/Docker and upgrade-survivor soak lanes; forced on for release_profile=full + required: false + default: false + type: boolean rerun_group: description: Validation group to run required: false @@ -136,6 +141,7 @@ jobs: EVIDENCE_PACKAGE_SPEC: ${{ inputs.evidence_package_spec }} PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} RELEASE_PROFILE: ${{ inputs.release_profile }} + RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'full' }} RERUN_GROUP: ${{ inputs.rerun_group }} LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }} run: | @@ -145,6 +151,7 @@ jobs: echo "- Target ref: \`${TARGET_REF}\`" echo "- Target SHA: \`${TARGET_SHA}\`" echo "- Child workflow ref: \`${CHILD_WORKFLOW_REF}\`" + echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" echo "- Rerun group: \`${RERUN_GROUP}\`" if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then echo "- Live suite filter: \`${LIVE_SUITE_FILTER}\`" @@ -206,7 +213,7 @@ jobs: local workflow="$1" shift - local before_json dispatch_output run_id status conclusion url + local before_json dispatch_output run_id status conclusion url poll_count before_json="$(gh run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')" dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" @@ -246,11 +253,17 @@ jobs: } trap cancel_child EXIT INT TERM + poll_count=0 while true; do status="$(gh run view "$run_id" --json status --jq '.status')" if [[ "$status" == "completed" ]]; then break fi + poll_count=$((poll_count + 1)) + if (( poll_count % 10 == 0 )); then + echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" + gh run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true + fi sleep 30 done trap - EXIT INT TERM @@ -299,7 +312,7 @@ jobs: local workflow="$1" shift - local before_json dispatch_output run_id status conclusion url + local before_json dispatch_output run_id status conclusion url poll_count before_json="$(gh run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')" dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" @@ -339,11 +352,17 @@ jobs: } trap cancel_child EXIT INT TERM + poll_count=0 while true; do status="$(gh run view "$run_id" --json status --jq '.status')" if [[ "$status" == "completed" ]]; then break fi + poll_count=$((poll_count + 1)) + if (( poll_count % 10 == 0 )); then + echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" + gh run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true + fi sleep 30 done trap - EXIT INT TERM @@ -388,6 +407,7 @@ jobs: PROVIDER: ${{ inputs.provider }} MODE: ${{ inputs.mode }} RELEASE_PROFILE: ${{ inputs.release_profile }} + RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'full' }} RERUN_GROUP: ${{ inputs.rerun_group }} LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }} PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} @@ -398,7 +418,7 @@ jobs: local workflow="$1" shift - local before_json dispatch_output run_id status conclusion url + local before_json dispatch_output run_id status conclusion url poll_count before_json="$(gh run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')" dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" @@ -438,11 +458,17 @@ jobs: } trap cancel_child EXIT INT TERM + poll_count=0 while true; do status="$(gh run view "$run_id" --json status --jq '.status')" if [[ "$status" == "completed" ]]; then break fi + poll_count=$((poll_count + 1)) + if (( poll_count % 10 == 0 )); then + echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" + gh run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true + fi sleep 30 done trap - EXIT INT TERM @@ -465,6 +491,7 @@ jobs: echo "- Provider: \`${PROVIDER}\`" echo "- Cross-OS mode: \`${MODE}\`" echo "- Release profile: \`${RELEASE_PROFILE}\`" + echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" echo "- Rerun group: \`${RERUN_GROUP}\`" if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then echo "- Live suite filter: \`${LIVE_SUITE_FILTER}\`" @@ -485,6 +512,7 @@ jobs: -f provider="$PROVIDER" -f mode="$MODE" -f release_profile="$RELEASE_PROFILE" + -f run_release_soak="$RUN_RELEASE_SOAK" -f rerun_group="$child_rerun_group" ) if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then @@ -640,11 +668,17 @@ jobs: } trap cancel_child EXIT INT TERM + poll_count=0 while true; do status="$(gh run view "$run_id" --json status --jq '.status')" if [[ "$status" == "completed" ]]; then break fi + poll_count=$((poll_count + 1)) + if (( poll_count % 10 == 0 )); then + echo "Still waiting on npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" + gh run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true + fi sleep 30 done trap - EXIT INT TERM diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 92686c9c7ac..d1469c5e8c7 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -39,6 +39,11 @@ on: - minimum - stable - full + run_release_soak: + description: Run exhaustive live/Docker and upgrade-survivor soak lanes; forced on for release_profile=full + required: false + default: false + type: boolean rerun_group: description: Release check group to run required: false @@ -86,6 +91,7 @@ jobs: provider: ${{ steps.inputs.outputs.provider }} mode: ${{ steps.inputs.outputs.mode }} release_profile: ${{ steps.inputs.outputs.release_profile }} + run_release_soak: ${{ steps.inputs.outputs.run_release_soak }} rerun_group: ${{ steps.inputs.outputs.rerun_group }} live_suite_filter: ${{ steps.inputs.outputs.live_suite_filter }} qa_live_matrix_enabled: ${{ steps.inputs.outputs.qa_live_matrix_enabled }} @@ -206,6 +212,7 @@ jobs: RELEASE_PROVIDER_INPUT: ${{ inputs.provider }} RELEASE_MODE_INPUT: ${{ inputs.mode }} RELEASE_PROFILE_INPUT: ${{ inputs.release_profile }} + RELEASE_RUN_RELEASE_SOAK_INPUT: ${{ inputs.run_release_soak }} RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }} RELEASE_LIVE_SUITE_FILTER_INPUT: ${{ inputs.live_suite_filter }} RELEASE_QA_SLACK_LIVE_CI_ENABLED: ${{ vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED || 'false' }} @@ -221,6 +228,15 @@ jobs: else qa_live_slack_ci_enabled=true fi + run_release_soak="$(printf '%s' "$RELEASE_RUN_RELEASE_SOAK_INPUT" | tr '[:upper:]' '[:lower:]')" + if [[ "$run_release_soak" != "true" && "$run_release_soak" != "1" && "$run_release_soak" != "yes" ]]; then + run_release_soak=false + else + run_release_soak=true + fi + if [[ "$RELEASE_PROFILE_INPUT" == "full" ]]; then + run_release_soak=true + fi filter="$(printf '%s' "$RELEASE_LIVE_SUITE_FILTER_INPUT" | tr '[:upper:]' '[:lower:]')" if [[ -n "${filter// }" ]]; then @@ -273,6 +289,7 @@ jobs: printf 'provider=%s\n' "$RELEASE_PROVIDER_INPUT" printf 'mode=%s\n' "$RELEASE_MODE_INPUT" printf 'release_profile=%s\n' "$RELEASE_PROFILE_INPUT" + printf 'run_release_soak=%s\n' "$run_release_soak" printf 'rerun_group=%s\n' "$RELEASE_RERUN_GROUP_INPUT" printf 'live_suite_filter=%s\n' "$RELEASE_LIVE_SUITE_FILTER_INPUT" printf 'qa_live_matrix_enabled=%s\n' "$qa_live_matrix_enabled" @@ -289,6 +306,7 @@ jobs: RELEASE_PROVIDER: ${{ inputs.provider }} RELEASE_MODE: ${{ inputs.mode }} RELEASE_PROFILE: ${{ inputs.release_profile }} + RUN_RELEASE_SOAK: ${{ steps.inputs.outputs.run_release_soak }} RELEASE_RERUN_GROUP: ${{ inputs.rerun_group }} RELEASE_LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }} PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} @@ -302,6 +320,7 @@ jobs: echo "- Cross-OS provider: \`${RELEASE_PROVIDER}\`" echo "- Cross-OS mode: \`${RELEASE_MODE}\`" echo "- Release profile: \`${RELEASE_PROFILE}\`" + echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" echo "- Rerun group: \`${RELEASE_RERUN_GROUP}\`" if [[ -n "${RELEASE_LIVE_SUITE_FILTER// }" ]]; then echo "- Live suite filter: \`${RELEASE_LIVE_SUITE_FILTER}\`" @@ -312,7 +331,11 @@ jobs: else echo "- Package Acceptance package spec: prepared release artifact" fi - echo "- This run will execute cross-OS release validation, install smoke, QA Lab parity, Matrix, Telegram, and Slack lanes, and the non-Parallels Docker/live/openwebui coverage from the CI migration plan." + if [[ "$RUN_RELEASE_SOAK" == "true" ]]; then + echo "- This run will execute blocking release validation plus exhaustive live/Docker soak coverage." + else + echo "- This run will execute blocking release validation. Exhaustive live/Docker soak lanes are skipped unless \`run_release_soak=true\`, \`release_profile=full\`, or \`rerun_group=live-e2e\` is selected." + fi } >> "$GITHUB_STEP_SUMMARY" prepare_release_package: @@ -423,7 +446,7 @@ jobs: live_repo_e2e_release_checks: name: Run repo/live E2E validation needs: [resolve_target] - if: contains(fromJSON('["all","live-e2e"]'), needs.resolve_target.outputs.rerun_group) + if: needs.resolve_target.outputs.rerun_group == 'live-e2e' || (needs.resolve_target.outputs.rerun_group == 'all' && needs.resolve_target.outputs.run_release_soak == 'true') permissions: actions: read contents: read @@ -488,7 +511,7 @@ jobs: docker_e2e_release_checks: name: Run Docker release-path validation needs: [resolve_target, prepare_release_package] - if: contains(fromJSON('["all","live-e2e"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.live_suite_filter == '' + if: (needs.resolve_target.outputs.rerun_group == 'live-e2e' || (needs.resolve_target.outputs.rerun_group == 'all' && needs.resolve_target.outputs.run_release_soak == 'true')) && needs.resolve_target.outputs.live_suite_filter == '' permissions: actions: read contents: read @@ -523,8 +546,8 @@ jobs: package_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }} suite_profile: custom docker_lanes: doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update - published_upgrade_survivor_baselines: all-since-2026.4.23 - published_upgrade_survivor_scenarios: reported-issues + published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'all-since-2026.4.23' || '' }} + published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }} telegram_mode: mock-openai telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-current-session-status-tool,telegram-mention-gating secrets: diff --git a/docs/ci.md b/docs/ci.md index 878d5deeb6e..6c67eb6e1e0 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -152,7 +152,7 @@ Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured ## Full Release Validation -`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, Docker release-path suites, live/E2E, OpenWebUI, QA Lab parity, Matrix, and Telegram lanes. With `rerun_group=all` and `release_profile=full`, it also runs `NPM Telegram Beta E2E` against the `release-package-under-test` artifact from release checks. After publishing, pass `npm_telegram_package_spec` to rerun the same Telegram package lane against the published npm package. +`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, QA Lab parity, Matrix, and Telegram lanes. Stable/default runs keep exhaustive live/E2E and Docker release-path coverage behind `run_release_soak=true`; `release_profile=full` forces that soak coverage on so broad advisory validation remains broad. With `rerun_group=all` and `release_profile=full`, it also runs `NPM Telegram Beta E2E` against the `release-package-under-test` artifact from release checks. After publishing, pass `npm_telegram_package_spec` to rerun the same Telegram package lane against the published npm package. See [Full release validation](/reference/full-release-validation) for the stage matrix, exact workflow job names, profile differences, artifacts, and @@ -189,7 +189,9 @@ different SHA. `release_profile` controls live/provider breadth passed into release checks. The manual release workflows default to `stable`; use `full` only when you -intentionally want the broad advisory provider/media matrix. +intentionally want the broad advisory provider/media matrix. `run_release_soak` +controls whether stable/default release checks run the exhaustive live/E2E and +Docker release-path soak; `full` forces soak on. - `minimum` keeps the fastest OpenAI/core release-critical lanes. - `stable` adds the stable provider/backend set. @@ -199,7 +201,7 @@ The umbrella records the dispatched child run ids, and the final `Verify full va For recovery, both `Full Release Validation` and `OpenClaw Release Checks` accept `rerun_group`. Use `all` for a release candidate, `ci` for only the normal full CI child, `plugin-prerelease` for only the plugin prerelease child, `release-checks` for every release child, or a narrower group: `install-smoke`, `cross-os`, `live-e2e`, `package`, `qa`, `qa-parity`, `qa-live`, or `npm-telegram` on the umbrella. This keeps a failed release box rerun bounded after a focused fix. -`OpenClaw Release Checks` uses the trusted workflow ref to resolve the selected ref once into a `release-package-under-test` tarball, then passes that artifact to both the live/E2E release-path Docker workflow and the package acceptance shard. That keeps the package bytes consistent across release boxes and avoids repacking the same candidate in multiple child jobs. +`OpenClaw Release Checks` uses the trusted workflow ref to resolve the selected ref once into a `release-package-under-test` tarball, then passes that artifact to cross-OS checks and Package Acceptance, plus the live/E2E release-path Docker workflow when soak coverage runs. That keeps the package bytes consistent across release boxes and avoids repacking the same candidate in multiple child jobs. Duplicate `Full Release Validation` runs for `ref=main` and `rerun_group=all` supersede the older umbrella. The parent monitor cancels any child workflow it @@ -263,7 +265,7 @@ For the dedicated update and plugin testing policy, including local commands, Docker lanes, Package Acceptance inputs, release defaults, and failure triage, see [Testing updates and plugins](/help/testing-updates-plugins). -Release checks call Package Acceptance with `source=artifact`, the prepared release package artifact, `suite_profile=custom`, `docker_lanes='doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update'`, `published_upgrade_survivor_baselines=all-since-2026.4.23`, `published_upgrade_survivor_scenarios=reported-issues`, and `telegram_mode=mock-openai`. This keeps package migration, update, stale-plugin-dependency cleanup, configured-plugin install repair, offline plugin, plugin-update, and Telegram proof on the same resolved package tarball. Set `package_acceptance_package_spec` on Full Release Validation or OpenClaw Release Checks to run that same matrix against a shipped npm package instead of the SHA-built artifact. Cross-OS release checks still cover OS-specific onboarding, installer, and platform behavior; package/update product validation should start with Package Acceptance. The `published-upgrade-survivor` Docker lane validates one published package baseline per run. In Package Acceptance, the resolved `package-under-test` tarball is always the candidate and `published_upgrade_survivor_baseline` selects the fallback published baseline, defaulting to `openclaw@latest`; failed-lane rerun commands preserve that baseline. Set `published_upgrade_survivor_baselines=all-since-2026.4.23` to expand Full Release CI across every stable npm release from `2026.4.23` through `latest`; `release-history` remains available for manual wider sampling with the older pre-date anchor. Set `published_upgrade_survivor_scenarios=reported-issues` to expand the same baselines across issue-shaped fixtures for Feishu config, preserved bootstrap/persona files, configured OpenClaw plugin installs, tilde log paths, and stale legacy plugin dependency roots. The separate `Update Migration` workflow uses the `update-migration` Docker lane with `all-since-2026.4.23` and `plugin-deps-cleanup` when the question is exhaustive published update cleanup, not normal Full Release CI breadth. Local aggregate runs can pass exact package specs with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, keep a single lane with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC` such as `openclaw@2026.4.15`, or set `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` for the scenario matrix. The published lane configures the baseline with a baked `openclaw config set` command recipe, records recipe steps in `summary.json`, and probes `/healthz`, `/readyz`, plus RPC status after Gateway start. The Windows packaged and installer fresh lanes also verify that an installed package can import a browser-control override from a raw absolute Windows path. The OpenAI cross-OS agent-turn smoke defaults to `OPENCLAW_CROSS_OS_OPENAI_MODEL` when set, otherwise `openai/gpt-5.4`, so the install and gateway proof stays on a GPT-5 test model while avoiding GPT-4.x defaults. +Release checks call Package Acceptance with `source=artifact`, the prepared release package artifact, `suite_profile=custom`, `docker_lanes='doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update'`, and `telegram_mode=mock-openai`. This keeps package migration, update, stale-plugin-dependency cleanup, configured-plugin install repair, offline plugin, plugin-update, and Telegram proof on the same resolved package tarball. Set `package_acceptance_package_spec` on Full Release Validation or OpenClaw Release Checks to run that same matrix against a shipped npm package instead of the SHA-built artifact. Cross-OS release checks still cover OS-specific onboarding, installer, and platform behavior; package/update product validation should start with Package Acceptance. The `published-upgrade-survivor` Docker lane validates one published package baseline per run in the blocking release path. In Package Acceptance, the resolved `package-under-test` tarball is always the candidate and `published_upgrade_survivor_baseline` selects the fallback published baseline, defaulting to `openclaw@latest`; failed-lane rerun commands preserve that baseline. Full Release Validation with `run_release_soak=true` or `release_profile=full` sets `published_upgrade_survivor_baselines=all-since-2026.4.23` and `published_upgrade_survivor_scenarios=reported-issues` to expand across every stable npm release from `2026.4.23` through `latest` and issue-shaped fixtures for Feishu config, preserved bootstrap/persona files, configured OpenClaw plugin installs, tilde log paths, and stale legacy plugin dependency roots. The separate `Update Migration` workflow uses the `update-migration` Docker lane with `all-since-2026.4.23` and `plugin-deps-cleanup` when the question is exhaustive published update cleanup, not normal Full Release CI breadth. Local aggregate runs can pass exact package specs with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, keep a single lane with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC` such as `openclaw@2026.4.15`, or set `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` for the scenario matrix. The published lane configures the baseline with a baked `openclaw config set` command recipe, records recipe steps in `summary.json`, and probes `/healthz`, `/readyz`, plus RPC status after Gateway start. The Windows packaged and installer fresh lanes also verify that an installed package can import a browser-control override from a raw absolute Windows path. The OpenAI cross-OS agent-turn smoke defaults to `OPENCLAW_CROSS_OS_OPENAI_MODEL` when set, otherwise `openai/gpt-5.4`, so the install and gateway proof stays on a GPT-5 test model while avoiding GPT-4.x defaults. ### Legacy compatibility windows diff --git a/docs/help/testing.md b/docs/help/testing.md index 179d5b6fb4f..daaa48c0163 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -112,7 +112,9 @@ These commands sit beside the main test suites when you need QA-lab realism: CI runs QA Lab in dedicated workflows. Agentic parity is nested under `QA-Lab - All Lanes` and release validation, not a standalone PR workflow. Broad validation should use `Full Release Validation` with -`rerun_group=qa-parity` or the release-checks QA group. `QA-Lab - All Lanes` +`rerun_group=qa-parity` or the release-checks QA group. Stable/default release +checks keep exhaustive live/Docker soak behind `run_release_soak=true`; the +`full` profile forces soak on. `QA-Lab - All Lanes` runs nightly on `main` and from manual dispatch with the mock parity lane, live Matrix lane, Convex-managed live Telegram lane, and Convex-managed live Discord lane as parallel jobs. Scheduled QA and release checks pass Matrix @@ -641,7 +643,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - Npm tarball onboarding/channel/agent smoke: `pnpm test:docker:npm-onboard-channel-agent` installs the packed OpenClaw tarball globally in Docker, configures OpenAI via env-ref onboarding plus Telegram by default, runs doctor, and runs one mocked OpenAI agent turn. Reuse a prebuilt tarball with `OPENCLAW_CURRENT_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host rebuild with `OPENCLAW_NPM_ONBOARD_HOST_BUILD=0`, or switch channel with `OPENCLAW_NPM_ONBOARD_CHANNEL=discord`. - Update channel switch smoke: `pnpm test:docker:update-channel-switch` installs the packed OpenClaw tarball globally in Docker, switches from package `stable` to git `dev`, verifies the persisted channel and plugin post-update work, then switches back to package `stable` and checks update status. - Upgrade survivor smoke: `pnpm test:docker:upgrade-survivor` installs the packed OpenClaw tarball over a dirty old-user fixture with agents, channel config, plugin allowlists, stale plugin dependency state, and existing workspace/session files. It runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks config/state preservation plus startup/status budgets. -- Published upgrade survivor smoke: `pnpm test:docker:published-upgrade-survivor` installs `openclaw@latest` by default, seeds realistic existing-user files, configures that baseline with a baked command recipe, validates the resulting config, updates that published install to the candidate tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks configured intents, state preservation, startup, `/healthz`, `/readyz`, and RPC status budgets. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, ask the aggregate scheduler to expand exact baselines with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS` such as `all-since-2026.4.23`, and expand issue-shaped fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` such as `reported-issues`; the reported-issues set includes `configured-plugin-installs` for automatic external OpenClaw plugin install repair. Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. +- Published upgrade survivor smoke: `pnpm test:docker:published-upgrade-survivor` installs `openclaw@latest` by default, seeds realistic existing-user files, configures that baseline with a baked command recipe, validates the resulting config, updates that published install to the candidate tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks configured intents, state preservation, startup, `/healthz`, `/readyz`, and RPC status budgets. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, ask the aggregate scheduler to expand exact baselines with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS` such as `all-since-2026.4.23`, and expand issue-shaped fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS` such as `reported-issues`; the reported-issues set includes `configured-plugin-installs` for automatic external OpenClaw plugin install repair. Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`; Full Release Validation uses the default latest baseline in the blocking path and expands to all-since/reported-issues only for `run_release_soak=true` or `release_profile=full`. - Session runtime context smoke: `pnpm test:docker:session-runtime-context` verifies hidden runtime context transcript persistence plus doctor repair of affected duplicated prompt-rewrite branches. - Bun global install smoke: `bash scripts/e2e/bun-global-install-smoke.sh` packs the current tree, installs it with `bun install -g` in an isolated home, and verifies `openclaw infer image providers --json` returns bundled image providers instead of hanging. Reuse a prebuilt tarball with `OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host build with `OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD=0`, or copy `dist/` from a built Docker image with `OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE=openclaw-dockerfile-smoke:local`. - Installer Docker smoke: `bash scripts/test-install-sh-docker.sh` shares one npm cache across its root, update, and direct-npm containers. Update smoke defaults to npm `latest` as the stable baseline before upgrading to the candidate tarball. Override with `OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE=2026.4.22` locally, or with the Install Smoke workflow's `update_baseline_version` input on GitHub. Non-root installer checks keep an isolated npm cache so root-owned cache entries do not mask user-local install behavior. Set `OPENCLAW_INSTALL_SMOKE_NPM_CACHE_DIR=/path/to/cache` to reuse the root/update/direct-npm cache across local reruns. diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index d5825ab98cd..3e972641ebe 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -114,11 +114,13 @@ the maintainer-only release runbook. - Run the manual `Full Release Validation` workflow before release approval to kick off all pre-release test boxes from one entrypoint. It accepts a branch, tag, or full commit SHA, dispatches manual `CI`, and dispatches - `OpenClaw Release Checks` for install smoke, package acceptance, Docker - release-path suites, live/E2E, OpenWebUI, QA Lab parity, Matrix, and Telegram - lanes. With `release_profile=full` and `rerun_group=all`, it also runs package - Telegram E2E against the `release-package-under-test` artifact from release - checks. Provide `npm_telegram_package_spec` after publishing when the same + `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS + package checks, QA Lab parity, Matrix, and Telegram lanes. Stable/default runs + keep exhaustive live/E2E and Docker release-path soak behind + `run_release_soak=true`; `release_profile=full` forces soak on. With + `release_profile=full` and `rerun_group=all`, it also runs package Telegram + E2E against the `release-package-under-test` artifact from release checks. + Provide `npm_telegram_package_spec` after publishing when the same Telegram E2E should prove the published npm package too. Provide `package_acceptance_package_spec` after publishing when Package Acceptance should run its package/update matrix against the shipped npm package instead @@ -293,8 +295,8 @@ parent `release-package-under-test` artifact for package-facing checks, and dispatches standalone package Telegram E2E when `release_profile=full` with `rerun_group=all` or when `npm_telegram_package_spec` is set. `OpenClaw Release Checks` then fans out install smoke, cross-OS release checks, live/E2E Docker -release-path coverage, Package Acceptance with Telegram package QA, QA Lab -parity, live Matrix, and live Telegram. A full run is only acceptable when the +release-path coverage when soak is enabled, Package Acceptance with Telegram +package QA, QA Lab parity, live Matrix, and live Telegram. A full run is only acceptable when the `Full Release Validation` summary shows `normal_ci` and `release_checks` as successful. In full/all mode, the `npm_telegram` child must also be successful; outside full/all it is skipped @@ -318,10 +320,15 @@ Use `release_profile` to select live/provider breadth: - `stable`: minimum plus stable provider/backend coverage for release approval - `full`: stable plus broad advisory provider/media coverage +Use `run_release_soak=true` with `stable` when the release-blocking lanes are +green and you want the exhaustive live/E2E, Docker release-path, and +all-since-2026.4.23 upgrade-survivor sweep before promotion. `full` implies +`run_release_soak=true`. + `OpenClaw Release Checks` uses the trusted workflow ref to resolve the target -ref once as `release-package-under-test` and reuses that artifact in both -release-path Docker checks and Package Acceptance. This keeps all -package-facing boxes on the same bytes and avoids repeated package builds. +ref once as `release-package-under-test` and reuses that artifact in cross-OS, +Package Acceptance, and release-path Docker checks when soak runs. This keeps +all package-facing boxes on the same bytes and avoids repeated package builds. The cross-OS OpenAI install smoke uses `OPENCLAW_CROSS_OS_OPENAI_MODEL` when the repo/org variable is set, otherwise `openai/gpt-5.4`, because this lane is proving package install, onboarding, gateway startup, and one live agent turn @@ -474,11 +481,12 @@ Supported candidate sources: `OpenClaw Release Checks` runs Package Acceptance with `source=artifact`, the prepared release package artifact, `suite_profile=custom`, `docker_lanes=doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update`, -`published_upgrade_survivor_baselines=all-since-2026.4.23`, -`published_upgrade_survivor_scenarios=reported-issues`, and `telegram_mode=mock-openai`. Package Acceptance keeps migration, update, stale plugin dependency cleanup, offline plugin fixtures, plugin update, and Telegram -package QA against the same resolved tarball. The upgrade matrix covers every stable npm-published baseline from `2026.4.23` through `latest`; use +package QA against the same resolved tarball. Blocking release checks use the +default latest published package baseline; `run_release_soak=true` or +`release_profile=full` expands to every stable npm-published baseline from +`2026.4.23` through `latest` plus reported-issue fixtures. Use Package Acceptance with `source=npm` for an already shipped candidate, or `source=ref`/`source=artifact` for a SHA-backed local npm tarball before publish. It is the GitHub-native @@ -615,6 +623,9 @@ OpenClaw package must not be published. - `ref`: branch, tag, or full commit SHA to validate. Secret-bearing checks require the resolved commit to be reachable from an OpenClaw branch or release tag. +- `run_release_soak`: opt into exhaustive live/E2E, Docker release-path, and + all-since upgrade-survivor soak on stable/default release checks. It is forced + on by `release_profile=full`. Rules: diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index c44c0ee5cc9..6baeea4b1ee 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -27,6 +27,11 @@ Child workflows use the trusted workflow ref for the harness and the input `ref` for the candidate under test. That keeps new validation logic available when validating an older release branch or tag. +By default, `release_profile=stable` runs the release-blocking lanes and skips +the exhaustive live/Docker soak. Pass `run_release_soak=true` to include the +soak lanes on a stable run. `release_profile=full` always enables soak lanes so +the broad advisory profile never drops coverage silently. + Package Acceptance normally builds the candidate tarball from the resolved `ref`, including full-SHA runs dispatched with `pnpm ci:full-release`. After publish, pass `package_acceptance_package_spec=openclaw@YYYY.M.D` (or @@ -35,15 +40,15 @@ the shipped npm package instead. ## Top-level stages -| Stage | Details | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Target resolution | **Job:** `Resolve target ref`
**Child workflow:** none
**Proves:** resolves the release branch, tag, or full commit SHA and records selected inputs.
**Rerun:** rerun the umbrella if this fails. | -| Vitest and normal CI | **Job:** `Run normal full CI`
**Child workflow:** `CI`
**Proves:** manual full CI graph against the target ref, including Linux Node lanes, bundled plugin shards, channel contracts, Node 22 compatibility, `check`, `check-additional`, build smoke, docs checks, Python skills, Windows, macOS, Control UI i18n, and Android via the umbrella.
**Rerun:** `rerun_group=ci`. | -| Plugin prerelease | **Job:** `Run plugin prerelease validation`
**Child workflow:** `Plugin Prerelease`
**Proves:** release-only plugin static checks, agentic plugin coverage, full extension batch shards, and plugin prerelease Docker lanes.
**Rerun:** `rerun_group=plugin-prerelease`. | -| Release checks | **Job:** `Run release/live/Docker/QA validation`
**Child workflow:** `OpenClaw Release Checks`
**Proves:** install smoke, cross-OS package checks, live/E2E suites, Docker release-path chunks, Package Acceptance, QA Lab parity, live Matrix, and live Telegram.
**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. | -| Package artifact | **Job:** `Prepare release package artifact`
**Child workflow:** none
**Proves:** creates the parent `release-package-under-test` tarball early enough for package-facing checks that do not need to wait for `OpenClaw Release Checks`.
**Rerun:** rerun the umbrella or provide `npm_telegram_package_spec` for `rerun_group=npm-telegram`. | -| Package Telegram | **Job:** `Run package Telegram E2E`
**Child workflow:** `NPM Telegram Beta E2E`
**Proves:** parent-artifact-backed Telegram package proof for `rerun_group=all` with `release_profile=full`, or published-package Telegram proof when `npm_telegram_package_spec` is set.
**Rerun:** `rerun_group=npm-telegram` with `npm_telegram_package_spec`. | -| Umbrella verifier | **Job:** `Verify full validation`
**Child workflow:** none
**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.
**Rerun:** rerun only this job after rerunning a failed child to green. | +| Stage | Details | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Target resolution | **Job:** `Resolve target ref`
**Child workflow:** none
**Proves:** resolves the release branch, tag, or full commit SHA and records selected inputs.
**Rerun:** rerun the umbrella if this fails. | +| Vitest and normal CI | **Job:** `Run normal full CI`
**Child workflow:** `CI`
**Proves:** manual full CI graph against the target ref, including Linux Node lanes, bundled plugin shards, channel contracts, Node 22 compatibility, `check`, `check-additional`, build smoke, docs checks, Python skills, Windows, macOS, Control UI i18n, and Android via the umbrella.
**Rerun:** `rerun_group=ci`. | +| Plugin prerelease | **Job:** `Run plugin prerelease validation`
**Child workflow:** `Plugin Prerelease`
**Proves:** release-only plugin static checks, agentic plugin coverage, full extension batch shards, and plugin prerelease Docker lanes.
**Rerun:** `rerun_group=plugin-prerelease`. | +| Release checks | **Job:** `Run release/live/Docker/QA validation`
**Child workflow:** `OpenClaw Release Checks`
**Proves:** install smoke, cross-OS package checks, Package Acceptance, QA Lab parity, live Matrix, and live Telegram. With `run_release_soak=true` or `release_profile=full`, also runs exhaustive live/E2E suites and Docker release-path chunks.
**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. | +| Package artifact | **Job:** `Prepare release package artifact`
**Child workflow:** none
**Proves:** creates the parent `release-package-under-test` tarball early enough for package-facing checks that do not need to wait for `OpenClaw Release Checks`.
**Rerun:** rerun the umbrella or provide `npm_telegram_package_spec` for `rerun_group=npm-telegram`. | +| Package Telegram | **Job:** `Run package Telegram E2E`
**Child workflow:** `NPM Telegram Beta E2E`
**Proves:** parent-artifact-backed Telegram package proof for `rerun_group=all` with `release_profile=full`, or published-package Telegram proof when `npm_telegram_package_spec` is set.
**Rerun:** `rerun_group=npm-telegram` with `npm_telegram_package_spec`. | +| Umbrella verifier | **Job:** `Verify full validation`
**Child workflow:** none
**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.
**Rerun:** rerun only this job after rerunning a failed child to green. | For `ref=main` and `rerun_group=all`, a newer umbrella supersedes an older one. When the parent is cancelled, its monitor cancels any child workflow it already @@ -56,19 +61,19 @@ default. once and prepares a shared `release-package-under-test` artifact when package or Docker-facing stages need it. -| Stage | Details | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Release target | **Job:** `Resolve target ref`
**Backing workflow:** none
**Tests:** selected ref, optional expected SHA, profile, rerun group, and focused live suite filter.
**Rerun:** `rerun_group=release-checks`. | -| Package artifact | **Job:** `Prepare release package artifact`
**Backing workflow:** none
**Tests:** packs or resolves one candidate tarball and uploads `release-package-under-test` for downstream package-facing checks.
**Rerun:** the affected package, cross-OS, or live/E2E group. | -| Install smoke | **Job:** `Run install smoke`
**Backing workflow:** `Install Smoke`
**Tests:** full install path with root Dockerfile smoke image reuse, QR package install, root and gateway Docker smokes, installer Docker tests, Bun global install image-provider smoke, and fast bundled-plugin install/uninstall E2E.
**Rerun:** `rerun_group=install-smoke`. | -| Cross-OS | **Job:** `cross_os_release_checks`
**Backing workflow:** `OpenClaw Cross-OS Release Checks (Reusable)`
**Tests:** fresh and upgrade lanes on Linux, Windows, and macOS for the selected provider and mode, using the candidate tarball plus a baseline package.
**Rerun:** `rerun_group=cross-os`. | -| Repo and live E2E | **Job:** `Run repo/live E2E validation`
**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`
**Tests:** repository E2E, live cache, OpenAI websocket streaming, native live provider and plugin shards, and Docker-backed live model/backend/gateway harnesses selected by `release_profile`.
**Rerun:** `rerun_group=live-e2e`, optionally with `live_suite_filter`. | -| Docker release path | **Job:** `Run Docker release-path validation`
**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`
**Tests:** release-path Docker chunks against the shared package artifact.
**Rerun:** `rerun_group=live-e2e`. | -| Package Acceptance | **Job:** `Run package acceptance`
**Backing workflow:** `Package Acceptance`
**Tests:** offline plugin package fixtures, plugin update, mock-OpenAI Telegram package acceptance, and published-upgrade survivor checks from every stable npm release at or after `2026.4.23` against the same tarball.
**Rerun:** `rerun_group=package`. | -| QA parity | **Job:** `Run QA Lab parity lane` and `Run QA Lab parity report`
**Backing workflow:** direct jobs
**Tests:** candidate and baseline agentic parity packs, then the parity report.
**Rerun:** `rerun_group=qa-parity` or `rerun_group=qa`. | -| QA live Matrix | **Job:** `Run QA Lab live Matrix lane`
**Backing workflow:** direct job
**Tests:** fast live Matrix QA profile in the `qa-live-shared` environment.
**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. | -| QA live Telegram | **Job:** `Run QA Lab live Telegram lane`
**Backing workflow:** direct job
**Tests:** live Telegram QA with Convex CI credential leases.
**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. | -| Release verifier | **Job:** `Verify release checks`
**Backing workflow:** none
**Tests:** required release-check jobs for the selected rerun group.
**Rerun:** rerun after focused child jobs pass. | +| Stage | Details | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Release target | **Job:** `Resolve target ref`
**Backing workflow:** none
**Tests:** selected ref, optional expected SHA, profile, rerun group, and focused live suite filter.
**Rerun:** `rerun_group=release-checks`. | +| Package artifact | **Job:** `Prepare release package artifact`
**Backing workflow:** none
**Tests:** packs or resolves one candidate tarball and uploads `release-package-under-test` for downstream package-facing checks.
**Rerun:** the affected package, cross-OS, or live/E2E group. | +| Install smoke | **Job:** `Run install smoke`
**Backing workflow:** `Install Smoke`
**Tests:** full install path with root Dockerfile smoke image reuse, QR package install, root and gateway Docker smokes, installer Docker tests, Bun global install image-provider smoke, and fast bundled-plugin install/uninstall E2E.
**Rerun:** `rerun_group=install-smoke`. | +| Cross-OS | **Job:** `cross_os_release_checks`
**Backing workflow:** `OpenClaw Cross-OS Release Checks (Reusable)`
**Tests:** fresh and upgrade lanes on Linux, Windows, and macOS for the selected provider and mode, using the candidate tarball plus a baseline package.
**Rerun:** `rerun_group=cross-os`. | +| Repo and live E2E | **Job:** `Run repo/live E2E validation`
**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`
**Tests:** repository E2E, live cache, OpenAI websocket streaming, native live provider and plugin shards, and Docker-backed live model/backend/gateway harnesses selected by `release_profile`.
**Runs:** `run_release_soak=true`, `release_profile=full`, or focused `rerun_group=live-e2e`.
**Rerun:** `rerun_group=live-e2e`, optionally with `live_suite_filter`. | +| Docker release path | **Job:** `Run Docker release-path validation`
**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`
**Tests:** release-path Docker chunks against the shared package artifact.
**Runs:** `run_release_soak=true`, `release_profile=full`, or focused `rerun_group=live-e2e`.
**Rerun:** `rerun_group=live-e2e`. | +| Package Acceptance | **Job:** `Run package acceptance`
**Backing workflow:** `Package Acceptance`
**Tests:** offline plugin package fixtures, plugin update, mock-OpenAI Telegram package acceptance, and published-upgrade survivor checks against the same tarball. Blocking release checks use the default latest published baseline; soak checks expand to every stable npm release at or after `2026.4.23` plus reported-issue fixtures.
**Rerun:** `rerun_group=package`. | +| QA parity | **Job:** `Run QA Lab parity lane` and `Run QA Lab parity report`
**Backing workflow:** direct jobs
**Tests:** candidate and baseline agentic parity packs, then the parity report.
**Rerun:** `rerun_group=qa-parity` or `rerun_group=qa`. | +| QA live Matrix | **Job:** `Run QA Lab live Matrix lane`
**Backing workflow:** direct job
**Tests:** fast live Matrix QA profile in the `qa-live-shared` environment.
**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. | +| QA live Telegram | **Job:** `Run QA Lab live Telegram lane`
**Backing workflow:** direct job
**Tests:** live Telegram QA with Convex CI credential leases.
**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. | +| Release verifier | **Job:** `Verify release checks`
**Backing workflow:** none
**Tests:** required release-check jobs for the selected rerun group.
**Rerun:** rerun after focused child jobs pass. | ## Docker release-path chunks @@ -93,10 +98,11 @@ commands with package artifact and image reuse inputs when available. `release_profile` mostly controls live/provider breadth inside release checks. It does not remove normal full CI, Plugin Prerelease, install smoke, package -acceptance, QA Lab, or Docker release-path chunks. `full` also makes the -umbrella run package Telegram E2E against the parent release package artifact when -`rerun_group=all`, so a full pre-publish candidate does not silently skip that -Telegram package lane. +acceptance, or QA Lab. For `stable`, exhaustive repo/live E2E and Docker +release-path chunks are soak coverage and run when `run_release_soak=true`. +`full` forces soak coverage on and also makes the umbrella run package Telegram +E2E against the parent release package artifact when `rerun_group=all`, so a full +pre-publish candidate does not silently skip that Telegram package lane. | Profile | Intended use | Included live/provider coverage | | --------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | From e84d4b27f44ce4c1bca63f9d85c2a65f2ad7f688 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 22:34:59 +0100 Subject: [PATCH 078/107] feat: add gateway stall diagnostics --- docs/gateway/diagnostics.md | 19 ++-- docs/help/debugging.md | 7 ++ scripts/run-node.mjs | 13 ++- src/cli/gateway-cli/run.ts | 3 +- src/gateway/server.impl.ts | 3 +- src/infra/diagnostic-events.ts | 26 ++++++ src/infra/run-node.test.ts | 38 ++++++++ src/logging/diagnostic-phase.ts | 96 ++++++++++++++++++++ src/logging/diagnostic-run-activity.ts | 13 +++ src/logging/diagnostic-stability-bundle.ts | 1 + src/logging/diagnostic-stability.ts | 12 +++ src/logging/diagnostic.test.ts | 84 +++++++++++++++++ src/logging/diagnostic.ts | 100 ++++++++++++++++++++- 13 files changed, 401 insertions(+), 14 deletions(-) create mode 100644 src/logging/diagnostic-phase.ts diff --git a/docs/gateway/diagnostics.md b/docs/gateway/diagnostics.md index bf3b0c600d4..5877cc9c6e3 100644 --- a/docs/gateway/diagnostics.md +++ b/docs/gateway/diagnostics.md @@ -117,12 +117,19 @@ diagnostics are enabled. It is for operational facts, not content. The same diagnostic heartbeat records liveness samples when the Gateway keeps running but the Node.js event loop or CPU looks saturated. These `diagnostic.liveness.warning` events include event-loop delay, event-loop -utilization, CPU-core ratio, and active/waiting/queued session counts. Idle -samples stay in telemetry at `info` level. Liveness samples become Gateway -warnings only when work is waiting or queued, or when active work overlaps with -sustained event-loop delay. Transient max-delay spikes during otherwise healthy -background work stay in debug logs. They do not restart the Gateway by -themselves. +utilization, CPU-core ratio, active/waiting/queued session counts, the current +startup/runtime phase when known, recent phase spans, and bounded active/queued +work labels. Idle samples stay in telemetry at `info` level. Liveness samples +become Gateway warnings only when work is waiting or queued, or when active work +overlaps with sustained event-loop delay. Transient max-delay spikes during +otherwise healthy background work stay in debug logs. They do not restart the +Gateway by themselves. + +Startup phases also emit `diagnostic.phase.completed` events with wall-clock and +CPU timing. Stalled embedded-run diagnostics mark `terminalProgressStale=true` +when the last bridge progress looked terminal, such as a raw response item or +response completion event, but the Gateway still considers the embedded run +active. Inspect the live recorder: diff --git a/docs/help/debugging.md b/docs/help/debugging.md index a88a286d114..70220c6d76c 100644 --- a/docs/help/debugging.md +++ b/docs/help/debugging.md @@ -89,6 +89,13 @@ OPENCLAW_RUN_NODE_CPU_PROF_DIR=.artifacts/cli-cpu pnpm openclaw status The source runner adds Node CPU profile flags and writes a `.cpuprofile` for the command. Use this before adding temporary instrumentation to command code. +For startup stalls that look like synchronous filesystem or module-loader work, +add Node's sync I/O trace flag through the source runner: + +```bash +OPENCLAW_TRACE_SYNC_IO=1 pnpm openclaw gateway --force +``` + ## Gateway watch mode For fast iteration, run the gateway under the file watcher: diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 46226255357..e54361af211 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -487,6 +487,15 @@ const resolveRunNodeCpuProfileArgs = (deps) => { return ["--cpu-prof", `--cpu-prof-dir=${absoluteProfileDir}`, `--cpu-prof-name=${profileName}`]; }; +const resolveRunNodeDiagnosticArgs = (deps) => { + const args = [...resolveRunNodeCpuProfileArgs(deps)]; + if (deps.env.OPENCLAW_TRACE_SYNC_IO === "1") { + logRunner("Enabling Node --trace-sync-io for startup I/O diagnostics.", deps); + args.push("--trace-sync-io"); + } + return args; +}; + const waitForSpawnedProcess = async (childProcess, deps) => { let forwardedSignal = null; let onSigInt; @@ -557,8 +566,8 @@ const getInterruptedSpawnExitCode = (res) => { }; const runOpenClaw = async (deps) => { - const cpuProfileArgs = resolveRunNodeCpuProfileArgs(deps); - const nodeProcess = deps.spawn(deps.execPath, [...cpuProfileArgs, "openclaw.mjs", ...deps.args], { + const diagnosticArgs = resolveRunNodeDiagnosticArgs(deps); + const nodeProcess = deps.spawn(deps.execPath, [...diagnosticArgs, "openclaw.mjs", ...deps.args], { cwd: deps.cwd, env: deps.env, stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit", diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 674c8932a7c..5b23d01708d 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -25,6 +25,7 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { GatewayLockError } from "../../infra/gateway-lock.js"; import type { RespawnSupervisor } from "../../infra/supervisor-markers.js"; import { setConsoleSubsystemFilter, setConsoleTimestampPrefix } from "../../logging/console.js"; +import { withDiagnosticPhase } from "../../logging/diagnostic-phase.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { defaultRuntime } from "../../runtime.js"; import { @@ -158,7 +159,7 @@ function createGatewayCliStartupTrace() { async measure(name: string, run: () => Awaitable): Promise { const before = performance.now(); try { - return await run(); + return await withDiagnosticPhase(`cli.${name}`, run); } finally { const now = performance.now(); emit(name, now - before, now - started); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index ce3ba130d75..aa13bd722ac 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -37,6 +37,7 @@ import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import type { VoiceWakeRoutingConfig } from "../infra/voicewake-routing.js"; +import { withDiagnosticPhase } from "../logging/diagnostic-phase.js"; import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; import { @@ -355,7 +356,7 @@ function createGatewayStartupTrace() { timelineOptions(), ); try { - const result = await run(); + const result = await withDiagnosticPhase(mapTimelineName(name), run, { traceName: name }); const now = performance.now(); emitDiagnosticsTimelineEvent( { diff --git a/src/infra/diagnostic-events.ts b/src/infra/diagnostic-events.ts index 24dd86f0e5b..08015523027 100644 --- a/src/infra/diagnostic-events.ts +++ b/src/infra/diagnostic-events.ts @@ -146,6 +146,7 @@ type DiagnosticSessionAttentionBaseEvent = DiagnosticBaseEvent & { activeToolName?: string; activeToolCallId?: string; activeToolAgeMs?: number; + terminalProgressStale?: boolean; }; export type DiagnosticSessionLongRunningEvent = DiagnosticSessionAttentionBaseEvent & { @@ -206,6 +207,20 @@ export type DiagnosticHeartbeatEvent = DiagnosticBaseEvent & { export type DiagnosticLivenessWarningReason = "event_loop_delay" | "event_loop_utilization" | "cpu"; +export type DiagnosticPhaseDetails = Record; + +export type DiagnosticPhaseSnapshot = { + name: string; + startedAt: number; + endedAt?: number; + durationMs?: number; + cpuUserMs?: number; + cpuSystemMs?: number; + cpuTotalMs?: number; + cpuCoreRatio?: number; + details?: DiagnosticPhaseDetails; +}; + export type DiagnosticLivenessWarningEvent = DiagnosticBaseEvent & { type: "diagnostic.liveness.warning"; reasons: DiagnosticLivenessWarningReason[]; @@ -220,8 +235,18 @@ export type DiagnosticLivenessWarningEvent = DiagnosticBaseEvent & { active: number; waiting: number; queued: number; + phase?: string; + recentPhases?: DiagnosticPhaseSnapshot[]; + activeWorkLabels?: string[]; + waitingWorkLabels?: string[]; + queuedWorkLabels?: string[]; }; +export type DiagnosticPhaseCompletedEvent = DiagnosticBaseEvent & + DiagnosticPhaseSnapshot & { + type: "diagnostic.phase.completed"; + }; + export type DiagnosticToolLoopEvent = DiagnosticBaseEvent & { type: "tool.loop"; sessionKey?: string; @@ -501,6 +526,7 @@ export type DiagnosticEventPayload = | DiagnosticRunProgressEvent | DiagnosticHeartbeatEvent | DiagnosticLivenessWarningEvent + | DiagnosticPhaseCompletedEvent | DiagnosticToolLoopEvent | DiagnosticToolExecutionStartedEvent | DiagnosticToolExecutionCompletedEvent diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index d4bac6516ae..b38e944a929 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -497,6 +497,44 @@ describe("run-node script", () => { }); }); + it("adds Node sync I/O tracing flag to the launched OpenClaw child when requested", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + buildPaths: [DIST_ENTRY, BUILD_STAMP], + }); + const spawnCalls: string[][] = []; + const spawn = (_cmd: string, args: string[]) => { + spawnCalls.push(args); + return createExitedProcess(0); + }; + const { spawnSync } = createSpawnRecorder({ + gitHead: "abc123\n", + gitStatus: "", + }); + + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["gateway", "--force"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + OPENCLAW_TRACE_SYNC_IO: "1", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls.at(-1)).toEqual(["--trace-sync-io", "openclaw.mjs", "gateway", "--force"]); + }); + }); + it("surfaces generic output log stream errors", async () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp); diff --git a/src/logging/diagnostic-phase.ts b/src/logging/diagnostic-phase.ts new file mode 100644 index 00000000000..abd8b379a40 --- /dev/null +++ b/src/logging/diagnostic-phase.ts @@ -0,0 +1,96 @@ +import { performance } from "node:perf_hooks"; +import { + areDiagnosticsEnabledForProcess, + emitDiagnosticEvent, + type DiagnosticPhaseDetails, + type DiagnosticPhaseSnapshot, +} from "../infra/diagnostic-events.js"; + +const RECENT_PHASE_CAPACITY = 40; + +type ActiveDiagnosticPhase = { + name: string; + startedAt: number; + startedWallMs: number; + cpuStarted: NodeJS.CpuUsage; + details?: DiagnosticPhaseDetails; +}; + +let activePhaseStack: ActiveDiagnosticPhase[] = []; +let recentPhases: DiagnosticPhaseSnapshot[] = []; + +function roundMetric(value: number, digits = 1): number { + if (!Number.isFinite(value)) { + return 0; + } + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function pushRecentPhase(snapshot: DiagnosticPhaseSnapshot): void { + recentPhases.push(snapshot); + if (recentPhases.length > RECENT_PHASE_CAPACITY) { + recentPhases = recentPhases.slice(-RECENT_PHASE_CAPACITY); + } +} + +export function getCurrentDiagnosticPhase(): string | undefined { + return activePhaseStack.at(-1)?.name; +} + +export function getRecentDiagnosticPhases(limit = 8): DiagnosticPhaseSnapshot[] { + return recentPhases.slice(-Math.max(0, limit)).map((phase) => ({ ...phase })); +} + +export function recordDiagnosticPhase(snapshot: DiagnosticPhaseSnapshot): void { + pushRecentPhase(snapshot); + if (!areDiagnosticsEnabledForProcess()) { + return; + } + emitDiagnosticEvent({ + type: "diagnostic.phase.completed", + ...snapshot, + }); +} + +export async function withDiagnosticPhase( + name: string, + run: () => Promise | T, + details?: DiagnosticPhaseDetails, +): Promise { + const active: ActiveDiagnosticPhase = { + name, + startedAt: Date.now(), + startedWallMs: performance.now(), + cpuStarted: process.cpuUsage(), + details, + }; + activePhaseStack.push(active); + try { + return await run(); + } finally { + const endedAt = Date.now(); + const durationMs = roundMetric(performance.now() - active.startedWallMs, 1); + const cpu = process.cpuUsage(active.cpuStarted); + const cpuUserMs = roundMetric(cpu.user / 1_000, 1); + const cpuSystemMs = roundMetric(cpu.system / 1_000, 1); + const cpuTotalMs = roundMetric(cpuUserMs + cpuSystemMs, 1); + activePhaseStack = activePhaseStack.filter((entry) => entry !== active); + recordDiagnosticPhase({ + name, + startedAt: active.startedAt, + endedAt, + durationMs, + cpuUserMs, + cpuSystemMs, + cpuTotalMs, + cpuCoreRatio: roundMetric(cpuTotalMs / Math.max(1, durationMs), 3), + details: active.details, + }); + } +} + +export function resetDiagnosticPhasesForTest(): void { + activePhaseStack = []; + recentPhases = []; +} diff --git a/src/logging/diagnostic-run-activity.ts b/src/logging/diagnostic-run-activity.ts index f8155e94fcb..513b0d727a1 100644 --- a/src/logging/diagnostic-run-activity.ts +++ b/src/logging/diagnostic-run-activity.ts @@ -294,6 +294,19 @@ export function getDiagnosticSessionActivitySnapshot( }; } +export function markDiagnosticRunProgressForTest(params: { + sessionId?: string; + sessionKey?: string; + runId?: string; + reason: string; +}): void { + const activity = resolveSessionActivity({ ...params, create: true }); + if (!activity) { + return; + } + touchSessionActivity(activity, params.reason); +} + export function resetDiagnosticRunActivityForTest(): void { activityByRef.clear(); activityByRunId.clear(); diff --git a/src/logging/diagnostic-stability-bundle.ts b/src/logging/diagnostic-stability-bundle.ts index b8b4984ccbf..7d9d96542a6 100644 --- a/src/logging/diagnostic-stability-bundle.ts +++ b/src/logging/diagnostic-stability-bundle.ts @@ -348,6 +348,7 @@ function readStabilityEventRecord( assignOptionalCodeString(sanitized, "reason", record.reason, `${label}.reason`); assignOptionalCodeString(sanitized, "outcome", record.outcome, `${label}.outcome`); assignOptionalCodeString(sanitized, "level", record.level, `${label}.level`); + assignOptionalCodeString(sanitized, "phase", record.phase, `${label}.phase`); assignOptionalCodeString(sanitized, "detector", record.detector, `${label}.detector`); assignOptionalCodeString(sanitized, "toolName", record.toolName, `${label}.toolName`); assignOptionalCodeString( diff --git a/src/logging/diagnostic-stability.ts b/src/logging/diagnostic-stability.ts index dd4c361111b..332b6806ee7 100644 --- a/src/logging/diagnostic-stability.ts +++ b/src/logging/diagnostic-stability.ts @@ -24,6 +24,7 @@ export type DiagnosticStabilityEventRecord = { outcome?: string; mode?: string; level?: string; + phase?: string; detector?: string; deliveryKind?: string; toolName?: string; @@ -292,6 +293,17 @@ function sanitizeDiagnosticEvent(event: DiagnosticEventPayload): DiagnosticStabi record.active = event.active; record.waiting = event.waiting; record.queued = event.queued; + record.phase = event.phase; + if (event.activeWorkLabels?.length) { + record.source = event.activeWorkLabels[0]; + } else if (event.queuedWorkLabels?.length) { + record.source = event.queuedWorkLabels[0]; + } + break; + case "diagnostic.phase.completed": + record.phase = event.name; + record.durationMs = event.durationMs; + record.cpuCoreRatio = event.cpuCoreRatio; break; case "tool.loop": record.toolName = event.toolName; diff --git a/src/logging/diagnostic.test.ts b/src/logging/diagnostic.test.ts index 22d5d3c829e..ac93e0e2212 100644 --- a/src/logging/diagnostic.test.ts +++ b/src/logging/diagnostic.test.ts @@ -8,8 +8,10 @@ import { setDiagnosticsEnabledForProcess, type DiagnosticEventPayload, } from "../infra/diagnostic-events.js"; +import { withDiagnosticPhase } from "./diagnostic-phase.js"; import { getDiagnosticSessionActivitySnapshot, + markDiagnosticRunProgressForTest, markDiagnosticEmbeddedRunStarted, } from "./diagnostic-run-activity.js"; import { @@ -368,6 +370,39 @@ describe("stuck session diagnostics threshold", () => { expect(recoverStuckSession).not.toHaveBeenCalled(); }); + it("flags stale terminal bridge progress in stalled session diagnostics", async () => { + const events: DiagnosticEventPayload[] = []; + const warnSpy = vi.spyOn(diagnosticLogger, "warn").mockImplementation(() => undefined); + const unsubscribe = onDiagnosticEvent((event) => { + events.push(event); + }); + try { + logSessionStateChange({ sessionId: "s1", sessionKey: "main", state: "processing" }); + markDiagnosticEmbeddedRunStarted({ sessionId: "s1", sessionKey: "main" }); + markDiagnosticRunProgressForTest({ + sessionId: "s1", + sessionKey: "main", + reason: "codex_app_server:notification:rawResponseItem/completed", + }); + startDiagnosticHeartbeat({ + diagnostics: { + enabled: true, + stuckSessionWarnMs: 30_000, + }, + }); + + vi.advanceTimersByTime(61_000); + } finally { + unsubscribe(); + } + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("terminalProgressStale=true")); + expect(events.filter((event) => event.type === "session.stalled").at(-1)).toMatchObject({ + terminalProgressStale: true, + lastProgressReason: "codex_app_server:notification:rawResponseItem/completed", + }); + }); + it("aborts and drains embedded runs after an extended no-progress stall", () => { const events: DiagnosticEventPayload[] = []; const recoverStuckSession = vi.fn(); @@ -678,6 +713,55 @@ describe("stuck session diagnostics threshold", () => { ); }); + it("adds phase and work labels to liveness warnings", async () => { + const warnSpy = vi.spyOn(diagnosticLogger, "warn").mockImplementation(() => undefined); + const events: DiagnosticEventPayload[] = []; + const unsubscribe = onDiagnosticEvent((event) => events.push(event)); + let finishPhase!: () => void; + const phase = withDiagnosticPhase( + "startup.plugins.load", + () => + new Promise((resolve) => { + finishPhase = resolve; + }), + ); + + try { + startDiagnosticHeartbeat( + { + diagnostics: { + enabled: true, + }, + }, + { + emitMemorySample: createEmitMemorySampleMock(), + sampleLiveness: () => ({ + reasons: ["event_loop_delay"], + intervalMs: 30_000, + eventLoopDelayP99Ms: 1_500, + eventLoopDelayMaxMs: 2_000, + }), + }, + ); + + logMessageQueued({ sessionId: "s1", sessionKey: "main", source: "telegram" }); + vi.advanceTimersByTime(30_000); + } finally { + finishPhase(); + await phase; + unsubscribe(); + } + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("phase=startup.plugins.load")); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("work=[queued=main(")); + expect( + events.filter((event) => event.type === "diagnostic.liveness.warning").at(-1), + ).toMatchObject({ + phase: "startup.plugins.load", + queuedWorkLabels: [expect.stringContaining("main(")], + }); + }); + it("keeps transient event-loop max spikes debug-only when only background work is active", () => { const warnSpy = vi.spyOn(diagnosticLogger, "warn").mockImplementation(() => undefined); diff --git a/src/logging/diagnostic.ts b/src/logging/diagnostic.ts index c8840561499..65db6574ab4 100644 --- a/src/logging/diagnostic.ts +++ b/src/logging/diagnostic.ts @@ -5,9 +5,15 @@ import { areDiagnosticsEnabledForProcess, emitDiagnosticEvent, isDiagnosticsEnabled, + type DiagnosticPhaseSnapshot, type DiagnosticLivenessWarningReason, } from "../infra/diagnostic-events.js"; import { emitDiagnosticMemorySample, resetDiagnosticMemoryForTest } from "./diagnostic-memory.js"; +import { + getCurrentDiagnosticPhase, + getRecentDiagnosticPhases, + resetDiagnosticPhasesForTest, +} from "./diagnostic-phase.js"; import { getDiagnosticSessionActivitySnapshot, resetDiagnosticRunActivityForTest, @@ -81,6 +87,9 @@ type DiagnosticWorkSnapshot = { activeCount: number; waitingCount: number; queuedCount: number; + activeLabels: string[]; + waitingLabels: string[]; + queuedLabels: string[]; }; type RecoverStuckSession = (params: { @@ -142,21 +151,56 @@ function recoverStuckSession(params: { }); } -function getDiagnosticWorkSnapshot(): DiagnosticWorkSnapshot { +function formatDiagnosticWorkLabel( + state: { + sessionId?: string; + sessionKey?: string; + state: SessionStateValue; + queueDepth: number; + lastActivity: number; + }, + now: number, +): string { + const label = state.sessionKey ?? state.sessionId ?? "unknown"; + const ageSeconds = Math.round(Math.max(0, now - state.lastActivity) / 1000); + const activity = getDiagnosticSessionActivitySnapshot( + { sessionId: state.sessionId, sessionKey: state.sessionKey }, + now, + ); + const workKind = activity.activeWorkKind ? `/${activity.activeWorkKind}` : ""; + const lastProgress = activity.lastProgressReason ? ` last=${activity.lastProgressReason}` : ""; + return `${label}(${state.state}${workKind},q=${state.queueDepth},age=${ageSeconds}s${lastProgress})`; +} + +function pushLimitedDiagnosticLabel(labels: string[], label: string, limit = 5): void { + if (labels.length < limit) { + labels.push(label); + } +} + +function getDiagnosticWorkSnapshot(now = Date.now()): DiagnosticWorkSnapshot { let activeCount = 0; let waitingCount = 0; let queuedCount = 0; + const activeLabels: string[] = []; + const waitingLabels: string[] = []; + const queuedLabels: string[] = []; for (const state of diagnosticSessionStates.values()) { if (state.state === "processing") { activeCount += 1; + pushLimitedDiagnosticLabel(activeLabels, formatDiagnosticWorkLabel(state, now)); } else if (state.state === "waiting") { waitingCount += 1; + pushLimitedDiagnosticLabel(waitingLabels, formatDiagnosticWorkLabel(state, now)); + } + if (state.queueDepth > 0) { + pushLimitedDiagnosticLabel(queuedLabels, formatDiagnosticWorkLabel(state, now)); } queuedCount += state.queueDepth; } - return { activeCount, waitingCount, queuedCount }; + return { activeCount, waitingCount, queuedCount, activeLabels, waitingLabels, queuedLabels }; } function hasOpenDiagnosticWork(snapshot: DiagnosticWorkSnapshot): boolean { @@ -306,6 +350,10 @@ function emitDiagnosticLivenessWarning( sample: DiagnosticLivenessSample, work: DiagnosticWorkSnapshot, ): void { + const phase = getCurrentDiagnosticPhase(); + const recentPhases = getRecentDiagnosticPhases(6); + const recentPhaseSummary = formatRecentDiagnosticPhases(recentPhases); + const workLabelSummary = formatDiagnosticWorkLabels(work); const message = `liveness warning: reasons=${sample.reasons.join(",")} interval=${Math.round( sample.intervalMs / 1000, )}s eventLoopDelayP99Ms=${formatOptionalDiagnosticMetric( @@ -316,7 +364,11 @@ function emitDiagnosticLivenessWarning( sample.eventLoopUtilization, )} cpuCoreRatio=${formatOptionalDiagnosticMetric(sample.cpuCoreRatio)} active=${ work.activeCount - } waiting=${work.waitingCount} queued=${work.queuedCount}`; + } waiting=${work.waitingCount} queued=${work.queuedCount}${ + phase ? ` phase=${phase}` : "" + }${recentPhaseSummary ? ` recentPhases=${recentPhaseSummary}` : ""}${ + workLabelSummary ? ` work=[${workLabelSummary}]` : "" + }`; const hasBlockingWork = work.waitingCount > 0 || work.queuedCount > 0; const hasSustainedEventLoopDelay = (sample.eventLoopDelayP99Ms ?? 0) >= DEFAULT_LIVENESS_EVENT_LOOP_DELAY_WARN_MS; @@ -339,10 +391,28 @@ function emitDiagnosticLivenessWarning( active: work.activeCount, waiting: work.waitingCount, queued: work.queuedCount, + phase, + recentPhases, + activeWorkLabels: work.activeLabels, + waitingWorkLabels: work.waitingLabels, + queuedWorkLabels: work.queuedLabels, }); markActivity(); } +function formatRecentDiagnosticPhases(phases: DiagnosticPhaseSnapshot[]): string { + return phases.map((phase) => `${phase.name}:${Math.round(phase.durationMs ?? 0)}ms`).join(","); +} + +function formatDiagnosticWorkLabels(work: DiagnosticWorkSnapshot): string { + const parts = [ + work.activeLabels.length > 0 ? `active=${work.activeLabels.join("|")}` : "", + work.waitingLabels.length > 0 ? `waiting=${work.waitingLabels.join("|")}` : "", + work.queuedLabels.length > 0 ? `queued=${work.queuedLabels.join("|")}` : "", + ].filter(Boolean); + return parts.join(" "); +} + export function resolveStuckSessionWarnMs(config?: OpenClawConfig): number { const raw = config?.diagnostics?.stuckSessionWarnMs; if (typeof raw !== "number" || !Number.isFinite(raw)) { @@ -588,6 +658,9 @@ function sessionAttentionFields(params: { classification: SessionAttentionClassification; activity: DiagnosticSessionActivitySnapshot; }) { + const terminalProgressStale = isTerminalDiagnosticProgressReason( + params.activity.lastProgressReason, + ); return { ...(params.classification.activeWorkKind ? { activeWorkKind: params.classification.activeWorkKind } @@ -605,9 +678,24 @@ function sessionAttentionFields(params: { ...(params.activity.activeToolAgeMs !== undefined ? { activeToolAgeMs: params.activity.activeToolAgeMs } : {}), + ...(terminalProgressStale ? { terminalProgressStale: true } : {}), }; } +function isTerminalDiagnosticProgressReason(reason: string | undefined): boolean { + if (!reason) { + return false; + } + return ( + reason === "run:completed" || + reason === "embedded_run:ended" || + reason.includes("response.completed") || + reason.includes("rawResponseItem/completed") || + reason.includes("raw_response_item.completed") || + reason.includes("output_item.done") + ); +} + function formatSessionActivityLogFields(activity: DiagnosticSessionActivitySnapshot): string { const fields: string[] = []; if (activity.lastProgressReason) { @@ -625,6 +713,9 @@ function formatSessionActivityLogFields(activity: DiagnosticSessionActivitySnaps if (activity.activeToolAgeMs !== undefined) { fields.push(`activeToolAge=${Math.round(activity.activeToolAgeMs / 1000)}s`); } + if (isTerminalDiagnosticProgressReason(activity.lastProgressReason)) { + fields.push("terminalProgressStale=true"); + } return fields.join(" "); } @@ -835,7 +926,7 @@ export function startDiagnosticHeartbeat( const stuckSessionWarnMs = resolveStuckSessionWarnMs(heartbeatConfig); const now = Date.now(); pruneDiagnosticSessionStates(now, true); - const work = getDiagnosticWorkSnapshot(); + const work = getDiagnosticWorkSnapshot(now); const livenessSample = (opts?.sampleLiveness ?? sampleDiagnosticLiveness)(now, work); const shouldEmitLivenessEvent = livenessSample !== null && shouldEmitDiagnosticLivenessEvent(now); @@ -943,6 +1034,7 @@ export function resetDiagnosticStateForTest(): void { webhookStats.lastReceived = 0; stopDiagnosticHeartbeat(); resetDiagnosticMemoryForTest(); + resetDiagnosticPhasesForTest(); resetDiagnosticStabilityRecorderForTest(); resetDiagnosticStabilityBundleForTest(); } From 35e48a049b2e8c45ceb40b8ab93bed55571b9b73 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 22:44:19 +0100 Subject: [PATCH 079/107] fix: enable sync io tracing in gateway watch --- docs/help/debugging.md | 4 ++++ scripts/watch-node.mjs | 3 +++ src/infra/watch-node.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/docs/help/debugging.md b/docs/help/debugging.md index 70220c6d76c..073cfed9ce2 100644 --- a/docs/help/debugging.md +++ b/docs/help/debugging.md @@ -96,6 +96,10 @@ add Node's sync I/O trace flag through the source runner: OPENCLAW_TRACE_SYNC_IO=1 pnpm openclaw gateway --force ``` +`pnpm gateway:watch` enables this flag by default for the watched Gateway child. +Set `OPENCLAW_TRACE_SYNC_IO=0` to suppress Node sync I/O trace output in watch +mode. + ## Gateway watch mode For fast iteration, run the gateway under the file watcher: diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index 6cb65d6dbc9..da64b7cff0a 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -277,6 +277,9 @@ export async function runWatchMain(params = {}) { // The watcher owns process restarts; keep SIGUSR1/config reloads in-process // so inherited launchd/systemd markers do not make the child exit and stall. childEnv.OPENCLAW_NO_RESPAWN = "1"; + if (isGatewayWatchCommand(deps.args) && childEnv.OPENCLAW_TRACE_SYNC_IO === undefined) { + childEnv.OPENCLAW_TRACE_SYNC_IO = "1"; + } if (deps.args.length > 0) { childEnv.OPENCLAW_WATCH_COMMAND = deps.args.join(" "); } diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts index 3e6f787e3c9..84fff891353 100644 --- a/src/infra/watch-node.test.ts +++ b/src/infra/watch-node.test.ts @@ -143,6 +143,7 @@ describe("watch-node script", () => { OPENCLAW_WATCH_MODE: "1", OPENCLAW_WATCH_SESSION: "1700000000000-4242", OPENCLAW_NO_RESPAWN: "1", + OPENCLAW_TRACE_SYNC_IO: "1", OPENCLAW_WATCH_COMMAND: "gateway --force", }), }), @@ -155,6 +156,35 @@ describe("watch-node script", () => { }); }); + it("preserves explicit sync I/O trace overrides for gateway watch", async () => { + const { child, spawn, createWatcher, fakeProcess } = createWatchHarness(); + await withTempDir({ prefix: "openclaw-watch-node-" }, async (cwd) => { + const runPromise = runWatch({ + args: ["gateway", "--force"], + cwd, + createWatcher, + env: { OPENCLAW_TRACE_SYNC_IO: "0" }, + lockDisabled: true, + process: fakeProcess, + spawn, + }); + + expect(spawn).toHaveBeenCalledWith( + "/usr/local/bin/node", + ["scripts/run-node.mjs", "gateway", "--force"], + expect.objectContaining({ + env: expect.objectContaining({ + OPENCLAW_TRACE_SYNC_IO: "0", + }), + }), + ); + + fakeProcess.emit("SIGINT"); + await runPromise; + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + }); + }); + it("starts the runner before loading chokidar", async () => { const child = Object.assign(new EventEmitter(), { kill: vi.fn(() => {}), From d6917edc5329343b0707f322b59bacc2e8407f63 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 22:53:50 +0100 Subject: [PATCH 080/107] fix: preserve gateway watch trace overrides --- scripts/gateway-watch-tmux.mjs | 1 + src/cli/gateway-cli/run.ts | 2 +- src/infra/gateway-watch-tmux.test.ts | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/gateway-watch-tmux.mjs b/scripts/gateway-watch-tmux.mjs index 76a44a1ad53..b7f4b95dbe3 100644 --- a/scripts/gateway-watch-tmux.mjs +++ b/scripts/gateway-watch-tmux.mjs @@ -21,6 +21,7 @@ const TMUX_CHILD_ENV_KEYS = [ RUN_NODE_CPU_PROF_DIR_ENV, "OPENCLAW_SKIP_CHANNELS", "OPENCLAW_STATE_DIR", + "OPENCLAW_TRACE_SYNC_IO", ]; const sanitizeSessionPart = (value) => { diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 5b23d01708d..3088ff8b520 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -159,7 +159,7 @@ function createGatewayCliStartupTrace() { async measure(name: string, run: () => Awaitable): Promise { const before = performance.now(); try { - return await withDiagnosticPhase(`cli.${name}`, run); + return await withDiagnosticPhase(name, run); } finally { const now = performance.now(); emit(name, now - before, now - started); diff --git a/src/infra/gateway-watch-tmux.test.ts b/src/infra/gateway-watch-tmux.test.ts index f96304e32de..1e271eae8c1 100644 --- a/src/infra/gateway-watch-tmux.test.ts +++ b/src/infra/gateway-watch-tmux.test.ts @@ -43,6 +43,7 @@ describe("gateway-watch tmux wrapper", () => { env: { OPENCLAW_GATEWAY_PORT: "19001", OPENCLAW_PROFILE: "Dev Profile", + OPENCLAW_TRACE_SYNC_IO: "0", SHELL: "/bin/zsh", }, nodePath: "/opt/node", @@ -57,6 +58,7 @@ describe("gateway-watch tmux wrapper", () => { expect(command).toContain("'FORCE_COLOR=1'"); expect(command).toContain("'OPENCLAW_GATEWAY_PORT=19001'"); expect(command).toContain("'OPENCLAW_PROFILE=Dev Profile'"); + expect(command).toContain("'OPENCLAW_TRACE_SYNC_IO=0'"); expect(command).toContain("/opt/node"); expect(command).toContain("scripts/watch-node.mjs"); expect(command).toContain("gateway"); From d82992f0ae24b1d123a98387ae0de91a6128351d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:01:51 +0100 Subject: [PATCH 081/107] docs: add gateway diagnostics changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6131b15b48f..91343558ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Docs: clarify that IRC uses raw TCP/TLS sockets outside operator-managed forward proxy routing, so direct IRC egress should be explicitly approved before enabling IRC. Thanks @jesse-merhi. - Gateway/performance: defer non-readiness sidecars until after the ready signal, avoid hot-path channel plugin barrel imports, and fast-path trusted bundled plugin metadata during Gateway startup. - Gateway/performance: avoid importing `jiti` on native-loadable plugin startup paths, so compiled bundled plugin surfaces do not pay source-transform loader cost unless fallback loading is actually needed. +- Gateway/diagnostics: add startup phase spans, active work labels, stale terminal bridge markers, and default sync-I/O tracing in `pnpm gateway:watch` so slow Gateway turns are easier to attribute from logs and stability diagnostics. - Plugins/loader: preserve real compiled plugin module evaluation errors on the native fast path instead of treating every thrown `.js` module as a source-transform fallback miss. Thanks @vincentkoc. - QA/Mantis: add `pnpm openclaw qa mantis slack-desktop-smoke` to run Slack live QA inside a Crabbox VNC desktop, open Slack Web, and capture desktop screenshots beside the Slack QA artifacts. - QA/Mantis: pass the runtime env through desktop-browser Crabbox and artifact-copy child commands, so embedded Mantis callers can provide Crabbox credentials without mutating the parent process. Thanks @vincentkoc. From 9f2c8a6ab62582a5af931ff80e590419f143080e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:04:34 +0100 Subject: [PATCH 082/107] fix: clarify slack socket retry errors --- CHANGELOG.md | 1 + .../src/monitor/provider.reconnect.test.ts | 29 +++++++++++++++++-- extensions/slack/src/monitor/provider.ts | 17 ++++++++++- .../slack/src/monitor/reconnect-policy.ts | 9 ++++-- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91343558ceb..2f69caec1f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ Docs: https://docs.openclaw.ai - active-memory: skip the memory sub-agent gracefully instead of logging a confusing allowlist error when no memory plugin (`memory-core` or `memory-lancedb`) is loaded, so active-memory with no memory backend no longer produces misleading "No callable tools remain" warnings in the gateway log. Fixes #77506. Thanks @hclsys. - Memory/wiki: preserve representation from both corpora in `corpus=all` searches while backfilling unused result capacity, so memory hits are not starved by numerically higher wiki integer scores. Fixes #77337. Thanks @hclsys. - Telegram: clean up tool-only draft previews after assistant message boundaries so transient `Surfacing...` tool-status bubbles do not linger when no matching final preview arrives. Thanks @BunsDev. +- Slack: report `unknown error` instead of `undefined` in socket-mode startup retry logs and label the retry reason explicitly. - Telegram: let explicit forum-topic `requireMention` settings override persisted `/activate` and `/deactivate` state, so per-topic mention gates work consistently. Fixes #49864. Thanks @Panniantong. - Cron: surface failed isolated-run diagnostics in `cron show`, status, and run history when requested tools are unavailable, so blocked cron runs report the actual tool-policy failure instead of a misleading green result. Fixes #75763. Thanks @RyanSandoval. - TUI/escape abort: track the in-flight runId after `chat.send` resolves so pressing Esc during the gap before the first gateway event aborts the run instead of repeatedly printing `no active run`. Fixes #1296. Thanks @Lukavyi and @romneyda. diff --git a/extensions/slack/src/monitor/provider.reconnect.test.ts b/extensions/slack/src/monitor/provider.reconnect.test.ts index 3cdaaefc65c..2ca36bb952e 100644 --- a/extensions/slack/src/monitor/provider.reconnect.test.ts +++ b/extensions/slack/src/monitor/provider.reconnect.test.ts @@ -5,8 +5,11 @@ import { publishSlackDisconnectedStatus, startSlackSocketAndWaitForDisconnect, } from "./provider-support.js"; -import { formatSlackSocketReconnectMessage } from "./provider.js"; -import { waitForSlackSocketDisconnect } from "./reconnect-policy.js"; +import { + formatSlackSocketReconnectMessage, + formatSlackSocketStartRetryMessage, +} from "./provider.js"; +import { formatUnknownError, waitForSlackSocketDisconnect } from "./reconnect-policy.js"; class FakeEmitter { private listeners = new Map void>>(); @@ -97,6 +100,28 @@ describe("slack socket reconnect helpers", () => { ).toBe("slack socket disconnected (disconnect); reconnecting in 2s (attempt 1/12)"); }); + it("formats missing and unserializable socket errors without leaking undefined", () => { + const circular: Record = {}; + circular.self = circular; + + expect(formatUnknownError(undefined)).toBe("unknown error"); + expect(formatUnknownError(null)).toBe("unknown error"); + expect(formatUnknownError("")).toBe("unknown error"); + expect(formatUnknownError(new Error(""))).toBe("Error"); + expect(formatUnknownError(circular)).toBe("unknown error"); + }); + + it("formats socket start retries with an explicit reason field", () => { + expect( + formatSlackSocketStartRetryMessage({ + attempt: 1, + maxAttempts: 12, + delayMs: 2_340, + error: undefined, + }), + ).toBe('slack socket mode failed to start; retry 1/12 in 2s reason="unknown error"'); + }); + it("resolves disconnect waiter on socket disconnect event", async () => { const client = new FakeEmitter(); const app = { receiver: { client } }; diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index 46fb13459a5..986368d367f 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -97,6 +97,16 @@ export function formatSlackSocketReconnectMessage(params: { return `slack socket disconnected (${params.event}); reconnecting in ${Math.round(params.delayMs / 1000)}s (attempt ${params.attempt}/${maxAttempts})${suffix}`; } +export function formatSlackSocketStartRetryMessage(params: { + attempt: number; + maxAttempts: number; + delayMs: number; + error: unknown; +}) { + const maxAttempts = params.maxAttempts > 0 ? String(params.maxAttempts) : "∞"; + return `slack socket mode failed to start; retry ${params.attempt}/${maxAttempts} in ${Math.round(params.delayMs / 1000)}s reason="${formatUnknownError(params.error)}"`; +} + function parseApiAppIdFromAppToken(raw?: string) { const token = raw?.trim(); if (!token) { @@ -534,7 +544,12 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { } const delayMs = computeBackoff(SLACK_SOCKET_RECONNECT_POLICY, reconnectAttempts); runtime.error?.( - `slack socket mode failed to start. retry ${reconnectAttempts}/${SLACK_SOCKET_RECONNECT_POLICY.maxAttempts || "∞"} in ${Math.round(delayMs / 1000)}s (${formatUnknownError(err)})`, + formatSlackSocketStartRetryMessage({ + attempt: reconnectAttempts, + maxAttempts: SLACK_SOCKET_RECONNECT_POLICY.maxAttempts, + delayMs, + error: err, + }), ); try { await sleepWithAbort(delayMs, opts.abortSignal); diff --git a/extensions/slack/src/monitor/reconnect-policy.ts b/extensions/slack/src/monitor/reconnect-policy.ts index 2c1d7bde9d9..319fd561b47 100644 --- a/extensions/slack/src/monitor/reconnect-policy.ts +++ b/extensions/slack/src/monitor/reconnect-policy.ts @@ -94,14 +94,17 @@ export function isNonRecoverableSlackAuthError(error: unknown): boolean { } export function formatUnknownError(error: unknown): string { + if (error === undefined || error === null) { + return "unknown error"; + } if (error instanceof Error) { - return error.message; + return error.message || error.name || "unknown error"; } if (typeof error === "string") { - return error; + return error || "unknown error"; } try { - return JSON.stringify(error); + return JSON.stringify(error) ?? "unknown error"; } catch { return "unknown error"; } From 592998ae0eff57af81cf833eca8123e74f0ae9fd Mon Sep 17 00:00:00 2001 From: Kevin Lin Date: Mon, 4 May 2026 15:28:49 -0700 Subject: [PATCH 083/107] fix: clean up orphaned child processes (#77481) * fix: forward launcher respawn signals * docs: explain respawn signal exit timer * fix: centralize launcher respawn supervision * fix: include respawn helper in duplicate scan * fix: keep launcher respawn bridge local --- CHANGELOG.md | 1 + openclaw.mjs | 152 +++++++++++++++++++++++------ src/entry.compile-cache.test.ts | 110 ++++++++++++++++++++- src/entry.compile-cache.ts | 101 +++++++++++++++++-- src/entry.ts | 140 +++++++++++++------------- test/openclaw-launcher.e2e.test.ts | 134 ++++++++++++++++++++++++- 6 files changed, 527 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f69caec1f0..9ce5351e1bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai - Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc. - Plugins/update: treat OpenClaw CalVer correction versions like `2026.5.3-1` as satisfying base plugin API ranges, so correction builds can install plugins that require the base runtime API. Fixes #77293. (#77450) Thanks @p3nchan. - Discord/Gateway startup: retry Discord READY waits with backoff, defer startup `sessions.list` and native approval readiness failures until sidecars recover, and preserve component-only Discord payloads when final reply scrubbing removes all text. (#77478) Thanks @NikolaFC. +- CLI/launcher: forward termination signals to compile-cache respawn children, so killing a wrapper process no longer leaves the security audit worker orphaned. Fixes #77458. Thanks @jaikharbanda. - fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987. - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. - Active Memory: send a bounded latest-message search query to the recall worker so channel/runtime metadata does not become the memory search string. Fixes #65309. Thanks @joeykrug, @westley3601, @pimenov, and @tasi333. diff --git a/openclaw.mjs b/openclaw.mjs index aae262e5b57..7a35f199b2a 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { existsSync, readFileSync, statSync } from "node:fs"; import { access } from "node:fs/promises"; import module from "node:module"; @@ -84,6 +84,102 @@ const resolvePackagedCompileCacheDirectory = () => { ); }; +const respawnSignals = + process.platform === "win32" + ? ["SIGTERM", "SIGINT", "SIGBREAK"] + : ["SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT"]; +const respawnSignalExitGraceMs = 1_000; +const respawnSignalForceKillGraceMs = 1_000; + +const runRespawnedChild = (command, args, env) => { + const child = spawn(command, args, { + stdio: "inherit", + env, + }); + const listeners = new Map(); + // This intentionally overlaps with src/entry.compile-cache.ts; keep the + // respawn supervision behavior in sync until the launcher can share TS code. + // Give the child a moment to honor forwarded signals, then exit the wrapper so + // a child that ignores SIGTERM cannot keep the launcher alive indefinitely. + let signalExitTimer = null; + let signalForceKillTimer = null; + const detach = () => { + for (const [signal, listener] of listeners) { + process.off(signal, listener); + } + listeners.clear(); + if (signalExitTimer) { + clearTimeout(signalExitTimer); + signalExitTimer = null; + } + if (signalForceKillTimer) { + clearTimeout(signalForceKillTimer); + signalForceKillTimer = null; + } + }; + const forceKillChild = () => { + try { + child.kill(process.platform === "win32" ? "SIGTERM" : "SIGKILL"); + } catch { + // Best-effort shutdown fallback. + } + }; + const requestChildTermination = () => { + try { + child.kill("SIGTERM"); + } catch { + // Best-effort shutdown fallback. + } + signalForceKillTimer = setTimeout(() => { + forceKillChild(); + process.exit(1); + }, respawnSignalForceKillGraceMs); + signalForceKillTimer.unref?.(); + }; + const scheduleParentExit = () => { + if (signalExitTimer) { + return; + } + signalExitTimer = setTimeout(() => { + requestChildTermination(); + }, respawnSignalExitGraceMs); + signalExitTimer.unref?.(); + }; + for (const signal of respawnSignals) { + const listener = () => { + try { + child.kill(signal); + } catch { + // Best-effort signal forwarding. + } + scheduleParentExit(); + }; + try { + process.on(signal, listener); + listeners.set(signal, listener); + } catch { + // Unsupported signal on this platform. + } + } + child.once("exit", (code, signal) => { + detach(); + if (signal) { + process.exit(1); + } + process.exit(code ?? 1); + }); + child.once("error", (error) => { + detach(); + process.stderr.write( + `[openclaw] Failed to respawn launcher: ${ + error instanceof Error ? (error.stack ?? error.message) : String(error) + }\n`, + ); + process.exit(1); + }); + return true; +}; + const respawnWithoutCompileCacheIfNeeded = () => { if (!isSourceCheckoutLauncher()) { return false; @@ -100,22 +196,13 @@ const respawnWithoutCompileCacheIfNeeded = () => { OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED: "1", }; delete env.NODE_COMPILE_CACHE; - const result = spawnSync( + return runRespawnedChild( process.execPath, [...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)], - { - stdio: "inherit", - env, - }, + env, ); - if (result.error) { - throw result.error; - } - process.exit(result.status ?? 1); }; -respawnWithoutCompileCacheIfNeeded(); - const respawnWithPackagedCompileCacheIfNeeded = () => { if (isSourceCheckoutLauncher() || isNodeCompileCacheDisabled()) { return false; @@ -136,24 +223,23 @@ const respawnWithPackagedCompileCacheIfNeeded = () => { NODE_COMPILE_CACHE: desiredDirectory, OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED: "1", }; - const result = spawnSync( + return runRespawnedChild( process.execPath, [...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)], - { - stdio: "inherit", - env, - }, + env, ); - if (result.error) { - throw result.error; - } - process.exit(result.status ?? 1); }; -respawnWithPackagedCompileCacheIfNeeded(); +const waitingForCompileCacheRespawn = + respawnWithoutCompileCacheIfNeeded() || respawnWithPackagedCompileCacheIfNeeded(); // https://nodejs.org/api/module.html#module-compile-cache -if (module.enableCompileCache && !isNodeCompileCacheDisabled() && !isSourceCheckoutLauncher()) { +if ( + !waitingForCompileCacheRespawn && + module.enableCompileCache && + !isNodeCompileCacheDisabled() && + !isSourceCheckoutLauncher() +) { try { module.enableCompileCache(resolvePackagedCompileCacheDirectory()); } catch { @@ -297,17 +383,19 @@ const tryOutputBrowserHelp = () => { return true; }; -if (!isHelpFastPathDisabled() && (await tryOutputBareRootHelp())) { - // OK -} else if (!isHelpFastPathDisabled() && tryOutputBrowserHelp()) { - // OK -} else { - await installProcessWarningFilter(); - if (await tryImport("./dist/entry.js")) { +if (!waitingForCompileCacheRespawn) { + if (!isHelpFastPathDisabled() && (await tryOutputBareRootHelp())) { // OK - } else if (await tryImport("./dist/entry.mjs")) { + } else if (!isHelpFastPathDisabled() && tryOutputBrowserHelp()) { // OK } else { - throw new Error(await buildMissingEntryErrorMessage()); + await installProcessWarningFilter(); + if (await tryImport("./dist/entry.js")) { + // OK + } else if (await tryImport("./dist/entry.mjs")) { + // OK + } else { + throw new Error(await buildMissingEntryErrorMessage()); + } } } diff --git a/src/entry.compile-cache.test.ts b/src/entry.compile-cache.test.ts index c43f1d9f3e7..576aeafc4ab 100644 --- a/src/entry.compile-cache.test.ts +++ b/src/entry.compile-cache.test.ts @@ -1,12 +1,15 @@ +import type { ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanupTempDirs, makeTempDir } from "../test/helpers/temp-dir.js"; import { buildOpenClawCompileCacheRespawnPlan, isSourceCheckoutInstallRoot, resolveOpenClawCompileCacheDirectory, resolveEntryInstallRoot, + runOpenClawCompileCacheRespawnPlan, shouldEnableOpenClawCompileCache, } from "./entry.compile-cache.js"; @@ -122,4 +125,109 @@ describe("entry compile cache", () => { }), ).toBeUndefined(); }); + + it("runs compile-cache respawn plans with the child-process bridge", () => { + const child = new EventEmitter() as ChildProcess; + const spawn = vi.fn(() => child); + const attachChildProcessBridge = vi.fn(); + const exit = vi.fn(); + const writeError = vi.fn(); + + runOpenClawCompileCacheRespawnPlan( + { + command: "/usr/bin/node", + args: ["/repo/openclaw/dist/entry.js", "status"], + env: { NODE_DISABLE_COMPILE_CACHE: "1" }, + }, + { + spawn: spawn as unknown as typeof import("node:child_process").spawn, + attachChildProcessBridge, + exit: exit as unknown as (code?: number) => never, + writeError, + }, + ); + + expect(spawn).toHaveBeenCalledWith( + "/usr/bin/node", + ["/repo/openclaw/dist/entry.js", "status"], + { + stdio: "inherit", + env: { NODE_DISABLE_COMPILE_CACHE: "1" }, + }, + ); + expect(attachChildProcessBridge).toHaveBeenCalledWith(child, { + onSignal: expect.any(Function), + }); + + child.emit("exit", 0, null); + + expect(exit).toHaveBeenCalledWith(0); + expect(writeError).not.toHaveBeenCalled(); + }); + + it("marks signal-terminated compile-cache respawn children as failed without forcing another exit", () => { + const child = new EventEmitter() as ChildProcess; + const spawn = vi.fn(() => child); + const exit = vi.fn(); + + runOpenClawCompileCacheRespawnPlan( + { + command: "/usr/bin/node", + args: ["/repo/openclaw/dist/entry.js"], + env: {}, + }, + { + spawn: spawn as unknown as typeof import("node:child_process").spawn, + attachChildProcessBridge: vi.fn(), + exit: exit as unknown as (code?: number) => never, + writeError: vi.fn(), + }, + ); + + child.emit("exit", null, "SIGTERM"); + + expect(exit).toHaveBeenCalledWith(1); + }); + + it("terminates before force-killing a signaled compile-cache respawn child", () => { + vi.useFakeTimers(); + const child = new EventEmitter() as ChildProcess; + const kill = vi.fn<(signal?: NodeJS.Signals) => boolean>(() => true); + child.kill = kill as ChildProcess["kill"]; + const spawn = vi.fn(() => child); + const exit = vi.fn(); + let onSignal: ((signal: NodeJS.Signals) => void) | undefined; + + try { + runOpenClawCompileCacheRespawnPlan( + { + command: "/usr/bin/node", + args: ["/repo/openclaw/dist/entry.js"], + env: {}, + }, + { + spawn: spawn as unknown as typeof import("node:child_process").spawn, + attachChildProcessBridge: vi.fn((_child, options) => { + onSignal = options?.onSignal; + return { detach: vi.fn() }; + }), + exit: exit as unknown as (code?: number) => never, + writeError: vi.fn(), + }, + ); + + onSignal?.("SIGTERM"); + vi.advanceTimersByTime(1_000); + + expect(kill).toHaveBeenCalledWith("SIGTERM"); + expect(exit).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1_000); + + expect(kill).toHaveBeenCalledWith(process.platform === "win32" ? "SIGTERM" : "SIGKILL"); + expect(exit).toHaveBeenCalledWith(1); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/entry.compile-cache.ts b/src/entry.compile-cache.ts index f3b2c8905a1..72595d1bff4 100644 --- a/src/entry.compile-cache.ts +++ b/src/entry.compile-cache.ts @@ -1,8 +1,12 @@ -import { spawnSync } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { existsSync, readFileSync, statSync } from "node:fs"; import { enableCompileCache, getCompileCacheDir } from "node:module"; import os from "node:os"; import path from "node:path"; +import { attachChildProcessBridge } from "./process/child-process-bridge.js"; + +const COMPILE_CACHE_RESPAWN_SIGNAL_EXIT_GRACE_MS = 1_000; +const COMPILE_CACHE_RESPAWN_SIGNAL_FORCE_KILL_GRACE_MS = 1_000; export function resolveEntryInstallRoot(entryFile: string): string { const entryDir = path.dirname(entryFile); @@ -84,12 +88,19 @@ export function resolveOpenClawCompileCacheDirectory(params: { ); } -type OpenClawCompileCacheRespawnPlan = { +export type OpenClawCompileCacheRespawnPlan = { command: string; args: string[]; env: NodeJS.ProcessEnv; }; +type OpenClawCompileCacheRespawnRuntime = { + spawn: typeof spawn; + attachChildProcessBridge: typeof attachChildProcessBridge; + exit: (code?: number) => never; + writeError: (message: string) => void; +}; + export function buildOpenClawCompileCacheRespawnPlan(params: { currentFile: string; env?: NodeJS.ProcessEnv; @@ -138,15 +149,89 @@ export function respawnWithoutOpenClawCompileCacheIfNeeded(params: { if (!plan) { return false; } - const result = spawnSync(plan.command, plan.args, { + runOpenClawCompileCacheRespawnPlan(plan); + return true; +} + +export function runOpenClawCompileCacheRespawnPlan( + plan: OpenClawCompileCacheRespawnPlan, + runtime: OpenClawCompileCacheRespawnRuntime = { + spawn, + attachChildProcessBridge, + exit: process.exit.bind(process) as (code?: number) => never, + writeError: (message: string) => process.stderr.write(message), + }, +): ChildProcess { + const child = runtime.spawn(plan.command, plan.args, { stdio: "inherit", env: plan.env, }); - if (result.error) { - throw result.error; - } - process.exit(result.status ?? 1); - return true; + // Give the child a moment to honor forwarded signals, then exit the parent so + // a child that ignores SIGTERM cannot keep the compile-cache wrapper alive indefinitely. + let signalExitTimer: NodeJS.Timeout | undefined; + let signalForceKillTimer: NodeJS.Timeout | undefined; + const clearSignalExitTimer = (): void => { + if (signalExitTimer) { + clearTimeout(signalExitTimer); + signalExitTimer = undefined; + } + if (signalForceKillTimer) { + clearTimeout(signalForceKillTimer); + signalForceKillTimer = undefined; + } + }; + const forceKillChild = (): void => { + try { + child.kill(process.platform === "win32" ? "SIGTERM" : "SIGKILL"); + } catch { + // Best-effort shutdown fallback. + } + }; + const requestChildTermination = (): void => { + try { + child.kill("SIGTERM"); + } catch { + // Best-effort shutdown fallback. + } + signalForceKillTimer = setTimeout(() => { + forceKillChild(); + runtime.exit(1); + }, COMPILE_CACHE_RESPAWN_SIGNAL_FORCE_KILL_GRACE_MS); + signalForceKillTimer.unref?.(); + }; + const scheduleParentExit = (): void => { + if (signalExitTimer) { + return; + } + signalExitTimer = setTimeout(() => { + requestChildTermination(); + }, COMPILE_CACHE_RESPAWN_SIGNAL_EXIT_GRACE_MS); + signalExitTimer.unref?.(); + }; + + runtime.attachChildProcessBridge(child, { + onSignal: scheduleParentExit, + }); + + child.once("exit", (code, signal) => { + clearSignalExitTimer(); + if (signal) { + runtime.exit(1); + } + runtime.exit(code ?? 1); + }); + + child.once("error", (error) => { + clearSignalExitTimer(); + runtime.writeError( + `[openclaw] Failed to respawn CLI without compile cache: ${ + error instanceof Error ? (error.stack ?? error.message) : String(error) + }\n`, + ); + runtime.exit(1); + }); + + return child; } export function enableOpenClawCompileCache(params: { diff --git a/src/entry.ts b/src/entry.ts index 4e15f468a27..22632b5612d 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -84,92 +84,94 @@ if ( } else { const entryFile = fileURLToPath(import.meta.url); const installRoot = resolveEntryInstallRoot(entryFile); - respawnWithoutOpenClawCompileCacheIfNeeded({ + const waitingForCompileCacheRespawn = respawnWithoutOpenClawCompileCacheIfNeeded({ currentFile: entryFile, installRoot, }); - process.title = "openclaw"; - ensureOpenClawExecMarkerOnProcess(); - installProcessWarningFilter(); - normalizeEnv(); - enableOpenClawCompileCache({ - installRoot, - }); - gatewayEntryStartupTrace.mark("bootstrap"); + if (!waitingForCompileCacheRespawn) { + process.title = "openclaw"; + ensureOpenClawExecMarkerOnProcess(); + installProcessWarningFilter(); + normalizeEnv(); + enableOpenClawCompileCache({ + installRoot, + }); + gatewayEntryStartupTrace.mark("bootstrap"); - if (shouldForceReadOnlyAuthStore(process.argv)) { - process.env.OPENCLAW_AUTH_STORE_READONLY = "1"; - } - - if (process.argv.includes("--no-color")) { - process.env.NO_COLOR = "1"; - process.env.FORCE_COLOR = "0"; - } - - function ensureCliRespawnReady(): boolean { - const plan = buildCliRespawnPlan(); - if (!plan) { - return false; + if (shouldForceReadOnlyAuthStore(process.argv)) { + process.env.OPENCLAW_AUTH_STORE_READONLY = "1"; } - const child = spawn(plan.command, plan.argv, { - stdio: "inherit", - env: plan.env, - }); + if (process.argv.includes("--no-color")) { + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + } - attachChildProcessBridge(child); - - child.once("exit", (code, signal) => { - if (signal) { - process.exitCode = 1; - return; + function ensureCliRespawnReady(): boolean { + const plan = buildCliRespawnPlan(); + if (!plan) { + return false; } - process.exit(code ?? 1); - }); - child.once("error", (error) => { - console.error( - "[openclaw] Failed to respawn CLI:", - error instanceof Error ? (error.stack ?? error.message) : error, - ); - process.exit(1); - }); + const child = spawn(plan.command, plan.argv, { + stdio: "inherit", + env: plan.env, + }); - // Parent must not continue running the CLI. - return true; - } + attachChildProcessBridge(child); - process.argv = normalizeWindowsArgv(process.argv); + child.once("exit", (code, signal) => { + if (signal) { + process.exitCode = 1; + return; + } + process.exit(code ?? 1); + }); - if (!ensureCliRespawnReady()) { - const parsedContainer = parseCliContainerArgs(process.argv); - if (!parsedContainer.ok) { - console.error(`[openclaw] ${parsedContainer.error}`); - process.exit(2); + child.once("error", (error) => { + console.error( + "[openclaw] Failed to respawn CLI:", + error instanceof Error ? (error.stack ?? error.message) : error, + ); + process.exit(1); + }); + + // Parent must not continue running the CLI. + return true; } - const parsed = parseCliProfileArgs(parsedContainer.argv); - if (!parsed.ok) { - // Keep it simple; Commander will handle rich help/errors after we strip flags. - console.error(`[openclaw] ${parsed.error}`); - process.exit(2); - } + process.argv = normalizeWindowsArgv(process.argv); - const containerTargetName = resolveCliContainerTarget(process.argv); - if (containerTargetName && parsed.profile) { - console.error("[openclaw] --container cannot be combined with --profile/--dev"); - process.exit(2); - } + if (!ensureCliRespawnReady()) { + const parsedContainer = parseCliContainerArgs(process.argv); + if (!parsedContainer.ok) { + console.error(`[openclaw] ${parsedContainer.error}`); + process.exit(2); + } - if (parsed.profile) { - applyCliProfileEnv({ profile: parsed.profile }); - // Keep Commander and ad-hoc argv checks consistent. - process.argv = parsed.argv; - } - gatewayEntryStartupTrace.mark("argv"); + const parsed = parseCliProfileArgs(parsedContainer.argv); + if (!parsed.ok) { + // Keep it simple; Commander will handle rich help/errors after we strip flags. + console.error(`[openclaw] ${parsed.error}`); + process.exit(2); + } - if (!tryHandleRootVersionFastPath(process.argv)) { - await runMainOrRootHelp(process.argv); + const containerTargetName = resolveCliContainerTarget(process.argv); + if (containerTargetName && parsed.profile) { + console.error("[openclaw] --container cannot be combined with --profile/--dev"); + process.exit(2); + } + + if (parsed.profile) { + applyCliProfileEnv({ profile: parsed.profile }); + // Keep Commander and ad-hoc argv checks consistent. + process.argv = parsed.argv; + } + gatewayEntryStartupTrace.mark("argv"); + + if (!tryHandleRootVersionFastPath(process.argv)) { + await runMainOrRootHelp(process.argv); + } } } } diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index 59ab00993f9..9a5f69cae6b 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -36,6 +36,41 @@ async function addCompileCacheProbe(fixtureRoot: string): Promise { ); } +async function waitForFile(filePath: string, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + return await fs.readFile(filePath, "utf8"); + } catch { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + throw new Error(`timed out waiting for ${filePath}`); +} + +async function waitUntil(check: () => boolean, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return check(); +} + +function isProcessAlive(pid: number | undefined): boolean { + if (!pid) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + function launcherEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { const env = { ...process.env, ...extra }; delete env.NODE_COMPILE_CACHE; @@ -138,6 +173,103 @@ describe("openclaw launcher", () => { expect(result.stdout).toBe("cache:disabled;respawn:1"); }); + it.runIf(process.platform !== "win32")( + "forwards SIGTERM to source-checkout compile-cache respawn children", + async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await addGitMarker(fixtureRoot); + const childInfoPath = path.join(fixtureRoot, "child-info.json"); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + [ + 'import { writeFileSync } from "node:fs";', + `writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`, + 'process.title = "openclaw-launcher-sigterm-test-child";', + "setInterval(() => {}, 1000);", + "", + ].join("\n"), + "utf8", + ); + + const launcher = spawn(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { + cwd: fixtureRoot, + env: launcherEnv({ + NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"), + }), + stdio: "ignore", + }); + let respawnChildPid: number | undefined; + + try { + const childInfo = JSON.parse(await waitForFile(childInfoPath, 5000)) as { pid: number }; + respawnChildPid = childInfo.pid; + + launcher.kill("SIGTERM"); + + await waitUntil(() => !isProcessAlive(respawnChildPid), 5000); + expect(isProcessAlive(respawnChildPid)).toBe(false); + } finally { + if (isProcessAlive(respawnChildPid)) { + process.kill(respawnChildPid!, "SIGKILL"); + } + if (isProcessAlive(launcher.pid)) { + process.kill(launcher.pid!, "SIGKILL"); + } + } + }, + ); + + it.runIf(process.platform !== "win32")( + "exits after SIGTERM when the respawn child ignores the forwarded signal", + async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await addGitMarker(fixtureRoot); + const childInfoPath = path.join(fixtureRoot, "child-info.json"); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + [ + 'import { writeFileSync } from "node:fs";', + `writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`, + 'process.title = "openclaw-launcher-sigterm-ignore-test-child";', + 'process.on("SIGTERM", () => {});', + "setInterval(() => {}, 1000);", + "", + ].join("\n"), + "utf8", + ); + + const launcher = spawn(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { + cwd: fixtureRoot, + env: launcherEnv({ + NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"), + }), + stdio: "ignore", + }); + let respawnChildPid: number | undefined; + + try { + const childInfo = JSON.parse(await waitForFile(childInfoPath, 5000)) as { pid: number }; + respawnChildPid = childInfo.pid; + + launcher.kill("SIGTERM"); + + await waitUntil( + () => !isProcessAlive(launcher.pid) && !isProcessAlive(respawnChildPid), + 5000, + ); + expect(isProcessAlive(launcher.pid)).toBe(false); + expect(isProcessAlive(respawnChildPid)).toBe(false); + } finally { + if (isProcessAlive(respawnChildPid)) { + process.kill(respawnChildPid!, "SIGKILL"); + } + if (isProcessAlive(launcher.pid)) { + process.kill(launcher.pid!, "SIGKILL"); + } + } + }, + ); + it.runIf(process.platform !== "win32")( "respawns symlinked source-checkout launchers without inherited NODE_COMPILE_CACHE", async () => { From f042b53782d94505002f33a529e0bfe56233487f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 15:29:47 -0700 Subject: [PATCH 084/107] fix(channels): preserve channel aliases in plugin probes Key package-state probes, env/config presence, and read-only command defaults by channel id instead of manifest plugin id so alias-owned channel plugins keep setup/native-command detection working. --- CHANGELOG.md | 1 + src/channels/config-presence.ts | 6 +- src/channels/plugins/bundled-ids.ts | 14 ++++ .../plugins/bundled-root-caches.test.ts | 6 +- .../plugins/package-state-probes.test.ts | 64 +++++++++++++++++++ src/channels/plugins/package-state-probes.ts | 11 +++- .../read-only-command-defaults.test.ts | 39 +++++++++++ .../plugins/read-only-command-defaults.ts | 14 ++-- 8 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 src/channels/plugins/package-state-probes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ce5351e1bd..7e0f2912c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc. +- Channels/plugins: key bundled package-state probes, env/config presence, and read-only command defaults by channel id instead of manifest plugin id, preserving setup and native-command detection for channel plugins whose package id differs from the channel alias. Thanks @vincentkoc. - Docker: prune package-excluded plugin dist directories from runtime images unless the build explicitly opts that plugin in, so official external plugins such as Feishu stay install-on-demand instead of shipping partial metadata without compiled runtime output. Fixes #77424. Thanks @vincentkoc. - Model switching: include the exact additive allowlist repair command when `/model ... --runtime ...` targets a blocked model, and make Telegram's model picker say that it changes only the session model while leaving the runtime unchanged. Thanks @vincentkoc. - Doctor/config: keep active `auth.profiles` metadata intact when `doctor --fix` strips stale secret fields from configs, repairing legacy `:default` API-key profile metadata when model fallbacks or explicit `model@profile` refs still depend on it. Fixes #77400. diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index 9114f2211bb..c556cb20802 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -9,7 +9,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { hasNonEmptyString } from "../infra/outbound/channel-target.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { isRecord } from "../utils.js"; -import { listBundledChannelPluginIds } from "./plugins/bundled-ids.js"; +import { listBundledChannelIds } from "./plugins/bundled-ids.js"; const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); @@ -121,7 +121,7 @@ export function listPotentialConfiguredChannelPresenceSignals( signals.push({ channelId, source }); }; const configuredChannelIds = new Set(); - const channelIds = options.channelIds ?? listBundledChannelPluginIds(env); + const channelIds = options.channelIds ?? listBundledChannelIds(env); const channelEnvPrefixes = listChannelEnvPrefixes(channelIds); const channels = isRecord(cfg.channels) ? cfg.channels : null; if (channels) { @@ -165,7 +165,7 @@ function hasEnvConfiguredChannel( env: NodeJS.ProcessEnv, options: ChannelPresenceOptions = {}, ): boolean { - const channelIds = options.channelIds ?? listBundledChannelPluginIds(env); + const channelIds = options.channelIds ?? listBundledChannelIds(env); const channelEnvPrefixes = listChannelEnvPrefixes(channelIds); for (const [key, value] of Object.entries(env)) { if (!hasNonEmptyString(value)) { diff --git a/src/channels/plugins/bundled-ids.ts b/src/channels/plugins/bundled-ids.ts index 5cc75528eb6..c23cd4b994b 100644 --- a/src/channels/plugins/bundled-ids.ts +++ b/src/channels/plugins/bundled-ids.ts @@ -10,6 +10,20 @@ export function listBundledChannelPluginIdsForRoot( .toSorted((left, right) => left.localeCompare(right)); } +export function listBundledChannelIdsForRoot( + _packageRoot: string, + env: NodeJS.ProcessEnv = process.env, +): string[] { + return listChannelCatalogEntries({ origin: "bundled", env }) + .map((entry) => entry.channel.id) + .filter((channelId): channelId is string => Boolean(channelId)) + .toSorted((left, right) => left.localeCompare(right)); +} + export function listBundledChannelPluginIds(env: NodeJS.ProcessEnv = process.env): string[] { return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope(env).cacheKey, env); } + +export function listBundledChannelIds(env: NodeJS.ProcessEnv = process.env): string[] { + return listBundledChannelIdsForRoot(resolveBundledChannelRootScope(env).cacheKey, env); +} diff --git a/src/channels/plugins/bundled-root-caches.test.ts b/src/channels/plugins/bundled-root-caches.test.ts index 71e2340c325..eca47f5fe30 100644 --- a/src/channels/plugins/bundled-root-caches.test.ts +++ b/src/channels/plugins/bundled-root-caches.test.ts @@ -62,10 +62,10 @@ describe("bundled root-aware plugin lookups", () => { listChannelCatalogEntries: (params?: { env?: NodeJS.ProcessEnv }) => { const activeRoot = params?.env?.OPENCLAW_BUNDLED_PLUGINS_DIR; if (activeRoot === rootA.pluginsDir) { - return [{ pluginId: "alpha" }]; + return [{ pluginId: "alpha", channel: { id: "alpha-chat" } }]; } if (activeRoot === rootB.pluginsDir) { - return [{ pluginId: "beta" }]; + return [{ pluginId: "beta", channel: { id: "beta-chat" } }]; } return []; }, @@ -78,9 +78,11 @@ describe("bundled root-aware plugin lookups", () => { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = rootA.pluginsDir; expect(bundledIds.listBundledChannelPluginIds()).toEqual(["alpha"]); + expect(bundledIds.listBundledChannelIds()).toEqual(["alpha-chat"]); process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = rootB.pluginsDir; expect(bundledIds.listBundledChannelPluginIds()).toEqual(["beta"]); + expect(bundledIds.listBundledChannelIds()).toEqual(["beta-chat"]); }); it("reads bootstrap plugins from the active bundled root without re-importing", async () => { diff --git a/src/channels/plugins/package-state-probes.test.ts b/src/channels/plugins/package-state-probes.test.ts new file mode 100644 index 00000000000..162d00278a4 --- /dev/null +++ b/src/channels/plugins/package-state-probes.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginChannelCatalogEntry } from "../../plugins/channel-catalog-registry.js"; +import { + hasBundledChannelPackageState, + listBundledChannelIdsForPackageState, +} from "./package-state-probes.js"; + +const listChannelCatalogEntriesMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../plugins/channel-catalog-registry.js", () => ({ + listChannelCatalogEntries: listChannelCatalogEntriesMock, +})); + +function makeBundledChannelCatalogEntry(params: { + pluginId: string; + channelId: string; +}): PluginChannelCatalogEntry { + return { + pluginId: params.pluginId, + origin: "bundled", + rootDir: "/tmp/openclaw-channel-plugin", + channel: { + id: params.channelId, + configuredState: { + env: { + allOf: ["ALIAS_CHAT_TOKEN"], + }, + }, + }, + }; +} + +beforeEach(() => { + listChannelCatalogEntriesMock.mockReset(); +}); + +describe("channel package-state probes", () => { + it("uses channel ids when manifest plugin ids differ", () => { + listChannelCatalogEntriesMock.mockReturnValue([ + makeBundledChannelCatalogEntry({ + pluginId: "vendor-alias-chat-plugin", + channelId: "alias-chat", + }), + ]); + + expect(listBundledChannelIdsForPackageState("configuredState")).toEqual(["alias-chat"]); + expect( + hasBundledChannelPackageState({ + metadataKey: "configuredState", + channelId: "alias-chat", + cfg: {}, + env: { ALIAS_CHAT_TOKEN: "token" }, + }), + ).toBe(true); + expect( + hasBundledChannelPackageState({ + metadataKey: "configuredState", + channelId: "vendor-alias-chat-plugin", + cfg: {}, + env: { ALIAS_CHAT_TOKEN: "token" }, + }), + ).toBe(false); + }); +}); diff --git a/src/channels/plugins/package-state-probes.ts b/src/channels/plugins/package-state-probes.ts index 9431f8310c1..fe4d3e88344 100644 --- a/src/channels/plugins/package-state-probes.ts +++ b/src/channels/plugins/package-state-probes.ts @@ -137,10 +137,16 @@ function resolveChannelPackageStateChecker(params: { } } +function resolvePackageStateChannelId(entry: PluginChannelCatalogEntry): string | undefined { + return normalizeOptionalString(entry.channel.id); +} + export function listBundledChannelIdsForPackageState( metadataKey: ChannelPackageStateMetadataKey, ): string[] { - return listChannelPackageStateCatalog(metadataKey).map((entry) => entry.pluginId); + return listChannelPackageStateCatalog(metadataKey) + .map((entry) => resolvePackageStateChannelId(entry)) + .filter((channelId): channelId is string => Boolean(channelId)); } export function hasBundledChannelPackageState(params: { @@ -149,8 +155,9 @@ export function hasBundledChannelPackageState(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; }): boolean { + const requestedChannelId = normalizeOptionalString(params.channelId); const entry = listChannelPackageStateCatalog(params.metadataKey).find( - (candidate) => candidate.pluginId === params.channelId, + (candidate) => resolvePackageStateChannelId(candidate) === requestedChannelId, ); if (!entry) { return false; diff --git a/src/channels/plugins/read-only-command-defaults.test.ts b/src/channels/plugins/read-only-command-defaults.test.ts index 0ad3fec1dfe..ec38f03817f 100644 --- a/src/channels/plugins/read-only-command-defaults.test.ts +++ b/src/channels/plugins/read-only-command-defaults.test.ts @@ -65,4 +65,43 @@ describe("resolveReadOnlyChannelCommandDefaults", () => { workspaceDir: "/workspace", }); }); + + it("resolves command defaults for manifest channel aliases", () => { + loadPluginMetadataSnapshot.mockReturnValue({ + index: { + plugins: [ + { + pluginId: "vendor-demo-plugin", + origin: "global", + enabled: true, + enabledByDefault: true, + }, + ], + }, + plugins: [ + { + id: "vendor-demo-plugin", + origin: "global", + channels: ["demo"], + channelConfigs: { + demo: { + commands: { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: false, + }, + }, + }, + }, + ], + }); + + expect( + resolveReadOnlyChannelCommandDefaults("demo", { + config: {}, + }), + ).toEqual({ + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: false, + }); + }); }); diff --git a/src/channels/plugins/read-only-command-defaults.ts b/src/channels/plugins/read-only-command-defaults.ts index 393f2292db9..e4b8d6c9c01 100644 --- a/src/channels/plugins/read-only-command-defaults.ts +++ b/src/channels/plugins/read-only-command-defaults.ts @@ -74,12 +74,6 @@ export function resolveReadOnlyChannelCommandDefaults( if (!record.channels.includes(normalizedChannelId)) { continue; } - if ( - record.id !== normalizedChannelId && - record.channelCatalogMeta?.id !== normalizedChannelId - ) { - continue; - } if (!isInstalledPluginEnabled(snapshot.index, record.id, options.config)) { continue; } @@ -92,9 +86,11 @@ export function resolveReadOnlyChannelCommandDefaults( !Array.isArray(channelConfigValue) ? (channelConfigValue as ManifestChannelConfigRecord) : undefined; - const commands = normalizeChannelCommandDefaults( - channelConfig?.commands ?? record.channelCatalogMeta?.commands, - ); + const catalogCommands = + record.channelCatalogMeta?.id === normalizedChannelId + ? record.channelCatalogMeta.commands + : undefined; + const commands = normalizeChannelCommandDefaults(channelConfig?.commands ?? catalogCommands); if (commands) { return commands; } From f8e080386d8bcad6b90052e97ea9ba16584be30c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:36:18 +0100 Subject: [PATCH 085/107] fix(gateway): quiet benchmark watch output --- CHANGELOG.md | 1 + docs/help/debugging.md | 3 +++ scripts/gateway-watch-tmux.mjs | 1 + src/infra/gateway-watch-tmux.test.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e0f2912c88..4b1fc1900bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/watch: suppress sync-I/O trace output during `pnpm gateway:watch --benchmark` unless explicitly requested, so CPU profiling no longer floods the terminal with stack traces. - Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc. - Channels/plugins: key bundled package-state probes, env/config presence, and read-only command defaults by channel id instead of manifest plugin id, preserving setup and native-command detection for channel plugins whose package id differs from the channel alias. Thanks @vincentkoc. - Docker: prune package-excluded plugin dist directories from runtime images unless the build explicitly opts that plugin in, so official external plugins such as Feishu stay install-on-demand instead of shipping partial metadata without compiled runtime output. Fixes #77424. Thanks @vincentkoc. diff --git a/docs/help/debugging.md b/docs/help/debugging.md index 073cfed9ce2..1e24d01dfc3 100644 --- a/docs/help/debugging.md +++ b/docs/help/debugging.md @@ -157,6 +157,9 @@ Use `--benchmark-dir ` when you want profiles somewhere else. Use `--benchmark-no-force` when you want the benchmarked child to skip the default `--force` port cleanup and fail fast if the Gateway port is already in use. +Benchmark mode suppresses sync-I/O trace spam by default. Set +`OPENCLAW_TRACE_SYNC_IO=1` with `--benchmark` when you explicitly want both CPU +profiles and Node sync-I/O stack traces. The tmux wrapper carries common non-secret runtime selectors such as `OPENCLAW_PROFILE`, `OPENCLAW_CONFIG_PATH`, `OPENCLAW_STATE_DIR`, diff --git a/scripts/gateway-watch-tmux.mjs b/scripts/gateway-watch-tmux.mjs index b7f4b95dbe3..0aab8cd034f 100644 --- a/scripts/gateway-watch-tmux.mjs +++ b/scripts/gateway-watch-tmux.mjs @@ -97,6 +97,7 @@ const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {}) if (benchmarkFlagSeen) { nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] = benchmarkDir || nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] || DEFAULT_BENCHMARK_PROFILE_DIR; + nextEnv.OPENCLAW_TRACE_SYNC_IO ??= "0"; } return { args: benchmarkNoForceSeen diff --git a/src/infra/gateway-watch-tmux.test.ts b/src/infra/gateway-watch-tmux.test.ts index 1e271eae8c1..99045a54cc1 100644 --- a/src/infra/gateway-watch-tmux.test.ts +++ b/src/infra/gateway-watch-tmux.test.ts @@ -89,6 +89,7 @@ describe("gateway-watch tmux wrapper", () => { expect(code).toBe(0); const command = spawnSync.mock.calls[1]?.[1]?.[6] as string; expect(command).toContain("'OPENCLAW_RUN_NODE_CPU_PROF_DIR=.artifacts/gateway-watch-profiles'"); + expect(command).toContain("'OPENCLAW_TRACE_SYNC_IO=0'"); expect(command).not.toContain("--benchmark"); expect(command).toContain("'gateway'"); expect(command).toContain("'--force'"); @@ -97,6 +98,31 @@ describe("gateway-watch tmux wrapper", () => { ); }); + it("preserves explicit sync I/O tracing in benchmark mode", () => { + const stdout = createOutput(); + const stderr = createOutput(); + const spawnSync = vi + .fn() + .mockReturnValueOnce({ status: 1, stdout: "", stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }); + + const code = runGatewayWatchTmuxMain({ + args: ["gateway", "--force", "--benchmark"], + cwd: "/repo", + env: { OPENCLAW_TRACE_SYNC_IO: "1", SHELL: "/bin/zsh" }, + nodePath: "/node", + spawnSync, + stderr: stderr.stream, + stdout: stdout.stream, + }); + + expect(code).toBe(0); + const command = spawnSync.mock.calls[1]?.[1]?.[6] as string; + expect(command).toContain("'OPENCLAW_TRACE_SYNC_IO=1'"); + }); + it("can remove --force from benchmarked watch runs", () => { const stdout = createOutput(); const stderr = createOutput(); From e39d3b42230402de098bd32751b1d251f527e764 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 15:37:58 -0700 Subject: [PATCH 086/107] fix(mattermost): clarify model picker runtime behavior --- CHANGELOG.md | 1 + extensions/mattermost/src/mattermost/model-picker.test.ts | 2 ++ extensions/mattermost/src/mattermost/model-picker.ts | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b1fc1900bd..4adf07b2379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai - Channels/plugins: key bundled package-state probes, env/config presence, and read-only command defaults by channel id instead of manifest plugin id, preserving setup and native-command detection for channel plugins whose package id differs from the channel alias. Thanks @vincentkoc. - Docker: prune package-excluded plugin dist directories from runtime images unless the build explicitly opts that plugin in, so official external plugins such as Feishu stay install-on-demand instead of shipping partial metadata without compiled runtime output. Fixes #77424. Thanks @vincentkoc. - Model switching: include the exact additive allowlist repair command when `/model ... --runtime ...` targets a blocked model, and make Telegram's model picker say that it changes only the session model while leaving the runtime unchanged. Thanks @vincentkoc. +- Mattermost: clarify that the model picker only changes the session model and that runtime switches require `/oc_model --runtime `. Thanks @vincentkoc. - Doctor/config: keep active `auth.profiles` metadata intact when `doctor --fix` strips stale secret fields from configs, repairing legacy `:default` API-key profile metadata when model fallbacks or explicit `model@profile` refs still depend on it. Fixes #77400. - CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc. - CLI/update: use an absolute POSIX npm script shell during package-manager updates, so restricted PATH environments can still run dependency lifecycle scripts while updating from `--tag main`. Fixes #77530. Thanks @PeterTremonti. diff --git a/extensions/mattermost/src/mattermost/model-picker.test.ts b/extensions/mattermost/src/mattermost/model-picker.test.ts index 753c9218aeb..a3c8d02c949 100644 --- a/extensions/mattermost/src/mattermost/model-picker.test.ts +++ b/extensions/mattermost/src/mattermost/model-picker.test.ts @@ -57,6 +57,8 @@ describe("Mattermost model picker", () => { expect(view.text).toContain("Current: openai/gpt-5"); expect(view.text).toContain("Tap below to browse models"); expect(view.text).toContain("/oc_model to switch"); + expect(view.text).toContain("Browse keeps the current runtime"); + expect(view.text).toContain("/oc_model --runtime "); expect(view.buttons[0]?.[0]?.text).toBe("Browse providers"); }); diff --git a/extensions/mattermost/src/mattermost/model-picker.ts b/extensions/mattermost/src/mattermost/model-picker.ts index d5f1a7c6d2b..20d2206f564 100644 --- a/extensions/mattermost/src/mattermost/model-picker.ts +++ b/extensions/mattermost/src/mattermost/model-picker.ts @@ -273,7 +273,7 @@ export function renderMattermostModelSummaryView(params: { "", "Tap below to browse models, or use:", "/oc_model to switch", - "/oc_model --runtime for runtime", + "Browse keeps the current runtime; use /oc_model --runtime to switch runtime too", "/oc_model status for details", ].join("\n"), buttons: [ From 864b1be1b3205141f0e0e8ddf83f38aff334dc23 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:39:37 +0100 Subject: [PATCH 087/107] fix: repair release validation checks --- extensions/diagnostics-otel/src/service.ts | 2 ++ src/logging/diagnostic-phase.ts | 2 +- src/logging/diagnostic.test.ts | 6 ++---- test/scripts/package-acceptance-workflow.test.ts | 12 ++++++++---- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 8c3f607fd66..3ada2aefacc 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -2222,6 +2222,8 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { case "diagnostic.liveness.warning": recordLivenessWarning(evt); return; + case "diagnostic.phase.completed": + return; case "run.started": recordRunStarted(evt, metadata); return; diff --git a/src/logging/diagnostic-phase.ts b/src/logging/diagnostic-phase.ts index abd8b379a40..d6b8ddbb1bc 100644 --- a/src/logging/diagnostic-phase.ts +++ b/src/logging/diagnostic-phase.ts @@ -39,7 +39,7 @@ export function getCurrentDiagnosticPhase(): string | undefined { } export function getRecentDiagnosticPhases(limit = 8): DiagnosticPhaseSnapshot[] { - return recentPhases.slice(-Math.max(0, limit)).map((phase) => ({ ...phase })); + return recentPhases.slice(-Math.max(0, limit)).map((phase) => Object.assign({}, phase)); } export function recordDiagnosticPhase(snapshot: DiagnosticPhaseSnapshot): void { diff --git a/src/logging/diagnostic.test.ts b/src/logging/diagnostic.test.ts index ac93e0e2212..26eae67d146 100644 --- a/src/logging/diagnostic.test.ts +++ b/src/logging/diagnostic.test.ts @@ -397,7 +397,7 @@ describe("stuck session diagnostics threshold", () => { } expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("terminalProgressStale=true")); - expect(events.filter((event) => event.type === "session.stalled").at(-1)).toMatchObject({ + expect(events.findLast((event) => event.type === "session.stalled")).toMatchObject({ terminalProgressStale: true, lastProgressReason: "codex_app_server:notification:rawResponseItem/completed", }); @@ -754,9 +754,7 @@ describe("stuck session diagnostics threshold", () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("phase=startup.plugins.load")); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("work=[queued=main(")); - expect( - events.filter((event) => event.type === "diagnostic.liveness.warning").at(-1), - ).toMatchObject({ + expect(events.findLast((event) => event.type === "diagnostic.liveness.warning")).toMatchObject({ phase: "startup.plugins.load", queuedWorkLabels: [expect.stringContaining("main(")], }); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 666a3d05339..3e1fd42f7de 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -528,8 +528,12 @@ describe("package artifact reuse", () => { expect(workflow).toContain( "docker_lanes: doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update", ); - expect(workflow).toContain("published_upgrade_survivor_baselines: all-since-2026.4.23"); - expect(workflow).toContain("published_upgrade_survivor_scenarios: reported-issues"); + expect(workflow).toContain( + "published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'all-since-2026.4.23' || '' }}", + ); + expect(workflow).toContain( + "published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}", + ); expect(workflow).toContain("telegram_mode: mock-openai"); expect(workflow).toContain( "telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-current-session-status-tool,telegram-mention-gating", @@ -551,7 +555,7 @@ describe("package artifact reuse", () => { "contains(fromJSON('[\"all\",\"cross-os\",\"package\"]'), needs.resolve_target.outputs.rerun_group) || (needs.resolve_target.outputs.rerun_group == 'live-e2e' && needs.resolve_target.outputs.live_suite_filter == '')", ); expect(workflow).toContain( - "contains(fromJSON('[\"all\",\"live-e2e\"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.live_suite_filter == ''", + "(needs.resolve_target.outputs.rerun_group == 'live-e2e' || (needs.resolve_target.outputs.rerun_group == 'all' && needs.resolve_target.outputs.run_release_soak == 'true')) && needs.resolve_target.outputs.live_suite_filter == ''", ); expect(workflow).toContain("- live-e2e"); expect(workflow).toContain("- qa-live"); @@ -673,7 +677,7 @@ describe("package artifact reuse", () => { "Focused `npm-telegram` reruns require `npm_telegram_package_spec`", ); expectTextToIncludeAll(fullReleaseDocs, [ - "full pre-publish candidate", + "pre-publish candidate", "silently skip that", "Telegram package lane", "| `npm-telegram` | Published-package Telegram E2E; requires `npm_telegram_package_spec`. |", From ce8bc1a3e39b92e8803f01967c6a3531a5ad3708 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 15:40:00 -0700 Subject: [PATCH 088/107] fix(lint): cover diagnostic phase events --- extensions/diagnostics-otel/src/service.ts | 2 ++ src/logging/diagnostic-phase.ts | 2 +- src/logging/diagnostic.test.ts | 6 ++---- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 8c3f607fd66..3ada2aefacc 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -2222,6 +2222,8 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { case "diagnostic.liveness.warning": recordLivenessWarning(evt); return; + case "diagnostic.phase.completed": + return; case "run.started": recordRunStarted(evt, metadata); return; diff --git a/src/logging/diagnostic-phase.ts b/src/logging/diagnostic-phase.ts index abd8b379a40..d6b8ddbb1bc 100644 --- a/src/logging/diagnostic-phase.ts +++ b/src/logging/diagnostic-phase.ts @@ -39,7 +39,7 @@ export function getCurrentDiagnosticPhase(): string | undefined { } export function getRecentDiagnosticPhases(limit = 8): DiagnosticPhaseSnapshot[] { - return recentPhases.slice(-Math.max(0, limit)).map((phase) => ({ ...phase })); + return recentPhases.slice(-Math.max(0, limit)).map((phase) => Object.assign({}, phase)); } export function recordDiagnosticPhase(snapshot: DiagnosticPhaseSnapshot): void { diff --git a/src/logging/diagnostic.test.ts b/src/logging/diagnostic.test.ts index ac93e0e2212..26eae67d146 100644 --- a/src/logging/diagnostic.test.ts +++ b/src/logging/diagnostic.test.ts @@ -397,7 +397,7 @@ describe("stuck session diagnostics threshold", () => { } expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("terminalProgressStale=true")); - expect(events.filter((event) => event.type === "session.stalled").at(-1)).toMatchObject({ + expect(events.findLast((event) => event.type === "session.stalled")).toMatchObject({ terminalProgressStale: true, lastProgressReason: "codex_app_server:notification:rawResponseItem/completed", }); @@ -754,9 +754,7 @@ describe("stuck session diagnostics threshold", () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("phase=startup.plugins.load")); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("work=[queued=main(")); - expect( - events.filter((event) => event.type === "diagnostic.liveness.warning").at(-1), - ).toMatchObject({ + expect(events.findLast((event) => event.type === "diagnostic.liveness.warning")).toMatchObject({ phase: "startup.plugins.load", queuedWorkLabels: [expect.stringContaining("main(")], }); From a167acee6792bd479f2084a4bee0b545e49c1db4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:41:07 +0100 Subject: [PATCH 089/107] fix(gateway): route watch trace spam to artifacts --- CHANGELOG.md | 1 + docs/help/debugging.md | 4 +- scripts/gateway-watch-tmux.mjs | 26 +++++++++++ scripts/run-node.mjs | 67 +++++++++++++++++++++++++++- src/infra/gateway-watch-tmux.test.ts | 7 +++ src/infra/run-node.test.ts | 53 ++++++++++++++++++++++ 6 files changed, 156 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b1fc1900bd..0e473ea9d92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/watch: suppress sync-I/O trace output during `pnpm gateway:watch --benchmark` unless explicitly requested, so CPU profiling no longer floods the terminal with stack traces. +- Gateway/watch: when benchmark sync-I/O tracing is explicitly enabled, tee trace blocks to the benchmark output log and filter them from the terminal pane while keeping normal Gateway logs visible. - Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc. - Channels/plugins: key bundled package-state probes, env/config presence, and read-only command defaults by channel id instead of manifest plugin id, preserving setup and native-command detection for channel plugins whose package id differs from the channel alias. Thanks @vincentkoc. - Docker: prune package-excluded plugin dist directories from runtime images unless the build explicitly opts that plugin in, so official external plugins such as Feishu stay install-on-demand instead of shipping partial metadata without compiled runtime output. Fixes #77424. Thanks @vincentkoc. diff --git a/docs/help/debugging.md b/docs/help/debugging.md index 1e24d01dfc3..1f01cbc819c 100644 --- a/docs/help/debugging.md +++ b/docs/help/debugging.md @@ -159,7 +159,9 @@ default `--force` port cleanup and fail fast if the Gateway port is already in use. Benchmark mode suppresses sync-I/O trace spam by default. Set `OPENCLAW_TRACE_SYNC_IO=1` with `--benchmark` when you explicitly want both CPU -profiles and Node sync-I/O stack traces. +profiles and Node sync-I/O stack traces. In benchmark mode those trace blocks +are written to `gateway-watch-output.log` under the benchmark directory and +filtered from the terminal pane; normal Gateway logs remain visible. The tmux wrapper carries common non-secret runtime selectors such as `OPENCLAW_PROFILE`, `OPENCLAW_CONFIG_PATH`, `OPENCLAW_STATE_DIR`, diff --git a/scripts/gateway-watch-tmux.mjs b/scripts/gateway-watch-tmux.mjs index 0aab8cd034f..24d63ba1b57 100644 --- a/scripts/gateway-watch-tmux.mjs +++ b/scripts/gateway-watch-tmux.mjs @@ -9,6 +9,8 @@ const TMUX_ATTACH_FORCE_VALUES = new Set(["1", "true", "yes", "on"]); const DEFAULT_PROFILE_NAME = "main"; const DEFAULT_BENCHMARK_PROFILE_DIR = ".artifacts/gateway-watch-profiles"; const RUN_NODE_CPU_PROF_DIR_ENV = "OPENCLAW_RUN_NODE_CPU_PROF_DIR"; +const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG"; +const RUN_NODE_FILTER_SYNC_IO_STDERR_ENV = "OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR"; const RAW_WATCH_SCRIPT = "scripts/watch-node.mjs"; const TMUX_CWD_ENV_KEY = "OPENCLAW_GATEWAY_WATCH_CWD"; const TMUX_CWD_OPTION_KEY = "@openclaw.gateway_watch.cwd"; @@ -19,6 +21,8 @@ const TMUX_CHILD_ENV_KEYS = [ "OPENCLAW_HOME", "OPENCLAW_PROFILE", RUN_NODE_CPU_PROF_DIR_ENV, + RUN_NODE_FILTER_SYNC_IO_STDERR_ENV, + RUN_NODE_OUTPUT_LOG_ENV, "OPENCLAW_SKIP_CHANNELS", "OPENCLAW_STATE_DIR", "OPENCLAW_TRACE_SYNC_IO", @@ -50,6 +54,11 @@ const readArgValue = (args, flag) => { return null; }; +const joinArtifactPath = (dir, basename) => { + const normalizedDir = String(dir || DEFAULT_BENCHMARK_PROFILE_DIR).replace(/[\\/]+$/g, ""); + return `${normalizedDir || "."}/${basename}`; +}; + const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {}) => { const passthroughArgs = []; let benchmarkDir = null; @@ -98,6 +107,13 @@ const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {}) nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] = benchmarkDir || nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] || DEFAULT_BENCHMARK_PROFILE_DIR; nextEnv.OPENCLAW_TRACE_SYNC_IO ??= "0"; + if (nextEnv.OPENCLAW_TRACE_SYNC_IO === "1") { + nextEnv[RUN_NODE_OUTPUT_LOG_ENV] ??= joinArtifactPath( + nextEnv[RUN_NODE_CPU_PROF_DIR_ENV], + "gateway-watch-output.log", + ); + nextEnv[RUN_NODE_FILTER_SYNC_IO_STDERR_ENV] ??= "1"; + } } return { args: benchmarkNoForceSeen @@ -105,6 +121,10 @@ const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {}) : passthroughArgs, benchmarkNoForce: benchmarkNoForceSeen, benchmarkProfileDir: nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] || null, + benchmarkTraceOutputLog: + nextEnv[RUN_NODE_FILTER_SYNC_IO_STDERR_ENV] === "1" + ? nextEnv[RUN_NODE_OUTPUT_LOG_ENV] || null + : null, env: nextEnv, }; }; @@ -250,6 +270,12 @@ export const runGatewayWatchTmuxMain = (params = {}) => { if (resolvedArgs.benchmarkProfileDir) { log(deps.stderr, `gateway:watch benchmark CPU profiles: ${resolvedArgs.benchmarkProfileDir}`); } + if (resolvedArgs.benchmarkTraceOutputLog) { + log( + deps.stderr, + `gateway:watch benchmark trace output: ${resolvedArgs.benchmarkTraceOutputLog}`, + ); + } if (resolvedArgs.benchmarkNoForce) { log(deps.stderr, "gateway:watch benchmark running without --force"); } diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index e54361af211..9b2e8744b1f 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -386,6 +386,7 @@ const getSignalExitCode = (signal) => (isSignalKey(signal) ? SIGNAL_EXIT_CODES[s const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG"; const RUN_NODE_CPU_PROF_DIR_ENV = "OPENCLAW_RUN_NODE_CPU_PROF_DIR"; +const RUN_NODE_FILTER_SYNC_IO_STDERR_ENV = "OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR"; const RUN_NODE_BUILD_LOCK_TIMEOUT_ENV = "OPENCLAW_RUN_NODE_BUILD_LOCK_TIMEOUT_MS"; const RUN_NODE_BUILD_LOCK_POLL_ENV = "OPENCLAW_RUN_NODE_BUILD_LOCK_POLL_MS"; const RUN_NODE_BUILD_LOCK_STALE_ENV = "OPENCLAW_RUN_NODE_BUILD_LOCK_STALE_MS"; @@ -585,14 +586,78 @@ const pipeSpawnedOutput = (childProcess, deps) => { if (!deps.outputTee) { return; } + const stderrFilter = + deps.env[RUN_NODE_FILTER_SYNC_IO_STDERR_ENV] === "1" + ? createSyncIoTraceStderrFilter(deps) + : null; childProcess.stdout?.on("data", (chunk) => { deps.stdout.write(chunk); deps.outputTee.write(chunk); }); childProcess.stderr?.on("data", (chunk) => { - deps.stderr.write(chunk); + if (stderrFilter) { + stderrFilter.write(chunk); + } else { + deps.stderr.write(chunk); + } deps.outputTee.write(chunk); }); + childProcess.stderr?.on("end", () => { + stderrFilter?.flush(); + }); +}; + +const createSyncIoTraceStderrFilter = (deps) => { + let buffer = ""; + let inSyncIoTrace = false; + + const shouldSuppressLine = (line) => { + const text = line.replace(/\r?\n$/, ""); + if (/^\(node:\d+\) WARNING: Detected use of sync API/.test(text)) { + inSyncIoTrace = true; + return true; + } + if (!inSyncIoTrace) { + return false; + } + if (text.trim() === "") { + inSyncIoTrace = false; + return true; + } + if (/^\s+at\b/.test(text)) { + return true; + } + inSyncIoTrace = false; + return false; + }; + + const writeLine = (line) => { + if (!shouldSuppressLine(line)) { + deps.stderr.write(line); + } + }; + + return { + write(chunk) { + buffer += String(chunk); + while (true) { + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex === -1) { + break; + } + const line = buffer.slice(0, newlineIndex + 1); + buffer = buffer.slice(newlineIndex + 1); + writeLine(line); + } + }, + flush() { + if (!buffer) { + return; + } + writeLine(buffer); + buffer = ""; + }, + }; }; const closeRunNodeOutputTee = async (deps, exitCode) => { diff --git a/src/infra/gateway-watch-tmux.test.ts b/src/infra/gateway-watch-tmux.test.ts index 99045a54cc1..6c21233c535 100644 --- a/src/infra/gateway-watch-tmux.test.ts +++ b/src/infra/gateway-watch-tmux.test.ts @@ -121,6 +121,13 @@ describe("gateway-watch tmux wrapper", () => { expect(code).toBe(0); const command = spawnSync.mock.calls[1]?.[1]?.[6] as string; expect(command).toContain("'OPENCLAW_TRACE_SYNC_IO=1'"); + expect(command).toContain( + "'OPENCLAW_RUN_NODE_OUTPUT_LOG=.artifacts/gateway-watch-profiles/gateway-watch-output.log'", + ); + expect(command).toContain("'OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR=1'"); + expect(stderr.chunks.join("")).toContain( + "gateway:watch benchmark trace output: .artifacts/gateway-watch-profiles/gateway-watch-output.log", + ); }); it("can remove --force from benchmarked watch runs", () => { diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index b38e944a929..25ff7b966d1 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -448,6 +448,59 @@ describe("run-node script", () => { }); }); + it("routes sync I/O trace stderr blocks to the output log without flooding stderr", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp); + const outputPath = path.join(tmp, ".artifacts", "gateway-watch-profiles", "output.log"); + const childStderr = [ + "normal before\n", + "(node:12345) WARNING: Detected use of sync API\n", + " at statSync (node:fs:1739:25)\n", + " at loadConfig (/repo/src/config.ts:1:1)\n", + "\n", + "normal after\n", + ].join(""); + const spawn = (_cmd: string, args: string[]) => + createPipedExitedProcess({ + stderr: args[0] === "openclaw.mjs" ? childStderr : "", + }); + const stderrChunks: string[] = []; + const stderr = { + write: (chunk: string | Buffer) => { + stderrChunks.push(String(chunk)); + return true; + }, + } as unknown as NodeJS.WriteStream; + const stdout = { + write: () => true, + } as unknown as NodeJS.WriteStream; + + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR: "1", + OPENCLAW_RUN_NODE_OUTPUT_LOG: outputPath, + }, + spawn, + stderr, + stdout, + execPath: process.execPath, + platform: process.platform, + } as Parameters[0] & { stdout: NodeJS.WriteStream }); + + expect(exitCode).toBe(0); + const terminalStderr = stderrChunks.join(""); + expect(terminalStderr).toContain("normal before\n"); + expect(terminalStderr).toContain("normal after\n"); + expect(terminalStderr).not.toContain("Detected use of sync API"); + expect(terminalStderr).not.toContain("statSync"); + await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain(childStderr); + }); + }); + it("adds Node CPU profiling flags to the launched OpenClaw child when requested", async () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp, { From 68d4921392881e28fdbc5cf6b7e2b2bf31bb40c0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 15:41:21 -0700 Subject: [PATCH 090/107] test(doctor): mock bundled channel ids --- src/commands/doctor-state-integrity.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index 21536a5a108..33183e2425d 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -10,6 +10,7 @@ import { import { noteStateIntegrity } from "./doctor-state-integrity.js"; vi.mock("../channels/plugins/bundled-ids.js", () => ({ + listBundledChannelIds: () => ["matrix", "whatsapp"], listBundledChannelPluginIds: () => ["matrix", "whatsapp"], })); From 81035e651bf7b8446751182475a820bc65af8d48 Mon Sep 17 00:00:00 2001 From: dougbtv Date: Sat, 2 May 2026 09:50:13 -0400 Subject: [PATCH 091/107] fix(config): register bundledMode in zod schema and help text Addresses review feedback: adds bundledMode to the strict plugins zod object so the config option passes validation, and adds schema.help documentation for the field. --- src/config/schema.help.ts | 2 ++ src/config/zod-schema.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index d53ce928333..4b6c9642e0e 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1212,6 +1212,8 @@ export const FIELD_HELP: Record = { 'Select the active memory plugin by id, or "none" to disable memory plugins.', "plugins.slots.contextEngine": "Selects the active context engine plugin by id so one plugin provides context orchestration behavior.", + "plugins.bundledMode": + 'Controls whether bundled plugins bypass plugins.allow on runtime discovery paths. "compat" (default) preserves legacy behavior where bundled provider plugins are force-loaded on every chat turn. "respect-allow" gates bundled plugins by the allowlist the same way third-party plugins are gated.', "plugins.entries": "Per-plugin settings keyed by plugin ID including enablement and plugin-specific runtime configuration payloads. Use this for scoped plugin tuning without changing global loader policy.", "plugins.entries.*.enabled": diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 33a4e8c0592..af5a330d75d 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -1073,6 +1073,7 @@ export const OpenClawSchema = z .strict() .optional(), entries: z.record(z.string(), PluginEntrySchema).optional(), + bundledMode: z.enum(["compat", "respect-allow"]).optional(), }) .strict() .optional(), From f738663c7967841caa2f5e3c9b53250e3ac938b9 Mon Sep 17 00:00:00 2001 From: dougbtv Date: Sat, 2 May 2026 09:29:18 -0400 Subject: [PATCH 092/107] fix(plugins): add bundledMode to gate runtime provider discovery by allowlist When plugins.bundledMode is set to "respect-allow", runtime provider discovery paths honor plugins.allow for bundled plugins instead of force-loading all providers. Default "compat" preserves existing behavior. Closes #75575 --- ....providers.plugin-allowlist-compat.test.ts | 24 +++++++ src/config/types.plugins.ts | 10 +++ src/plugins/activation-context.ts | 9 ++- src/plugins/bundled-compat.ts | 8 +++ src/plugins/providers.test.ts | 69 +++++++++++++++++++ src/plugins/providers.ts | 11 ++- 6 files changed, 129 insertions(+), 2 deletions(-) diff --git a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts index a440b4dbe80..5b2f808be7b 100644 --- a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts +++ b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts @@ -84,6 +84,30 @@ describe("implicit provider plugin allowlist compatibility", () => { ).toEqual(["kilocode", "moonshot", "openrouter"]); }); + it("respects allowlist for bundled plugins when bundledMode is respect-allow", () => { + const config = withBundledPluginEnablementCompat({ + config: withBundledPluginAllowlistCompat({ + config: { + plugins: { + allow: ["openrouter"], + bundledMode: "respect-allow", + }, + }, + pluginIds: ["kilocode", "moonshot"], + }), + pluginIds: ["kilocode", "moonshot"], + }); + + expect( + resolveEnabledProviderPluginIds({ + config, + registry: providerRegistry, + manifestRegistry: providerManifestRegistry, + onlyPluginIds: PROVIDER_PLUGIN_IDS, + }), + ).toEqual(["openrouter"]); + }); + it("still honors explicit plugin denies over compat allowlist injection", () => { const config = withBundledPluginEnablementCompat({ config: withBundledPluginAllowlistCompat({ diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 0f5818baf37..eb44e59d0fc 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -51,6 +51,16 @@ export type PluginsConfig = { allow?: string[]; /** Optional plugin denylist (plugin ids). */ deny?: string[]; + /** + * Controls whether bundled plugins bypass `allow` / `entries` on runtime + * provider discovery paths. + * + * - `"compat"` (default): bundled provider plugins are force-loaded on + * every chat turn regardless of the allowlist (legacy behavior). + * - `"respect-allow"`: bundled provider plugins are gated by `allow` and + * `entries..enabled` the same way third-party plugins are. + */ + bundledMode?: "compat" | "respect-allow"; load?: PluginsLoadConfig; slots?: PluginSlotsConfig; entries?: Record; diff --git a/src/plugins/activation-context.ts b/src/plugins/activation-context.ts index fa9add9df4c..5753174e772 100644 --- a/src/plugins/activation-context.ts +++ b/src/plugins/activation-context.ts @@ -78,7 +78,11 @@ export function withActivatedPluginIds(params: { if (params.pluginIds.length === 0) { return params.config; } - const allow = new Set(params.config?.plugins?.allow ?? []); + const originalAllow = params.config?.plugins?.allow ?? []; + const respectAllow = + params.config?.plugins?.bundledMode === "respect-allow" && originalAllow.length > 0; + const originalAllowSet = respectAllow ? new Set(originalAllow) : undefined; + const allow = new Set(originalAllow); const entries = { ...params.config?.plugins?.entries, }; @@ -87,6 +91,9 @@ export function withActivatedPluginIds(params: { if (!normalized) { continue; } + if (originalAllowSet && !originalAllowSet.has(normalized)) { + continue; + } allow.add(normalized); const existingEntry = entries[normalized]; entries[normalized] = { diff --git a/src/plugins/bundled-compat.ts b/src/plugins/bundled-compat.ts index 1fd096a90a2..33cdecb2700 100644 --- a/src/plugins/bundled-compat.ts +++ b/src/plugins/bundled-compat.ts @@ -6,6 +6,9 @@ export function withBundledPluginAllowlistCompat(params: { config: OpenClawConfig | undefined; pluginIds: readonly string[]; }): OpenClawConfig | undefined { + if (params.config?.plugins?.bundledMode === "respect-allow") { + return params.config; + } const allow = params.config?.plugins?.allow; if (!Array.isArray(allow) || allow.length === 0) { return params.config; @@ -39,6 +42,8 @@ export function withBundledPluginEnablementCompat(params: { }): OpenClawConfig | undefined { const existingEntries = params.config?.plugins?.entries ?? {}; const forcePluginsEnabled = params.config?.plugins?.enabled === false; + const respectAllow = params.config?.plugins?.bundledMode === "respect-allow"; + const allowSet = respectAllow ? new Set(params.config?.plugins?.allow ?? []) : undefined; let changed = false; const nextEntries: Record = { ...existingEntries }; @@ -46,6 +51,9 @@ export function withBundledPluginEnablementCompat(params: { if (existingEntries[pluginId] !== undefined) { continue; } + if (allowSet && !allowSet.has(pluginId)) { + continue; + } nextEntries[pluginId] = { enabled: true }; changed = true; } diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index d936b62ba67..4adb7d30366 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -593,6 +593,75 @@ describe("resolvePluginProviders", () => { ).toEqual(["legacy-auth-owner"]); }); + it("filters bundled provider plugins by allowlist when bundledMode is respect-allow", () => { + setManifestPlugins([ + createManifestProviderPlugin({ + id: "kilocode", + providerIds: ["kilocode"], + origin: "bundled", + enabledByDefault: true, + }), + createManifestProviderPlugin({ + id: "moonshot", + providerIds: ["moonshot"], + origin: "bundled", + enabledByDefault: true, + }), + createManifestProviderPlugin({ + id: "openrouter", + providerIds: ["openrouter"], + origin: "bundled", + enabledByDefault: true, + }), + ]); + + const discovered = resolveDiscoveredProviderPluginIds({ + config: { + plugins: { + allow: ["openrouter"], + bundledMode: "respect-allow", + }, + }, + env: {} as NodeJS.ProcessEnv, + }); + + expect(discovered).toEqual(["openrouter"]); + }); + + it("returns all bundled provider plugins in compat mode (default)", () => { + setManifestPlugins([ + createManifestProviderPlugin({ + id: "kilocode", + providerIds: ["kilocode"], + origin: "bundled", + enabledByDefault: true, + }), + createManifestProviderPlugin({ + id: "moonshot", + providerIds: ["moonshot"], + origin: "bundled", + enabledByDefault: true, + }), + createManifestProviderPlugin({ + id: "openrouter", + providerIds: ["openrouter"], + origin: "bundled", + enabledByDefault: true, + }), + ]); + + const discovered = resolveDiscoveredProviderPluginIds({ + config: { + plugins: { + allow: ["openrouter"], + }, + }, + env: {} as NodeJS.ProcessEnv, + }); + + expect(discovered).toEqual(["kilocode", "moonshot", "openrouter"]); + }); + it("treats explicit empty provider scopes as scoped-empty in provider helpers", () => { expect( resolveEnabledProviderPluginIds({ diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index b9903b5109b..d21282ef8a2 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -255,6 +255,7 @@ export function resolveDiscoveredProviderPluginIds(params: { const { registry, onlyPluginIdSet } = loadScopedProviderRegistry(params); const providerSurfacePluginIds = resolveProviderSurfacePluginIdSet({ ...params, registry }); const shouldFilterUntrustedWorkspacePlugins = params.includeUntrustedWorkspacePlugins === false; + const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledMode === "respect-allow"; const normalizedConfig = normalizePluginsConfigWithRegistry(params.config?.plugins, registry); return listRegistryPluginIds(registry, (plugin) => { if ( @@ -268,6 +269,7 @@ export function resolveDiscoveredProviderPluginIds(params: { return isProviderPluginEligibleForSetupDiscovery({ plugin, shouldFilterUntrustedWorkspacePlugins, + shouldFilterBundledByAllowlist, normalizedConfig, rootConfig: params.config, }); @@ -277,10 +279,15 @@ export function resolveDiscoveredProviderPluginIds(params: { function isProviderPluginEligibleForSetupDiscovery(params: { plugin: PluginRegistryRecord; shouldFilterUntrustedWorkspacePlugins: boolean; + shouldFilterBundledByAllowlist: boolean; normalizedConfig: NormalizedPluginsConfig; rootConfig?: PluginLoadOptions["config"]; }): boolean { - if (!params.shouldFilterUntrustedWorkspacePlugins || params.plugin.origin !== "workspace") { + if (params.plugin.origin === "workspace") { + if (!params.shouldFilterUntrustedWorkspacePlugins) { + return true; + } + } else if (!params.shouldFilterBundledByAllowlist) { return true; } if ( @@ -306,12 +313,14 @@ export function resolveDiscoverableProviderOwnerPluginIds(params: { includeUntrustedWorkspacePlugins?: boolean; }): string[] { const shouldFilterUntrustedWorkspacePlugins = params.includeUntrustedWorkspacePlugins === false; + const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledMode === "respect-allow"; return resolveProviderOwnerPluginIds({ ...params, isEligible: (plugin, normalizedConfig) => isProviderPluginEligibleForSetupDiscovery({ plugin, shouldFilterUntrustedWorkspacePlugins, + shouldFilterBundledByAllowlist, normalizedConfig, rootConfig: params.config, }), From 3ed569ac3c7adad2ad210c5e216771ab28920d45 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 09:09:17 +0100 Subject: [PATCH 093/107] fix(plugins): respect allowlist for web provider fallback --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 4 +- docs/gateway/configuration-reference.md | 4 ++ docs/tools/plugin.md | 7 +++ src/config/schema.base.generated.ts | 12 ++++ src/config/schema.labels.ts | 1 + src/plugins/activation-context.ts | 1 + ...provider-public-artifacts.fallback.test.ts | 58 +++++++++++++++++++ src/plugins/web-provider-public-artifacts.ts | 32 +++++++--- .../web-search-providers.runtime.test.ts | 57 ++++++++++++++++++ 10 files changed, 167 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0135a9efa..dbc9a44a6ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,7 @@ Docs: https://docs.openclaw.ai - Plugins/packages: reject blank `openclaw.runtimeExtensions` entries instead of silently ignoring them and falling back to inferred TypeScript runtime entries. Thanks @vincentkoc. - Doctor/plugins: remove stale managed npm plugin shadow entries from the managed package lock as well as `package.json` and `node_modules`, so future npm operations do not keep referencing repaired bundled-plugin shadows. Thanks @vincentkoc. - Plugins/runtime state: keep the key being registered when namespace eviction runs in the same millisecond as existing entries, so `register` and `registerIfAbsent` do not report success while evicting their own fresh value. Thanks @vincentkoc. +- Plugins/providers: add `plugins.bundledDiscovery: "allowlist"` so restrictive `plugins.allow` deployments can keep bundled provider and web-search provider discovery from auto-loading omitted bundled plugins. Thanks @dougbtv. - Control UI/Talk: make failed Talk startup errors dismissable and clear the stale Talk error state when dismissed, so missing realtime voice provider configuration does not leave a permanent chat banner. Fixes #77071. Thanks @ijoshdavis. - Control UI/Talk: stop and clear failed realtime Talk sessions when dismissing runtime error banners, so the next Talk click starts a fresh session instead of only stopping the stale one. Thanks @vincentkoc. - Control UI/Talk: retry from a failed realtime Talk session on the next Talk click instead of requiring a separate stale-session stop click first. Thanks @vincentkoc. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 2a1b748608a..28f30f0ee76 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -2c78fb7af01e2ee9e919be5ab7b675347b36cae1e347f97fd2640a6f7c72f3ac config-baseline.json -31ec333df9f8b92c7656ac7107cecd5860dd02e08f7e18c7c674dc47a8811baa config-baseline.core.json +ddea4f1ae40a4099baa9f216cdae69ac35a5e93aa254903227ce168e2fd5b8db config-baseline.json +b6b71095384ad98410bbfd520eebac43e244aeb47761c74325ff133be6ccd858 config-baseline.core.json cd7c0c7fb1435bc7e59099e9ac334462d5ad444016e9ab4512aae63a238f78dc config-baseline.channel.json 9832b30a696930a3da7efccf38073137571e1b66cae84e54d747b733fdafcc54 config-baseline.plugin.json diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 1afaa8d8581..b742b2550b7 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -166,6 +166,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and plugins: { enabled: true, allow: ["voice-call"], + bundledMode: "compat", deny: [], load: { paths: ["~/Projects/oss/voice-call-plugin"], @@ -187,6 +188,9 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and - Discovery accepts native OpenClaw plugins plus compatible Codex bundles and Claude bundles, including manifestless Claude default-layout bundles. - **Config changes require a gateway restart.** - `allow`: optional allowlist (only listed plugins load). `deny` wins. +- `bundledMode`: defaults to `"compat"` for legacy bundled provider activation. + Use `"respect-allow"` when a non-empty `plugins.allow` should also gate + bundled provider plugins, including web-search runtime providers. - `plugins.entries..apiKey`: plugin-level API key convenience field (when supported by the plugin). - `plugins.entries..env`: plugin-scoped env var map. - `plugins.entries..hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 54d924315a0..a80beba80a7 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -264,6 +264,7 @@ Looking for third-party plugins? See [Community Plugins](/plugins/community). | ---------------- | --------------------------------------------------------- | | `enabled` | Master toggle (default: `true`) | | `allow` | Plugin allowlist (optional) | +| `bundledMode` | Bundled plugin allowlist mode (`compat` by default) | | `deny` | Plugin denylist (optional; deny wins) | | `load.paths` | Extra plugin files/directories | | `slots` | Exclusive slot selectors (e.g. `memory`, `contextEngine`) | @@ -275,6 +276,12 @@ tool name. If a tool allowlist references plugin tools, add the owning plugin id to `plugins.allow` or remove `plugins.allow`; `openclaw doctor` warns about this shape. +`plugins.bundledMode` defaults to `"compat"` so older configs keep legacy +bundled provider behavior. Set it to `"respect-allow"` when a restrictive +`plugins.allow` inventory should also block omitted bundled provider plugins, +including runtime web-search provider discovery. An empty `plugins.allow` is +still treated as unset/open. + Config changes made through `/plugins enable` or `/plugins disable` trigger an in-process Gateway plugin reload. New agent turns rebuild their tool list from the refreshed plugin registry. Source-changing operations such as install, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index fccb1e06d4e..b8fc590c78e 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -24186,6 +24186,13 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: "Per-plugin settings keyed by plugin ID including enablement and plugin-specific runtime configuration payloads. Use this for scoped plugin tuning without changing global loader policy.", }, + bundledMode: { + type: "string", + enum: ["compat", "respect-allow"], + title: "Bundled Plugin Mode", + description: + 'Controls whether bundled plugins bypass plugins.allow on runtime discovery paths. "compat" (default) preserves legacy behavior where bundled provider plugins are force-loaded on every chat turn. "respect-allow" gates bundled plugins by the allowlist the same way third-party plugins are gated.', + }, }, additionalProperties: false, title: "Plugins", @@ -28865,6 +28872,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Optional allowlist of plugin IDs; when set, only listed plugins are eligible to load. Configured bundled chat channels can still activate their bundled plugin when the channel is explicitly enabled in config. Use this to enforce approved extension inventories in controlled environments.", tags: ["access"], }, + "plugins.bundledMode": { + label: "Bundled Plugin Mode", + help: 'Controls whether bundled plugins bypass plugins.allow on runtime discovery paths. "compat" (default) preserves legacy behavior where bundled provider plugins are force-loaded on every chat turn. "respect-allow" gates bundled plugins by the allowlist the same way third-party plugins are gated.', + tags: ["advanced"], + }, "plugins.deny": { label: "Plugin Denylist", help: "Optional denylist of plugin IDs that are blocked even if allowlists or paths include them. Use deny rules for emergency rollback and hard blocks on risky plugins.", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index ac38254f107..a3d9eaacd31 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -905,6 +905,7 @@ export const FIELD_LABELS: Record = { plugins: "Plugins", "plugins.enabled": "Enable Plugins", "plugins.allow": "Plugin Allowlist", + "plugins.bundledMode": "Bundled Plugin Mode", "plugins.deny": "Plugin Denylist", "plugins.load": "Plugin Loader", "plugins.load.paths": "Plugin Load Paths", diff --git a/src/plugins/activation-context.ts b/src/plugins/activation-context.ts index 5753174e772..bcace14890d 100644 --- a/src/plugins/activation-context.ts +++ b/src/plugins/activation-context.ts @@ -79,6 +79,7 @@ export function withActivatedPluginIds(params: { return params.config; } const originalAllow = params.config?.plugins?.allow ?? []; + // Empty allowlists are still open; respect-allow only stops compat from widening configured allowlists. const respectAllow = params.config?.plugins?.bundledMode === "respect-allow" && originalAllow.length > 0; const originalAllowSet = respectAllow ? new Set(originalAllow) : undefined; diff --git a/src/plugins/web-provider-public-artifacts.fallback.test.ts b/src/plugins/web-provider-public-artifacts.fallback.test.ts index d200de9fc46..91ab8f82aee 100644 --- a/src/plugins/web-provider-public-artifacts.fallback.test.ts +++ b/src/plugins/web-provider-public-artifacts.fallback.test.ts @@ -87,4 +87,62 @@ describe("web provider public artifact manifest fallback", () => { pluginId: "fallback-fetch", }); }); + + it("keeps explicit bundled web-search public artifact candidates inside respect-allow", () => { + mocks.resolveBundledExplicitWebSearchProvidersFromPublicArtifacts.mockImplementation( + (params: { onlyPluginIds: readonly string[] }) => + params.onlyPluginIds.map((pluginId) => ({ id: pluginId, pluginId })), + ); + + const providers = resolveBundledWebSearchProvidersFromPublicArtifacts({ + config: { + plugins: { + allow: ["fallback-search"], + bundledMode: "respect-allow", + }, + }, + onlyPluginIds: ["blocked-search", "fallback-search"], + }); + + expect(providers).toEqual([{ id: "fallback-search", pluginId: "fallback-search" }]); + expect(mocks.resolveBundledExplicitWebSearchProvidersFromPublicArtifacts).toHaveBeenCalledWith({ + onlyPluginIds: ["fallback-search"], + }); + }); + + it("keeps manifest bundled web-fetch public artifact candidates inside respect-allow", () => { + mocks.loadPluginMetadataSnapshot.mockReturnValueOnce({ + diagnostics: [], + plugins: [ + { + id: "blocked-fetch", + origin: "bundled", + rootDir: "/tmp/blocked-fetch", + contracts: { webFetchProviders: ["blocked-fetch"] }, + }, + { + id: "fallback-fetch", + origin: "bundled", + rootDir: "/tmp/fallback-fetch", + contracts: { webFetchProviders: ["fallback-fetch"] }, + }, + ], + }); + + const providers = resolveBundledWebFetchProvidersFromPublicArtifacts({ + config: { + plugins: { + allow: ["fallback-fetch"], + bundledMode: "respect-allow", + }, + }, + }); + + expect(providers).toEqual([{ id: "fallback-fetch", pluginId: "fallback-fetch" }]); + expect(mocks.loadBundledWebFetchProviderEntriesFromDir).toHaveBeenCalledOnce(); + expect(mocks.loadBundledWebFetchProviderEntriesFromDir).toHaveBeenCalledWith({ + dirName: "fallback-fetch", + pluginId: "fallback-fetch", + }); + }); }); diff --git a/src/plugins/web-provider-public-artifacts.ts b/src/plugins/web-provider-public-artifacts.ts index bcd89d29a0c..ed927e9f766 100644 --- a/src/plugins/web-provider-public-artifacts.ts +++ b/src/plugins/web-provider-public-artifacts.ts @@ -26,6 +26,22 @@ type BundledCandidateResolution = { manifestRecords?: readonly PluginManifestRecord[]; }; +function filterRespectAllowBundledPluginIds( + config: PluginLoadOptions["config"] | undefined, + pluginIds: readonly string[], +) { + const allow = config?.plugins?.allow; + if ( + config?.plugins?.bundledMode !== "respect-allow" || + !Array.isArray(allow) || + allow.length === 0 + ) { + return [...pluginIds]; + } + const allowedPluginIds = new Set(allow.map((pluginId) => pluginId.trim()).filter(Boolean)); + return pluginIds.filter((pluginId) => allowedPluginIds.has(pluginId)); +} + function resolveBundledCandidatePluginIds(params: { contract: "webSearchProviders" | "webFetchProviders"; configKey: "webSearch" | "webFetch"; @@ -35,17 +51,17 @@ function resolveBundledCandidatePluginIds(params: { bundledAllowlistCompat?: boolean; onlyPluginIds?: readonly string[]; }): BundledCandidateResolution { - if (params.onlyPluginIds && params.onlyPluginIds.length > 0) { - return { - pluginIds: [...new Set(params.onlyPluginIds)].toSorted((left, right) => - left.localeCompare(right), - ), - }; - } const resolvedConfig = params.contract === "webSearchProviders" ? resolveBundledWebSearchResolutionConfig(params).config : resolveBundledWebFetchResolutionConfig(params).config; + if (params.onlyPluginIds && params.onlyPluginIds.length > 0) { + return { + pluginIds: filterRespectAllowBundledPluginIds(resolvedConfig, [ + ...new Set(params.onlyPluginIds), + ]).toSorted((left, right) => left.localeCompare(right)), + }; + } const candidates = resolveManifestDeclaredWebProviderCandidates({ contract: params.contract, configKey: params.configKey, @@ -56,7 +72,7 @@ function resolveBundledCandidatePluginIds(params: { origin: "bundled", }); return { - pluginIds: candidates.pluginIds ?? [], + pluginIds: filterRespectAllowBundledPluginIds(resolvedConfig, candidates.pluginIds ?? []), ...(candidates.manifestRecords ? { manifestRecords: candidates.manifestRecords } : {}), }; } diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts index b537b330366..91abc6c6700 100644 --- a/src/plugins/web-search-providers.runtime.test.ts +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -182,6 +182,27 @@ function createManifestRegistryFixture(): PluginManifestRegistry { }; } +function createWebSearchManifestRecord(params: { + id: string; + providerId: string; +}): PluginManifestRegistry["plugins"][number] { + return { + id: params.id, + origin: "bundled", + rootDir: `/tmp/${params.id}`, + source: `/tmp/${params.id}/index.js`, + manifestPath: `/tmp/${params.id}/openclaw.plugin.json`, + channels: [], + providers: [], + cliBackends: [], + syntheticAuthRefs: [], + nonSecretAuthMarkers: [], + skills: [], + hooks: [], + contracts: { webSearchProviders: [params.providerId] }, + }; +} + function expectLoaderCallCount(count: number) { expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(count); } @@ -461,6 +482,42 @@ describe("resolvePluginWebSearchProviders", () => { expectScopedWebSearchCandidates(["brave"]); }); + it("keeps respect-allow web-search provider discovery scoped to the configured allowlist", () => { + loadInstalledPluginManifestRegistryMock.mockReturnValueOnce({ + plugins: [ + createWebSearchManifestRecord({ id: "brave", providerId: "brave" }), + createWebSearchManifestRecord({ id: "google", providerId: "gemini" }), + ], + diagnostics: [], + }); + + const providers = resolvePluginWebSearchProviders({ + config: { + plugins: { + allow: ["brave"], + bundledMode: "respect-allow", + }, + }, + bundledAllowlistCompat: true, + env: createWebSearchEnv(), + workspaceDir: DEFAULT_WEB_SEARCH_WORKSPACE, + }); + + expect(toRuntimeProviderKeys(providers)).toEqual(["brave:brave"]); + expectScopedWebSearchCandidates(["brave"]); + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + allow: ["brave"], + bundledMode: "respect-allow", + entries: { brave: { enabled: true } }, + }), + }), + }), + ); + }); + it("uses the active registry workspace for candidate discovery and snapshot loads when workspaceDir is omitted", () => { const env = createWebSearchEnv(); const rawConfig = createBraveAllowConfig(); From 76e0bcd2ded3ac3484a24d49466e3f84ee3afe4a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 09:11:41 +0100 Subject: [PATCH 094/107] test(plugins): type bundled public artifact mock --- .../web-provider-public-artifacts.fallback.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/plugins/web-provider-public-artifacts.fallback.test.ts b/src/plugins/web-provider-public-artifacts.fallback.test.ts index 91ab8f82aee..331cfa0ed3f 100644 --- a/src/plugins/web-provider-public-artifacts.fallback.test.ts +++ b/src/plugins/web-provider-public-artifacts.fallback.test.ts @@ -89,9 +89,16 @@ describe("web provider public artifact manifest fallback", () => { }); it("keeps explicit bundled web-search public artifact candidates inside respect-allow", () => { - mocks.resolveBundledExplicitWebSearchProvidersFromPublicArtifacts.mockImplementation( - (params: { onlyPluginIds: readonly string[] }) => - params.onlyPluginIds.map((pluginId) => ({ id: pluginId, pluginId })), + const resolveExplicitWebSearchProviders = + mocks.resolveBundledExplicitWebSearchProvidersFromPublicArtifacts as unknown as { + mockImplementation: ( + implementation: (params: { + onlyPluginIds: readonly string[]; + }) => { id: string; pluginId: string }[], + ) => void; + }; + resolveExplicitWebSearchProviders.mockImplementation((params) => + params.onlyPluginIds.map((pluginId) => ({ id: pluginId, pluginId })), ); const providers = resolveBundledWebSearchProvidersFromPublicArtifacts({ From 41257a5f6fdf9412bd490a3db2f12657fa9b965e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 09:42:37 +0100 Subject: [PATCH 095/107] fix(plugins): rename bundled allowlist discovery policy --- docs/.generated/config-baseline.sha256 | 4 +-- docs/gateway/configuration-reference.md | 6 ++-- docs/gateway/doctor.md | 4 ++- docs/tools/plugin.md | 22 +++++++------- ....providers.plugin-allowlist-compat.test.ts | 4 +-- src/commands/doctor-config-flow.ts | 13 ++++---- .../plugin-tool-allowlist-warnings.test.ts | 30 ++++++++++++++++++- .../shared/plugin-tool-allowlist-warnings.ts | 18 +++++++++++ src/config/schema.base.generated.ts | 14 ++++----- src/config/schema.help.ts | 4 +-- src/config/schema.labels.ts | 2 +- src/config/types.plugins.ts | 10 +++---- src/config/zod-schema.ts | 2 +- src/plugins/activation-context.ts | 8 ++--- src/plugins/bundled-compat.ts | 6 ++-- src/plugins/providers.test.ts | 4 +-- src/plugins/providers.ts | 4 +-- ...provider-public-artifacts.fallback.test.ts | 8 ++--- src/plugins/web-provider-public-artifacts.ts | 8 ++--- .../web-search-providers.runtime.test.ts | 6 ++-- 20 files changed, 114 insertions(+), 63 deletions(-) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 28f30f0ee76..e43f03ebeb0 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -ddea4f1ae40a4099baa9f216cdae69ac35a5e93aa254903227ce168e2fd5b8db config-baseline.json -b6b71095384ad98410bbfd520eebac43e244aeb47761c74325ff133be6ccd858 config-baseline.core.json +14558f9777b400fe4a1ef163a44e90ac0c59b56920ceb24b99675647d19d73a8 config-baseline.json +0c46cd7aeae83eb3afddd19209bf3520cecccc265903b2fe001ce458bc592ea5 config-baseline.core.json cd7c0c7fb1435bc7e59099e9ac334462d5ad444016e9ab4512aae63a238f78dc config-baseline.channel.json 9832b30a696930a3da7efccf38073137571e1b66cae84e54d747b733fdafcc54 config-baseline.plugin.json diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b742b2550b7..d9301b1a412 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -166,7 +166,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and plugins: { enabled: true, allow: ["voice-call"], - bundledMode: "compat", + bundledDiscovery: "compat", deny: [], load: { paths: ["~/Projects/oss/voice-call-plugin"], @@ -188,8 +188,8 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and - Discovery accepts native OpenClaw plugins plus compatible Codex bundles and Claude bundles, including manifestless Claude default-layout bundles. - **Config changes require a gateway restart.** - `allow`: optional allowlist (only listed plugins load). `deny` wins. -- `bundledMode`: defaults to `"compat"` for legacy bundled provider activation. - Use `"respect-allow"` when a non-empty `plugins.allow` should also gate +- `bundledDiscovery`: defaults to `"compat"` for legacy bundled provider activation. + Use `"allowlist"` when a non-empty `plugins.allow` should also gate bundled provider plugins, including web-search runtime providers. - `plugins.entries..apiKey`: plugin-level API key convenience field (when supported by the plugin). - `plugins.entries..env`: plugin-scoped env var map. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index aaf68a22176..c33f1000666 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -169,7 +169,9 @@ That stages grounded durable candidates into the short-term dreaming store while Doctor also warns when `plugins.allow` is non-empty and tool policy uses wildcard or plugin-owned tool entries. `tools.allow: ["*"]` only matches tools from plugins that actually load; it does not bypass the exclusive plugin - allowlist. + allowlist. If bundled provider discovery is still in legacy compatibility + mode, doctor also points to the stricter `plugins.bundledDiscovery: + "allowlist"` setting. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index a80beba80a7..cdef016d8c0 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -260,15 +260,15 @@ Looking for third-party plugins? See [Community Plugins](/plugins/community). } ``` -| Field | Description | -| ---------------- | --------------------------------------------------------- | -| `enabled` | Master toggle (default: `true`) | -| `allow` | Plugin allowlist (optional) | -| `bundledMode` | Bundled plugin allowlist mode (`compat` by default) | -| `deny` | Plugin denylist (optional; deny wins) | -| `load.paths` | Extra plugin files/directories | -| `slots` | Exclusive slot selectors (e.g. `memory`, `contextEngine`) | -| `entries.\` | Per-plugin toggles + config | +| Field | Description | +| ------------------ | --------------------------------------------------------- | +| `enabled` | Master toggle (default: `true`) | +| `allow` | Plugin allowlist (optional) | +| `bundledDiscovery` | Bundled plugin discovery mode (`compat` by default) | +| `deny` | Plugin denylist (optional; deny wins) | +| `load.paths` | Extra plugin files/directories | +| `slots` | Exclusive slot selectors (e.g. `memory`, `contextEngine`) | +| `entries.\` | Per-plugin toggles + config | `plugins.allow` is exclusive. When it is non-empty, only listed plugins can load or expose tools, even if `tools.allow` contains `"*"` or a specific plugin-owned @@ -276,8 +276,8 @@ tool name. If a tool allowlist references plugin tools, add the owning plugin id to `plugins.allow` or remove `plugins.allow`; `openclaw doctor` warns about this shape. -`plugins.bundledMode` defaults to `"compat"` so older configs keep legacy -bundled provider behavior. Set it to `"respect-allow"` when a restrictive +`plugins.bundledDiscovery` defaults to `"compat"` so older configs keep legacy +bundled provider behavior. Set it to `"allowlist"` when a restrictive `plugins.allow` inventory should also block omitted bundled provider plugins, including runtime web-search provider discovery. An empty `plugins.allow` is still treated as unset/open. diff --git a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts index 5b2f808be7b..aac1727d3d8 100644 --- a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts +++ b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts @@ -84,13 +84,13 @@ describe("implicit provider plugin allowlist compatibility", () => { ).toEqual(["kilocode", "moonshot", "openrouter"]); }); - it("respects allowlist for bundled plugins when bundledMode is respect-allow", () => { + it("respects allowlist for bundled plugins when bundledDiscovery is allowlist", () => { const config = withBundledPluginEnablementCompat({ config: withBundledPluginAllowlistCompat({ config: { plugins: { allow: ["openrouter"], - bundledMode: "respect-allow", + bundledDiscovery: "allowlist", }, }, pluginIds: ["kilocode", "moonshot"], diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 98443d2de10..8ae28bdd659 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -161,12 +161,15 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { })); } - const { collectPluginToolAllowlistWarnings } = + const { collectBundledProviderAllowlistPolicyWarnings, collectPluginToolAllowlistWarnings } = await import("./doctor/shared/plugin-tool-allowlist-warnings.js"); - const pluginToolAllowlistWarnings = collectPluginToolAllowlistWarnings({ - cfg: candidate, - env: process.env, - }); + const pluginToolAllowlistWarnings = [ + ...collectPluginToolAllowlistWarnings({ + cfg: candidate, + env: process.env, + }), + ...collectBundledProviderAllowlistPolicyWarnings({ cfg: candidate }), + ]; if (pluginToolAllowlistWarnings.length > 0) { note(sanitizeDoctorNote(pluginToolAllowlistWarnings.join("\n")), "Doctor warnings"); } diff --git a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts index 6f6375b870e..2dbb491a7e2 100644 --- a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts +++ b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import type { PluginManifestRegistry } from "../../../plugins/manifest-registry.js"; -import { collectPluginToolAllowlistWarnings } from "./plugin-tool-allowlist-warnings.js"; +import { + collectBundledProviderAllowlistPolicyWarnings, + collectPluginToolAllowlistWarnings, +} from "./plugin-tool-allowlist-warnings.js"; const manifestRegistry: PluginManifestRegistry = { diagnostics: [], @@ -109,4 +112,29 @@ describe("collectPluginToolAllowlistWarnings", () => { expect(warnings).toEqual([]); }); + + it("warns when restrictive plugins.allow leaves bundled provider discovery in compat mode", () => { + const warnings = collectBundledProviderAllowlistPolicyWarnings({ + cfg: { + plugins: { allow: ["telegram"] }, + }, + }); + + expect(warnings).toEqual([ + expect.stringContaining('set plugins.bundledDiscovery to "allowlist"'), + ]); + }); + + it("does not warn when bundled provider discovery follows the allowlist", () => { + const warnings = collectBundledProviderAllowlistPolicyWarnings({ + cfg: { + plugins: { + allow: ["telegram"], + bundledDiscovery: "allowlist", + }, + }, + }); + + expect(warnings).toEqual([]); + }); }); diff --git a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts index 500cd9cabdb..725429406d9 100644 --- a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts +++ b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts @@ -193,3 +193,21 @@ export function collectPluginToolAllowlistWarnings(params: { return warnings; } + +export function collectBundledProviderAllowlistPolicyWarnings(params: { + cfg: OpenClawConfig; +}): string[] { + if (params.cfg.plugins?.enabled === false) { + return []; + } + const allow = params.cfg.plugins?.allow; + if (!Array.isArray(allow) || allow.length === 0) { + return []; + } + if (params.cfg.plugins?.bundledDiscovery === "allowlist") { + return []; + } + return [ + '- plugins.allow is restrictive, but bundled provider discovery is still in legacy compatibility mode. Bundled provider plugins can still appear in runtime provider inventories; set plugins.bundledDiscovery to "allowlist" after confirming omitted bundled providers are intentionally blocked.', + ]; +} diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index b8fc590c78e..db0ae151a10 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -24186,12 +24186,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: "Per-plugin settings keyed by plugin ID including enablement and plugin-specific runtime configuration payloads. Use this for scoped plugin tuning without changing global loader policy.", }, - bundledMode: { + bundledDiscovery: { type: "string", - enum: ["compat", "respect-allow"], - title: "Bundled Plugin Mode", + enum: ["compat", "allowlist"], + title: "Bundled Plugin Discovery", description: - 'Controls whether bundled plugins bypass plugins.allow on runtime discovery paths. "compat" (default) preserves legacy behavior where bundled provider plugins are force-loaded on every chat turn. "respect-allow" gates bundled plugins by the allowlist the same way third-party plugins are gated.', + 'Controls bundled plugin runtime discovery when plugins.allow is configured. "compat" (default) preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn. "allowlist" gates bundled provider plugins by plugins.allow like third-party plugins.', }, }, additionalProperties: false, @@ -28872,9 +28872,9 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Optional allowlist of plugin IDs; when set, only listed plugins are eligible to load. Configured bundled chat channels can still activate their bundled plugin when the channel is explicitly enabled in config. Use this to enforce approved extension inventories in controlled environments.", tags: ["access"], }, - "plugins.bundledMode": { - label: "Bundled Plugin Mode", - help: 'Controls whether bundled plugins bypass plugins.allow on runtime discovery paths. "compat" (default) preserves legacy behavior where bundled provider plugins are force-loaded on every chat turn. "respect-allow" gates bundled plugins by the allowlist the same way third-party plugins are gated.', + "plugins.bundledDiscovery": { + label: "Bundled Plugin Discovery", + help: 'Controls bundled plugin runtime discovery when plugins.allow is configured. "compat" (default) preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn. "allowlist" gates bundled provider plugins by plugins.allow like third-party plugins.', tags: ["advanced"], }, "plugins.deny": { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 4b6c9642e0e..07562e0892f 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1212,8 +1212,8 @@ export const FIELD_HELP: Record = { 'Select the active memory plugin by id, or "none" to disable memory plugins.', "plugins.slots.contextEngine": "Selects the active context engine plugin by id so one plugin provides context orchestration behavior.", - "plugins.bundledMode": - 'Controls whether bundled plugins bypass plugins.allow on runtime discovery paths. "compat" (default) preserves legacy behavior where bundled provider plugins are force-loaded on every chat turn. "respect-allow" gates bundled plugins by the allowlist the same way third-party plugins are gated.', + "plugins.bundledDiscovery": + 'Controls bundled plugin runtime discovery when plugins.allow is configured. "compat" (default) preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn. "allowlist" gates bundled provider plugins by plugins.allow like third-party plugins.', "plugins.entries": "Per-plugin settings keyed by plugin ID including enablement and plugin-specific runtime configuration payloads. Use this for scoped plugin tuning without changing global loader policy.", "plugins.entries.*.enabled": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index a3d9eaacd31..641c3a9847d 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -905,7 +905,7 @@ export const FIELD_LABELS: Record = { plugins: "Plugins", "plugins.enabled": "Enable Plugins", "plugins.allow": "Plugin Allowlist", - "plugins.bundledMode": "Bundled Plugin Mode", + "plugins.bundledDiscovery": "Bundled Plugin Discovery", "plugins.deny": "Plugin Denylist", "plugins.load": "Plugin Loader", "plugins.load.paths": "Plugin Load Paths", diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index eb44e59d0fc..c3c9d67d978 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -52,15 +52,15 @@ export type PluginsConfig = { /** Optional plugin denylist (plugin ids). */ deny?: string[]; /** - * Controls whether bundled plugins bypass `allow` / `entries` on runtime - * provider discovery paths. + * Controls how bundled plugins participate in runtime provider discovery when + * `allow` is configured. * * - `"compat"` (default): bundled provider plugins are force-loaded on * every chat turn regardless of the allowlist (legacy behavior). - * - `"respect-allow"`: bundled provider plugins are gated by `allow` and - * `entries..enabled` the same way third-party plugins are. + * - `"allowlist"`: bundled provider plugins are gated by `allow` and + * `entries..enabled` like third-party plugins. */ - bundledMode?: "compat" | "respect-allow"; + bundledDiscovery?: "compat" | "allowlist"; load?: PluginsLoadConfig; slots?: PluginSlotsConfig; entries?: Record; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index af5a330d75d..9de258fe3f6 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -1073,7 +1073,7 @@ export const OpenClawSchema = z .strict() .optional(), entries: z.record(z.string(), PluginEntrySchema).optional(), - bundledMode: z.enum(["compat", "respect-allow"]).optional(), + bundledDiscovery: z.enum(["compat", "allowlist"]).optional(), }) .strict() .optional(), diff --git a/src/plugins/activation-context.ts b/src/plugins/activation-context.ts index bcace14890d..6f6a516ef84 100644 --- a/src/plugins/activation-context.ts +++ b/src/plugins/activation-context.ts @@ -79,10 +79,10 @@ export function withActivatedPluginIds(params: { return params.config; } const originalAllow = params.config?.plugins?.allow ?? []; - // Empty allowlists are still open; respect-allow only stops compat from widening configured allowlists. - const respectAllow = - params.config?.plugins?.bundledMode === "respect-allow" && originalAllow.length > 0; - const originalAllowSet = respectAllow ? new Set(originalAllow) : undefined; + // Empty allowlists are still open; allowlist mode only stops compat from widening configured allowlists. + const useAllowlistDiscovery = + params.config?.plugins?.bundledDiscovery === "allowlist" && originalAllow.length > 0; + const originalAllowSet = useAllowlistDiscovery ? new Set(originalAllow) : undefined; const allow = new Set(originalAllow); const entries = { ...params.config?.plugins?.entries, diff --git a/src/plugins/bundled-compat.ts b/src/plugins/bundled-compat.ts index 33cdecb2700..f1757864cb3 100644 --- a/src/plugins/bundled-compat.ts +++ b/src/plugins/bundled-compat.ts @@ -6,7 +6,7 @@ export function withBundledPluginAllowlistCompat(params: { config: OpenClawConfig | undefined; pluginIds: readonly string[]; }): OpenClawConfig | undefined { - if (params.config?.plugins?.bundledMode === "respect-allow") { + if (params.config?.plugins?.bundledDiscovery === "allowlist") { return params.config; } const allow = params.config?.plugins?.allow; @@ -42,8 +42,8 @@ export function withBundledPluginEnablementCompat(params: { }): OpenClawConfig | undefined { const existingEntries = params.config?.plugins?.entries ?? {}; const forcePluginsEnabled = params.config?.plugins?.enabled === false; - const respectAllow = params.config?.plugins?.bundledMode === "respect-allow"; - const allowSet = respectAllow ? new Set(params.config?.plugins?.allow ?? []) : undefined; + const useAllowlistDiscovery = params.config?.plugins?.bundledDiscovery === "allowlist"; + const allowSet = useAllowlistDiscovery ? new Set(params.config?.plugins?.allow ?? []) : undefined; let changed = false; const nextEntries: Record = { ...existingEntries }; diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 4adb7d30366..3d678a7a02e 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -593,7 +593,7 @@ describe("resolvePluginProviders", () => { ).toEqual(["legacy-auth-owner"]); }); - it("filters bundled provider plugins by allowlist when bundledMode is respect-allow", () => { + it("filters bundled provider plugins by allowlist when bundledDiscovery is allowlist", () => { setManifestPlugins([ createManifestProviderPlugin({ id: "kilocode", @@ -619,7 +619,7 @@ describe("resolvePluginProviders", () => { config: { plugins: { allow: ["openrouter"], - bundledMode: "respect-allow", + bundledDiscovery: "allowlist", }, }, env: {} as NodeJS.ProcessEnv, diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index d21282ef8a2..68e9fff84d8 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -255,7 +255,7 @@ export function resolveDiscoveredProviderPluginIds(params: { const { registry, onlyPluginIdSet } = loadScopedProviderRegistry(params); const providerSurfacePluginIds = resolveProviderSurfacePluginIdSet({ ...params, registry }); const shouldFilterUntrustedWorkspacePlugins = params.includeUntrustedWorkspacePlugins === false; - const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledMode === "respect-allow"; + const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledDiscovery === "allowlist"; const normalizedConfig = normalizePluginsConfigWithRegistry(params.config?.plugins, registry); return listRegistryPluginIds(registry, (plugin) => { if ( @@ -313,7 +313,7 @@ export function resolveDiscoverableProviderOwnerPluginIds(params: { includeUntrustedWorkspacePlugins?: boolean; }): string[] { const shouldFilterUntrustedWorkspacePlugins = params.includeUntrustedWorkspacePlugins === false; - const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledMode === "respect-allow"; + const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledDiscovery === "allowlist"; return resolveProviderOwnerPluginIds({ ...params, isEligible: (plugin, normalizedConfig) => diff --git a/src/plugins/web-provider-public-artifacts.fallback.test.ts b/src/plugins/web-provider-public-artifacts.fallback.test.ts index 331cfa0ed3f..81f8b3c1308 100644 --- a/src/plugins/web-provider-public-artifacts.fallback.test.ts +++ b/src/plugins/web-provider-public-artifacts.fallback.test.ts @@ -88,7 +88,7 @@ describe("web provider public artifact manifest fallback", () => { }); }); - it("keeps explicit bundled web-search public artifact candidates inside respect-allow", () => { + it("keeps explicit bundled web-search public artifact candidates inside allowlist discovery", () => { const resolveExplicitWebSearchProviders = mocks.resolveBundledExplicitWebSearchProvidersFromPublicArtifacts as unknown as { mockImplementation: ( @@ -105,7 +105,7 @@ describe("web provider public artifact manifest fallback", () => { config: { plugins: { allow: ["fallback-search"], - bundledMode: "respect-allow", + bundledDiscovery: "allowlist", }, }, onlyPluginIds: ["blocked-search", "fallback-search"], @@ -117,7 +117,7 @@ describe("web provider public artifact manifest fallback", () => { }); }); - it("keeps manifest bundled web-fetch public artifact candidates inside respect-allow", () => { + it("keeps manifest bundled web-fetch public artifact candidates inside allowlist discovery", () => { mocks.loadPluginMetadataSnapshot.mockReturnValueOnce({ diagnostics: [], plugins: [ @@ -140,7 +140,7 @@ describe("web provider public artifact manifest fallback", () => { config: { plugins: { allow: ["fallback-fetch"], - bundledMode: "respect-allow", + bundledDiscovery: "allowlist", }, }, }); diff --git a/src/plugins/web-provider-public-artifacts.ts b/src/plugins/web-provider-public-artifacts.ts index ed927e9f766..cec130ae0fe 100644 --- a/src/plugins/web-provider-public-artifacts.ts +++ b/src/plugins/web-provider-public-artifacts.ts @@ -26,13 +26,13 @@ type BundledCandidateResolution = { manifestRecords?: readonly PluginManifestRecord[]; }; -function filterRespectAllowBundledPluginIds( +function filterAllowlistedBundledPluginIds( config: PluginLoadOptions["config"] | undefined, pluginIds: readonly string[], ) { const allow = config?.plugins?.allow; if ( - config?.plugins?.bundledMode !== "respect-allow" || + config?.plugins?.bundledDiscovery !== "allowlist" || !Array.isArray(allow) || allow.length === 0 ) { @@ -57,7 +57,7 @@ function resolveBundledCandidatePluginIds(params: { : resolveBundledWebFetchResolutionConfig(params).config; if (params.onlyPluginIds && params.onlyPluginIds.length > 0) { return { - pluginIds: filterRespectAllowBundledPluginIds(resolvedConfig, [ + pluginIds: filterAllowlistedBundledPluginIds(resolvedConfig, [ ...new Set(params.onlyPluginIds), ]).toSorted((left, right) => left.localeCompare(right)), }; @@ -72,7 +72,7 @@ function resolveBundledCandidatePluginIds(params: { origin: "bundled", }); return { - pluginIds: filterRespectAllowBundledPluginIds(resolvedConfig, candidates.pluginIds ?? []), + pluginIds: filterAllowlistedBundledPluginIds(resolvedConfig, candidates.pluginIds ?? []), ...(candidates.manifestRecords ? { manifestRecords: candidates.manifestRecords } : {}), }; } diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts index 91abc6c6700..1dd2da06136 100644 --- a/src/plugins/web-search-providers.runtime.test.ts +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -482,7 +482,7 @@ describe("resolvePluginWebSearchProviders", () => { expectScopedWebSearchCandidates(["brave"]); }); - it("keeps respect-allow web-search provider discovery scoped to the configured allowlist", () => { + it("keeps allowlist web-search provider discovery scoped to the configured allowlist", () => { loadInstalledPluginManifestRegistryMock.mockReturnValueOnce({ plugins: [ createWebSearchManifestRecord({ id: "brave", providerId: "brave" }), @@ -495,7 +495,7 @@ describe("resolvePluginWebSearchProviders", () => { config: { plugins: { allow: ["brave"], - bundledMode: "respect-allow", + bundledDiscovery: "allowlist", }, }, bundledAllowlistCompat: true, @@ -510,7 +510,7 @@ describe("resolvePluginWebSearchProviders", () => { config: expect.objectContaining({ plugins: expect.objectContaining({ allow: ["brave"], - bundledMode: "respect-allow", + bundledDiscovery: "allowlist", entries: { brave: { enabled: true } }, }), }), From 55df2d4598b1acac7136897b75d1a4b13d666364 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 22:23:40 +0100 Subject: [PATCH 096/107] docs(config): refresh bundled discovery baseline --- docs/.generated/config-baseline.sha256 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index e43f03ebeb0..d9affa3c7b4 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -14558f9777b400fe4a1ef163a44e90ac0c59b56920ceb24b99675647d19d73a8 config-baseline.json -0c46cd7aeae83eb3afddd19209bf3520cecccc265903b2fe001ce458bc592ea5 config-baseline.core.json +0fc1b2f75e34ab92274067cdd6bfcfeac01c5b898a3aa355cfa1c3a5ec18bd5d config-baseline.json +0a6a2493d90ffe6204be807a8bc12dcf34f854602cc1c5e3c4917fed902d310e config-baseline.core.json cd7c0c7fb1435bc7e59099e9ac334462d5ad444016e9ab4512aae63a238f78dc config-baseline.channel.json 9832b30a696930a3da7efccf38073137571e1b66cae84e54d747b733fdafcc54 config-baseline.plugin.json From b2096d19ecde18efe98715e8c091e3d665ef508e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 22:46:39 +0100 Subject: [PATCH 097/107] fix(plugins): default bundled discovery to allowlist --- CHANGELOG.md | 2 +- docs/.generated/config-baseline.sha256 | 4 +- docs/gateway/configuration-reference.md | 9 +-- docs/gateway/doctor.md | 6 +- docs/tools/plugin.md | 13 ++-- ....providers.plugin-allowlist-compat.test.ts | 29 +++++++- .../shared/legacy-config-migrate.test.ts | 27 ++++++++ ...acy-config-migrations.runtime.providers.ts | 34 ++++++++++ .../plugin-tool-allowlist-warnings.test.ts | 31 +++++---- .../shared/plugin-tool-allowlist-warnings.ts | 2 +- src/config/schema.base.generated.ts | 4 +- src/config/schema.help.ts | 2 +- src/config/types.plugins.ts | 8 +-- src/plugins/activation-context.ts | 4 +- src/plugins/bundled-compat.ts | 8 ++- src/plugins/providers.test.ts | 68 +++++++++++-------- src/plugins/providers.ts | 7 +- src/plugins/web-provider-public-artifacts.ts | 2 +- .../web-search-providers.runtime.test.ts | 2 +- 19 files changed, 185 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbc9a44a6ce..b1143d810d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,7 +121,7 @@ Docs: https://docs.openclaw.ai - Plugins/packages: reject blank `openclaw.runtimeExtensions` entries instead of silently ignoring them and falling back to inferred TypeScript runtime entries. Thanks @vincentkoc. - Doctor/plugins: remove stale managed npm plugin shadow entries from the managed package lock as well as `package.json` and `node_modules`, so future npm operations do not keep referencing repaired bundled-plugin shadows. Thanks @vincentkoc. - Plugins/runtime state: keep the key being registered when namespace eviction runs in the same millisecond as existing entries, so `register` and `registerIfAbsent` do not report success while evicting their own fresh value. Thanks @vincentkoc. -- Plugins/providers: add `plugins.bundledDiscovery: "allowlist"` so restrictive `plugins.allow` deployments can keep bundled provider and web-search provider discovery from auto-loading omitted bundled plugins. Thanks @dougbtv. +- Plugins/providers: make bundled provider discovery honor restrictive `plugins.allow` by default for new configs, while doctor migrates legacy restrictive allowlist configs to `plugins.bundledDiscovery: "compat"` to preserve upgrade behavior. Thanks @dougbtv. - Control UI/Talk: make failed Talk startup errors dismissable and clear the stale Talk error state when dismissed, so missing realtime voice provider configuration does not leave a permanent chat banner. Fixes #77071. Thanks @ijoshdavis. - Control UI/Talk: stop and clear failed realtime Talk sessions when dismissing runtime error banners, so the next Talk click starts a fresh session instead of only stopping the stale one. Thanks @vincentkoc. - Control UI/Talk: retry from a failed realtime Talk session on the next Talk click instead of requiring a separate stale-session stop click first. Thanks @vincentkoc. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index d9affa3c7b4..e0a057e0e3d 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -0fc1b2f75e34ab92274067cdd6bfcfeac01c5b898a3aa355cfa1c3a5ec18bd5d config-baseline.json -0a6a2493d90ffe6204be807a8bc12dcf34f854602cc1c5e3c4917fed902d310e config-baseline.core.json +02987f4cecb64a98170b61c925fd7b16a22b276abfb261f9281b42f613ded923 config-baseline.json +de5a6f65ef09dc23453a2e12512e41c133c941519e0ebef7f2946e4a24265d17 config-baseline.core.json cd7c0c7fb1435bc7e59099e9ac334462d5ad444016e9ab4512aae63a238f78dc config-baseline.channel.json 9832b30a696930a3da7efccf38073137571e1b66cae84e54d747b733fdafcc54 config-baseline.plugin.json diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index d9301b1a412..789bc97c3e9 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -166,7 +166,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and plugins: { enabled: true, allow: ["voice-call"], - bundledDiscovery: "compat", + bundledDiscovery: "allowlist", deny: [], load: { paths: ["~/Projects/oss/voice-call-plugin"], @@ -188,9 +188,10 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and - Discovery accepts native OpenClaw plugins plus compatible Codex bundles and Claude bundles, including manifestless Claude default-layout bundles. - **Config changes require a gateway restart.** - `allow`: optional allowlist (only listed plugins load). `deny` wins. -- `bundledDiscovery`: defaults to `"compat"` for legacy bundled provider activation. - Use `"allowlist"` when a non-empty `plugins.allow` should also gate - bundled provider plugins, including web-search runtime providers. +- `bundledDiscovery`: defaults to `"allowlist"` for new configs, so a non-empty + `plugins.allow` also gates bundled provider plugins, including web-search + runtime providers. Doctor writes `"compat"` for migrated legacy allowlist + configs to preserve existing bundled provider behavior until you opt in. - `plugins.entries..apiKey`: plugin-level API key convenience field (when supported by the plugin). - `plugins.entries..env`: plugin-scoped env var map. - `plugins.entries..hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index c33f1000666..35389f89d9a 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -169,9 +169,9 @@ That stages grounded durable candidates into the short-term dreaming store while Doctor also warns when `plugins.allow` is non-empty and tool policy uses wildcard or plugin-owned tool entries. `tools.allow: ["*"]` only matches tools from plugins that actually load; it does not bypass the exclusive plugin - allowlist. If bundled provider discovery is still in legacy compatibility - mode, doctor also points to the stricter `plugins.bundledDiscovery: - "allowlist"` setting. + allowlist. Doctor writes `plugins.bundledDiscovery: "compat"` for migrated + legacy allowlist configs to preserve existing bundled provider behavior, and + then points to the stricter `"allowlist"` setting. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index cdef016d8c0..007bc7cf232 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -264,7 +264,7 @@ Looking for third-party plugins? See [Community Plugins](/plugins/community). | ------------------ | --------------------------------------------------------- | | `enabled` | Master toggle (default: `true`) | | `allow` | Plugin allowlist (optional) | -| `bundledDiscovery` | Bundled plugin discovery mode (`compat` by default) | +| `bundledDiscovery` | Bundled plugin discovery mode (`allowlist` by default) | | `deny` | Plugin denylist (optional; deny wins) | | `load.paths` | Extra plugin files/directories | | `slots` | Exclusive slot selectors (e.g. `memory`, `contextEngine`) | @@ -276,11 +276,12 @@ tool name. If a tool allowlist references plugin tools, add the owning plugin id to `plugins.allow` or remove `plugins.allow`; `openclaw doctor` warns about this shape. -`plugins.bundledDiscovery` defaults to `"compat"` so older configs keep legacy -bundled provider behavior. Set it to `"allowlist"` when a restrictive -`plugins.allow` inventory should also block omitted bundled provider plugins, -including runtime web-search provider discovery. An empty `plugins.allow` is -still treated as unset/open. +`plugins.bundledDiscovery` defaults to `"allowlist"` for new configs, so a +restrictive `plugins.allow` inventory also blocks omitted bundled provider +plugins, including runtime web-search provider discovery. Doctor stamps older +restrictive allowlist configs with `"compat"` during migration so upgrades keep +legacy bundled provider behavior until the operator opts into the stricter mode. +An empty `plugins.allow` is still treated as unset/open. Config changes made through `/plugins enable` or `/plugins disable` trigger an in-process Gateway plugin reload. New agent turns rebuild their tool list from diff --git a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts index aac1727d3d8..bd6070e98bc 100644 --- a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts +++ b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts @@ -61,7 +61,31 @@ const providerManifestRegistry: PluginManifestRegistry = { }; describe("implicit provider plugin allowlist compatibility", () => { - it("keeps bundled implicit providers discoverable when plugins.allow is set", () => { + it("keeps bundled implicit providers discoverable in explicit compat mode", () => { + const config = withBundledPluginEnablementCompat({ + config: withBundledPluginAllowlistCompat({ + config: { + plugins: { + allow: ["openrouter"], + bundledDiscovery: "compat", + }, + }, + pluginIds: ["kilocode", "moonshot"], + }), + pluginIds: ["kilocode", "moonshot"], + }); + + expect( + resolveEnabledProviderPluginIds({ + config, + registry: providerRegistry, + manifestRegistry: providerManifestRegistry, + onlyPluginIds: PROVIDER_PLUGIN_IDS, + }), + ).toEqual(["kilocode", "moonshot", "openrouter"]); + }); + + it("respects allowlist for bundled plugins by default", () => { const config = withBundledPluginEnablementCompat({ config: withBundledPluginAllowlistCompat({ config: { @@ -81,7 +105,7 @@ describe("implicit provider plugin allowlist compatibility", () => { manifestRegistry: providerManifestRegistry, onlyPluginIds: PROVIDER_PLUGIN_IDS, }), - ).toEqual(["kilocode", "moonshot", "openrouter"]); + ).toEqual(["openrouter"]); }); it("respects allowlist for bundled plugins when bundledDiscovery is allowlist", () => { @@ -114,6 +138,7 @@ describe("implicit provider plugin allowlist compatibility", () => { config: { plugins: { allow: ["openrouter"], + bundledDiscovery: "compat", deny: ["kilocode"], }, }, diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 2497f26a455..6d008e3be03 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -267,6 +267,33 @@ describe("legacy migrate mention routing", () => { }); }); +describe("legacy bundled provider discovery migrate", () => { + it("sets compat mode for existing restrictive plugin allowlists", () => { + const res = migrateLegacyConfigForTest({ + plugins: { + allow: ["telegram"], + }, + }); + + expect(res.config?.plugins?.bundledDiscovery).toBe("compat"); + expect(res.changes).toContain( + 'Set plugins.bundledDiscovery="compat" to preserve legacy bundled provider discovery for this restrictive plugins.allow config.', + ); + }); + + it("does not override explicit bundled discovery mode", () => { + const res = migrateLegacyConfigForTest({ + plugins: { + allow: ["telegram"], + bundledDiscovery: "allowlist", + }, + }); + + expect(res.config).toBeNull(); + expect(res.changes).toEqual([]); + }); +}); + describe("legacy migrate sandbox scope aliases", () => { it("removes legacy agents.defaults.llm timeout config", () => { const res = migrateLegacyConfigForTest({ diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts index 89e26d797e0..bce98e874ac 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts @@ -3,6 +3,7 @@ import { type LegacyConfigMigrationSpec, type LegacyConfigRule, } from "../../../config/legacy.shared.js"; +import { isRecord } from "./legacy-config-record-shared.js"; import { migrateLegacyXSearchConfig } from "./legacy-x-search-migrate.js"; const X_SEARCH_RULE: LegacyConfigRule = { @@ -11,7 +12,40 @@ const X_SEARCH_RULE: LegacyConfigRule = { 'tools.web.x_search.apiKey moved to the xAI plugin; use plugins.entries.xai.config.webSearch.apiKey instead. Run "openclaw doctor --fix".', }; +const BUNDLED_DISCOVERY_COMPAT_RULE: LegacyConfigRule = { + path: ["plugins", "allow"], + message: + 'plugins.allow now gates bundled provider discovery by default; run "openclaw doctor --fix" to preserve legacy bundled provider compatibility as plugins.bundledDiscovery="compat", or set plugins.bundledDiscovery="allowlist" to keep the stricter behavior.', + requireSourceLiteral: true, + match: (value, root) => { + if (!Array.isArray(value) || value.length === 0) { + return false; + } + const plugins = isRecord(root.plugins) ? root.plugins : undefined; + return plugins?.bundledDiscovery === undefined; + }, +}; + export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "plugins.allow->plugins.bundledDiscovery.compat", + describe: "Preserve legacy bundled provider discovery for existing restrictive allowlists", + legacyRules: [BUNDLED_DISCOVERY_COMPAT_RULE], + apply: (raw, changes) => { + const plugins = isRecord(raw.plugins) ? raw.plugins : undefined; + if (!plugins || plugins.bundledDiscovery !== undefined) { + return; + } + const allow = plugins.allow; + if (!Array.isArray(allow) || allow.length === 0) { + return; + } + plugins.bundledDiscovery = "compat"; + changes.push( + 'Set plugins.bundledDiscovery="compat" to preserve legacy bundled provider discovery for this restrictive plugins.allow config.', + ); + }, + }), defineLegacyConfigMigration({ id: "tools.web.x_search.apiKey->plugins.entries.xai.config.webSearch.apiKey", describe: "Move legacy x_search auth into the xAI plugin webSearch config", diff --git a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts index 2dbb491a7e2..981b21079f1 100644 --- a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts +++ b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts @@ -113,10 +113,13 @@ describe("collectPluginToolAllowlistWarnings", () => { expect(warnings).toEqual([]); }); - it("warns when restrictive plugins.allow leaves bundled provider discovery in compat mode", () => { + it("warns when restrictive plugins.allow leaves bundled provider discovery in explicit compat mode", () => { const warnings = collectBundledProviderAllowlistPolicyWarnings({ cfg: { - plugins: { allow: ["telegram"] }, + plugins: { + allow: ["telegram"], + bundledDiscovery: "compat", + }, }, }); @@ -125,16 +128,18 @@ describe("collectPluginToolAllowlistWarnings", () => { ]); }); - it("does not warn when bundled provider discovery follows the allowlist", () => { - const warnings = collectBundledProviderAllowlistPolicyWarnings({ - cfg: { - plugins: { - allow: ["telegram"], - bundledDiscovery: "allowlist", - }, - }, - }); + it.each([ + { name: "default", plugins: { allow: ["telegram"] } }, + { + name: "explicit allowlist", + plugins: { allow: ["telegram"], bundledDiscovery: "allowlist" }, + }, + ])( + "does not warn when bundled provider discovery follows the allowlist ($name)", + ({ plugins }) => { + const warnings = collectBundledProviderAllowlistPolicyWarnings({ cfg: { plugins } }); - expect(warnings).toEqual([]); - }); + expect(warnings).toEqual([]); + }, + ); }); diff --git a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts index 725429406d9..3dfd6b4188f 100644 --- a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts +++ b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts @@ -204,7 +204,7 @@ export function collectBundledProviderAllowlistPolicyWarnings(params: { if (!Array.isArray(allow) || allow.length === 0) { return []; } - if (params.cfg.plugins?.bundledDiscovery === "allowlist") { + if (params.cfg.plugins?.bundledDiscovery !== "compat") { return []; } return [ diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index db0ae151a10..940b6e97094 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -24191,7 +24191,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { enum: ["compat", "allowlist"], title: "Bundled Plugin Discovery", description: - 'Controls bundled plugin runtime discovery when plugins.allow is configured. "compat" (default) preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn. "allowlist" gates bundled provider plugins by plugins.allow like third-party plugins.', + 'Controls bundled plugin runtime discovery when plugins.allow is configured. "allowlist" (default) gates bundled provider plugins by plugins.allow like third-party plugins. "compat" preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn.', }, }, additionalProperties: false, @@ -28874,7 +28874,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, "plugins.bundledDiscovery": { label: "Bundled Plugin Discovery", - help: 'Controls bundled plugin runtime discovery when plugins.allow is configured. "compat" (default) preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn. "allowlist" gates bundled provider plugins by plugins.allow like third-party plugins.', + help: 'Controls bundled plugin runtime discovery when plugins.allow is configured. "allowlist" (default) gates bundled provider plugins by plugins.allow like third-party plugins. "compat" preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn.', tags: ["advanced"], }, "plugins.deny": { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 07562e0892f..78410289d5a 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1213,7 +1213,7 @@ export const FIELD_HELP: Record = { "plugins.slots.contextEngine": "Selects the active context engine plugin by id so one plugin provides context orchestration behavior.", "plugins.bundledDiscovery": - 'Controls bundled plugin runtime discovery when plugins.allow is configured. "compat" (default) preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn. "allowlist" gates bundled provider plugins by plugins.allow like third-party plugins.', + 'Controls bundled plugin runtime discovery when plugins.allow is configured. "allowlist" (default) gates bundled provider plugins by plugins.allow like third-party plugins. "compat" preserves legacy behavior where bundled provider plugins can be force-loaded on every chat turn.', "plugins.entries": "Per-plugin settings keyed by plugin ID including enablement and plugin-specific runtime configuration payloads. Use this for scoped plugin tuning without changing global loader policy.", "plugins.entries.*.enabled": diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index c3c9d67d978..6025a11b44f 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -55,10 +55,10 @@ export type PluginsConfig = { * Controls how bundled plugins participate in runtime provider discovery when * `allow` is configured. * - * - `"compat"` (default): bundled provider plugins are force-loaded on - * every chat turn regardless of the allowlist (legacy behavior). - * - `"allowlist"`: bundled provider plugins are gated by `allow` and - * `entries..enabled` like third-party plugins. + * - `"allowlist"` (default): bundled provider plugins are gated by `allow` + * and `entries..enabled` like third-party plugins. + * - `"compat"`: legacy mode for migrated configs; bundled provider plugins + * can be force-loaded regardless of the allowlist. */ bundledDiscovery?: "compat" | "allowlist"; load?: PluginsLoadConfig; diff --git a/src/plugins/activation-context.ts b/src/plugins/activation-context.ts index 6f6a516ef84..851a390f2bc 100644 --- a/src/plugins/activation-context.ts +++ b/src/plugins/activation-context.ts @@ -79,9 +79,9 @@ export function withActivatedPluginIds(params: { return params.config; } const originalAllow = params.config?.plugins?.allow ?? []; - // Empty allowlists are still open; allowlist mode only stops compat from widening configured allowlists. + // Empty allowlists are still open; only explicit compat widens configured allowlists. const useAllowlistDiscovery = - params.config?.plugins?.bundledDiscovery === "allowlist" && originalAllow.length > 0; + params.config?.plugins?.bundledDiscovery !== "compat" && originalAllow.length > 0; const originalAllowSet = useAllowlistDiscovery ? new Set(originalAllow) : undefined; const allow = new Set(originalAllow); const entries = { diff --git a/src/plugins/bundled-compat.ts b/src/plugins/bundled-compat.ts index f1757864cb3..24fa7129038 100644 --- a/src/plugins/bundled-compat.ts +++ b/src/plugins/bundled-compat.ts @@ -6,7 +6,7 @@ export function withBundledPluginAllowlistCompat(params: { config: OpenClawConfig | undefined; pluginIds: readonly string[]; }): OpenClawConfig | undefined { - if (params.config?.plugins?.bundledDiscovery === "allowlist") { + if (params.config?.plugins?.bundledDiscovery !== "compat") { return params.config; } const allow = params.config?.plugins?.allow; @@ -42,8 +42,10 @@ export function withBundledPluginEnablementCompat(params: { }): OpenClawConfig | undefined { const existingEntries = params.config?.plugins?.entries ?? {}; const forcePluginsEnabled = params.config?.plugins?.enabled === false; - const useAllowlistDiscovery = params.config?.plugins?.bundledDiscovery === "allowlist"; - const allowSet = useAllowlistDiscovery ? new Set(params.config?.plugins?.allow ?? []) : undefined; + const useCompatDiscovery = params.config?.plugins?.bundledDiscovery === "compat"; + const allow = params.config?.plugins?.allow; + const allowSet = + !useCompatDiscovery && Array.isArray(allow) && allow.length > 0 ? new Set(allow) : undefined; let changed = false; const nextEntries: Record = { ...existingEntries }; diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 3d678a7a02e..c5386ee0c16 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -295,6 +295,7 @@ function createBundledProviderCompatOptions(params?: { onlyPluginIds?: readonly config: { plugins: { allow: ["openrouter"], + bundledDiscovery: "compat" as const, }, }, bundledProviderAllowlistCompat: true, @@ -593,7 +594,7 @@ describe("resolvePluginProviders", () => { ).toEqual(["legacy-auth-owner"]); }); - it("filters bundled provider plugins by allowlist when bundledDiscovery is allowlist", () => { + it("filters bundled provider plugins by allowlist by default", () => { setManifestPlugins([ createManifestProviderPlugin({ id: "kilocode", @@ -619,7 +620,6 @@ describe("resolvePluginProviders", () => { config: { plugins: { allow: ["openrouter"], - bundledDiscovery: "allowlist", }, }, env: {} as NodeJS.ProcessEnv, @@ -628,7 +628,7 @@ describe("resolvePluginProviders", () => { expect(discovered).toEqual(["openrouter"]); }); - it("returns all bundled provider plugins in compat mode (default)", () => { + it("returns all bundled provider plugins in explicit compat mode", () => { setManifestPlugins([ createManifestProviderPlugin({ id: "kilocode", @@ -654,6 +654,7 @@ describe("resolvePluginProviders", () => { config: { plugins: { allow: ["openrouter"], + bundledDiscovery: "compat", }, }, env: {} as NodeJS.ProcessEnv, @@ -829,6 +830,7 @@ describe("resolvePluginProviders", () => { config: { plugins: { allow: ["openrouter"], + bundledDiscovery: "compat", }, }, bundledProviderAllowlistCompat: true, @@ -842,11 +844,38 @@ describe("resolvePluginProviders", () => { }); }); - it("loads all discovered provider plugins in setup mode", () => { + it("scopes setup provider plugin discovery to the allowlist by default", () => { + resolvePluginProviders({ + config: { + plugins: { + allow: ["google"], + }, + }, + mode: "setup", + includeUntrustedWorkspacePlugins: false, + }); + + expectLastSetupRegistryLoad({ + onlyPluginIds: ["google"], + }); + expect(getLastSetupLoadedPluginConfig()).toEqual( + expect.objectContaining({ + plugins: expect.objectContaining({ + allow: ["google"], + entries: expect.objectContaining({ + google: { enabled: true }, + }), + }), + }), + ); + }); + + it("loads all discovered provider plugins in setup mode for explicit compat configs", () => { resolvePluginProviders({ config: { plugins: { allow: ["openrouter"], + bundledDiscovery: "compat", entries: { google: { enabled: false }, }, @@ -884,6 +913,7 @@ describe("resolvePluginProviders", () => { config: { plugins: { allow: ["openrouter"], + bundledDiscovery: "compat", }, }, mode: "setup", @@ -900,6 +930,7 @@ describe("resolvePluginProviders", () => { config: { plugins: { allow: ["openrouter", "workspace-provider"], + bundledDiscovery: "compat", entries: { "workspace-provider": { enabled: false }, }, @@ -919,6 +950,7 @@ describe("resolvePluginProviders", () => { config: { plugins: { allow: ["openrouter", "workspace-provider"], + bundledDiscovery: "compat", deny: ["workspace-provider"], entries: { "workspace-provider": { enabled: false }, @@ -948,9 +980,7 @@ describe("resolvePluginProviders", () => { includeUntrustedWorkspacePlugins: false, }); - expectLastSetupRegistryLoad({ - onlyPluginIds: ["google", "kilocode", "moonshot"], - }); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); it("loads provider plugins from the auto-enabled config snapshot", () => { @@ -1242,16 +1272,7 @@ describe("resolvePluginProviders", () => { mode: "setup", }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - enabled: false, - allow: ["setup-owned-provider"], - }), - }), - }), - ); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); it("does not override explicitly disabled setup owners", () => { @@ -1278,18 +1299,7 @@ describe("resolvePluginProviders", () => { mode: "setup", }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: ["setup-owned-provider"], - entries: { - "setup-owned-provider": { enabled: false }, - }, - }), - }), - }), - ); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); it("filters explicit setup owners through the untrusted workspace discovery gate", () => { diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 68e9fff84d8..af75d5f41e7 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -255,7 +255,7 @@ export function resolveDiscoveredProviderPluginIds(params: { const { registry, onlyPluginIdSet } = loadScopedProviderRegistry(params); const providerSurfacePluginIds = resolveProviderSurfacePluginIdSet({ ...params, registry }); const shouldFilterUntrustedWorkspacePlugins = params.includeUntrustedWorkspacePlugins === false; - const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledDiscovery === "allowlist"; + const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledDiscovery !== "compat"; const normalizedConfig = normalizePluginsConfigWithRegistry(params.config?.plugins, registry); return listRegistryPluginIds(registry, (plugin) => { if ( @@ -298,6 +298,9 @@ function isProviderPluginEligibleForSetupDiscovery(params: { ) { return false; } + if (params.plugin.origin === "bundled") { + return true; + } return isActivatedManifestOwner({ plugin: toManifestOwnerRecord(params.plugin), normalizedConfig: params.normalizedConfig, @@ -313,7 +316,7 @@ export function resolveDiscoverableProviderOwnerPluginIds(params: { includeUntrustedWorkspacePlugins?: boolean; }): string[] { const shouldFilterUntrustedWorkspacePlugins = params.includeUntrustedWorkspacePlugins === false; - const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledDiscovery === "allowlist"; + const shouldFilterBundledByAllowlist = params.config?.plugins?.bundledDiscovery !== "compat"; return resolveProviderOwnerPluginIds({ ...params, isEligible: (plugin, normalizedConfig) => diff --git a/src/plugins/web-provider-public-artifacts.ts b/src/plugins/web-provider-public-artifacts.ts index cec130ae0fe..96631ee6720 100644 --- a/src/plugins/web-provider-public-artifacts.ts +++ b/src/plugins/web-provider-public-artifacts.ts @@ -32,7 +32,7 @@ function filterAllowlistedBundledPluginIds( ) { const allow = config?.plugins?.allow; if ( - config?.plugins?.bundledDiscovery !== "allowlist" || + config?.plugins?.bundledDiscovery === "compat" || !Array.isArray(allow) || allow.length === 0 ) { diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts index 1dd2da06136..5b310d1f164 100644 --- a/src/plugins/web-search-providers.runtime.test.ts +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -445,7 +445,7 @@ describe("resolvePluginWebSearchProviders", () => { const providers = resolvePluginWebSearchProviders({ config: { plugins: { - allow: ["perplexity"], + allow: ["brave"], }, }, mode: "setup", From 369d83f04c54ff1f858be55c5b092b69876f74c2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 22:48:57 +0100 Subject: [PATCH 098/107] test(doctor): preserve bundled discovery literal type --- .../doctor/shared/plugin-tool-allowlist-warnings.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts index 981b21079f1..fdf18a2b636 100644 --- a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts +++ b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.test.ts @@ -132,7 +132,7 @@ describe("collectPluginToolAllowlistWarnings", () => { { name: "default", plugins: { allow: ["telegram"] } }, { name: "explicit allowlist", - plugins: { allow: ["telegram"], bundledDiscovery: "allowlist" }, + plugins: { allow: ["telegram"], bundledDiscovery: "allowlist" as const }, }, ])( "does not warn when bundled provider discovery follows the allowlist ($name)", From fdbfabf9f95d68432ae078dece0a1b3560a2635c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:02:11 +0100 Subject: [PATCH 099/107] test(plugins): make loader compat contract explicit --- src/plugins/contracts/loader.contract.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/plugins/contracts/loader.contract.test.ts b/src/plugins/contracts/loader.contract.test.ts index 78442219e53..03b4fe714d6 100644 --- a/src/plugins/contracts/loader.contract.test.ts +++ b/src/plugins/contracts/loader.contract.test.ts @@ -26,6 +26,7 @@ function createAllowlistCompatConfig(pluginIds: string[]) { config: { plugins: { allow: [demoAllowEntry], + bundledDiscovery: "compat", }, }, pluginIds, @@ -51,6 +52,7 @@ describe("plugin loader contract", () => { config: { plugins: { allow: [demoAllowEntry], + bundledDiscovery: "compat", }, }, }); From 40e0844133e0dd0e3b26d3b84bf6124e87e3165a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:35:20 +0100 Subject: [PATCH 100/107] fix(plugins): preserve bundled allowlist edges --- docs/.generated/config-baseline.sha256 | 4 +-- ....providers.plugin-allowlist-compat.test.ts | 21 ++++++++++++ src/plugins/bundled-compat.ts | 8 +++-- ...provider-public-artifacts.fallback.test.ts | 33 +++++++++++++++++++ src/plugins/web-provider-public-artifacts.ts | 5 ++- 5 files changed, 65 insertions(+), 6 deletions(-) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index e0a057e0e3d..1bc9162c255 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -02987f4cecb64a98170b61c925fd7b16a22b276abfb261f9281b42f613ded923 config-baseline.json -de5a6f65ef09dc23453a2e12512e41c133c941519e0ebef7f2946e4a24265d17 config-baseline.core.json +2566cb33c48abf3884d44cc605e3fe23ee3dc3e998c29fe86dfe773faf58cb52 config-baseline.json +eab2f8a9af31910e26874209330d10ca46afd910cba88beda8a48fe6b9831159 config-baseline.core.json cd7c0c7fb1435bc7e59099e9ac334462d5ad444016e9ab4512aae63a238f78dc config-baseline.channel.json 9832b30a696930a3da7efccf38073137571e1b66cae84e54d747b733fdafcc54 config-baseline.plugin.json diff --git a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts index bd6070e98bc..63e9d72cb0d 100644 --- a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts +++ b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts @@ -132,6 +132,27 @@ describe("implicit provider plugin allowlist compatibility", () => { ).toEqual(["openrouter"]); }); + it("does not re-enable plugins when allowlist mode rejects every compat plugin", () => { + const config = withBundledPluginEnablementCompat({ + config: { + plugins: { + enabled: false, + allow: ["openrouter"], + bundledDiscovery: "allowlist", + }, + }, + pluginIds: ["kilocode", "moonshot"], + }); + + expect(config).toEqual({ + plugins: { + enabled: false, + allow: ["openrouter"], + bundledDiscovery: "allowlist", + }, + }); + }); + it("still honors explicit plugin denies over compat allowlist injection", () => { const config = withBundledPluginEnablementCompat({ config: withBundledPluginAllowlistCompat({ diff --git a/src/plugins/bundled-compat.ts b/src/plugins/bundled-compat.ts index 24fa7129038..c86bc7d975c 100644 --- a/src/plugins/bundled-compat.ts +++ b/src/plugins/bundled-compat.ts @@ -46,14 +46,16 @@ export function withBundledPluginEnablementCompat(params: { const allow = params.config?.plugins?.allow; const allowSet = !useCompatDiscovery && Array.isArray(allow) && allow.length > 0 ? new Set(allow) : undefined; + let hasEligiblePlugin = false; let changed = false; const nextEntries: Record = { ...existingEntries }; for (const pluginId of params.pluginIds) { - if (existingEntries[pluginId] !== undefined) { + if (allowSet && !allowSet.has(pluginId)) { continue; } - if (allowSet && !allowSet.has(pluginId)) { + hasEligiblePlugin = true; + if (existingEntries[pluginId] !== undefined) { continue; } nextEntries[pluginId] = { enabled: true }; @@ -61,7 +63,7 @@ export function withBundledPluginEnablementCompat(params: { } if (!changed) { - if (!forcePluginsEnabled) { + if (!forcePluginsEnabled || !hasEligiblePlugin) { return params.config; } } diff --git a/src/plugins/web-provider-public-artifacts.fallback.test.ts b/src/plugins/web-provider-public-artifacts.fallback.test.ts index 81f8b3c1308..f951c9b43d0 100644 --- a/src/plugins/web-provider-public-artifacts.fallback.test.ts +++ b/src/plugins/web-provider-public-artifacts.fallback.test.ts @@ -152,4 +152,37 @@ describe("web provider public artifact manifest fallback", () => { pluginId: "fallback-fetch", }); }); + + it("matches bundled web-search candidates through provider alias allowlist entries", () => { + mocks.resolveBundledExplicitWebSearchProvidersFromPublicArtifacts.mockReturnValueOnce(null); + mocks.loadPluginMetadataSnapshot.mockReturnValueOnce({ + diagnostics: [], + plugins: [ + { + id: "google", + origin: "bundled", + rootDir: "/tmp/google", + contracts: { webSearchProviders: ["gemini"] }, + }, + ], + }); + mocks.loadBundledWebSearchProviderEntriesFromDir.mockReturnValueOnce([ + { id: "gemini", pluginId: "google" }, + ]); + + const providers = resolveBundledWebSearchProvidersFromPublicArtifacts({ + config: { + plugins: { + allow: ["google-gemini-cli"], + bundledDiscovery: "allowlist", + }, + }, + }); + + expect(providers).toEqual([{ id: "gemini", pluginId: "google" }]); + expect(mocks.loadBundledWebSearchProviderEntriesFromDir).toHaveBeenCalledWith({ + dirName: "google", + pluginId: "google", + }); + }); }); diff --git a/src/plugins/web-provider-public-artifacts.ts b/src/plugins/web-provider-public-artifacts.ts index 96631ee6720..34ab13233b9 100644 --- a/src/plugins/web-provider-public-artifacts.ts +++ b/src/plugins/web-provider-public-artifacts.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { normalizePluginId } from "./config-state.js"; import type { PluginLoadOptions } from "./loader.js"; import { loadManifestMetadataSnapshot } from "./manifest-contract-eligibility.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; @@ -38,7 +39,9 @@ function filterAllowlistedBundledPluginIds( ) { return [...pluginIds]; } - const allowedPluginIds = new Set(allow.map((pluginId) => pluginId.trim()).filter(Boolean)); + const allowedPluginIds = new Set( + allow.map((pluginId) => normalizePluginId(pluginId)).filter(Boolean), + ); return pluginIds.filter((pluginId) => allowedPluginIds.has(pluginId)); } From d3628792825956702be7a6253061b96d976c623d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:45:31 +0100 Subject: [PATCH 101/107] fix(plugins): normalize compat allowlist aliases --- ....providers.plugin-allowlist-compat.test.ts | 24 +++++++++++++++++++ src/plugins/bundled-compat.ts | 5 +++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts index 63e9d72cb0d..c48554b07e5 100644 --- a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts +++ b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts @@ -153,6 +153,30 @@ describe("implicit provider plugin allowlist compatibility", () => { }); }); + it("re-enables globally disabled plugins when allowlist mode accepts a plugin alias", () => { + const config = withBundledPluginEnablementCompat({ + config: { + plugins: { + enabled: false, + allow: [" Google-Gemini-Cli "], + bundledDiscovery: "allowlist", + }, + }, + pluginIds: ["google"], + }); + + expect(config).toEqual({ + plugins: { + enabled: true, + allow: [" Google-Gemini-Cli "], + bundledDiscovery: "allowlist", + entries: { + google: { enabled: true }, + }, + }, + }); + }); + it("still honors explicit plugin denies over compat allowlist injection", () => { const config = withBundledPluginEnablementCompat({ config: withBundledPluginAllowlistCompat({ diff --git a/src/plugins/bundled-compat.ts b/src/plugins/bundled-compat.ts index c86bc7d975c..67f739e2768 100644 --- a/src/plugins/bundled-compat.ts +++ b/src/plugins/bundled-compat.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginEntryConfig } from "../config/types.plugins.js"; import { hasExplicitPluginConfig } from "./config-policy.js"; +import { normalizePluginId } from "./config-state.js"; export function withBundledPluginAllowlistCompat(params: { config: OpenClawConfig | undefined; @@ -45,7 +46,9 @@ export function withBundledPluginEnablementCompat(params: { const useCompatDiscovery = params.config?.plugins?.bundledDiscovery === "compat"; const allow = params.config?.plugins?.allow; const allowSet = - !useCompatDiscovery && Array.isArray(allow) && allow.length > 0 ? new Set(allow) : undefined; + !useCompatDiscovery && Array.isArray(allow) && allow.length > 0 + ? new Set(allow.map((pluginId) => normalizePluginId(pluginId)).filter(Boolean)) + : undefined; let hasEligiblePlugin = false; let changed = false; const nextEntries: Record = { ...existingEntries }; From c3c7c2df6f62a40130157865cf7589a00e74557c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:52:37 +0100 Subject: [PATCH 102/107] fix(discord): prefer IPv4 for gateway startup --- CHANGELOG.md | 1 + .../discord/src/monitor/gateway-plugin.ts | 12 ++- .../src/monitor/provider.proxy.test.ts | 40 +++++++- .../src/monitor/provider.rest-proxy.test.ts | 82 +++++++++++++++-- .../src/monitor/provider.startup.test.ts | 30 +++--- .../discord/src/monitor/provider.startup.ts | 13 +-- extensions/discord/src/monitor/provider.ts | 4 +- extensions/discord/src/monitor/rest-fetch.ts | 73 ++++++++++----- extensions/discord/src/network-config.test.ts | 92 +++++++++++++++++++ extensions/discord/src/network-config.ts | 79 ++++++++++++++++ 10 files changed, 359 insertions(+), 67 deletions(-) create mode 100644 extensions/discord/src/network-config.test.ts create mode 100644 extensions/discord/src/network-config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b1143d810d1..0d716f9c781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Gateway/watch: suppress sync-I/O trace output during `pnpm gateway:watch --benchmark` unless explicitly requested, so CPU profiling no longer floods the terminal with stack traces. - Gateway/watch: when benchmark sync-I/O tracing is explicitly enabled, tee trace blocks to the benchmark output log and filter them from the terminal pane while keeping normal Gateway logs visible. - Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc. +- Discord: prefer IPv4 for Discord REST and gateway WebSocket startup paths so IPv4-only networks no longer stall before Gateway READY and inbound message dispatch. Fixes #77398; refs #77526. Thanks @Beandon13. - Channels/plugins: key bundled package-state probes, env/config presence, and read-only command defaults by channel id instead of manifest plugin id, preserving setup and native-command detection for channel plugins whose package id differs from the channel alias. Thanks @vincentkoc. - Docker: prune package-excluded plugin dist directories from runtime images unless the build explicitly opts that plugin in, so official external plugins such as Feishu stay install-on-demand instead of shipping partial metadata without compiled runtime output. Fixes #77424. Thanks @vincentkoc. - Model switching: include the exact additive allowlist repair command when `/model ... --runtime ...` targets a blocked model, and make Telegram's model picker say that it changes only the session model while leaving the runtime unchanged. Thanks @vincentkoc. diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index 4b450db733d..65db426e7d0 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { Agent as HttpsAgent } from "node:https"; import * as httpsProxyAgent from "https-proxy-agent"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-types"; import { @@ -10,6 +11,7 @@ import { danger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import * as ws from "ws"; import * as discordGateway from "../internal/gateway.js"; +import { createDiscordDnsLookup } from "../network-config.js"; import { validateDiscordProxyUrl } from "../proxy-fetch.js"; import { resolveDiscordVoiceEnabled } from "../voice/config.js"; import { DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT } from "./gateway-handle.js"; @@ -28,11 +30,15 @@ export { } from "./gateway-metadata.js"; const DISCORD_GATEWAY_HANDSHAKE_TIMEOUT_MS = 30_000; +const discordDnsLookup = createDiscordDnsLookup(); type DiscordGatewayWebSocketCtor = new ( url: string, options?: { agent?: unknown; handshakeTimeout?: number }, ) => ws.WebSocket; +type DiscordGatewayWebSocketAgent = + | InstanceType + | InstanceType>; const registrationPromises = new WeakMap>(); type DiscordGatewayClient = Parameters[0]; type GatewayPluginTestingOptions = { @@ -100,7 +106,7 @@ function createGatewayPlugin(params: { gatewayInfoTimeoutMs: number; fetchImpl: DiscordGatewayFetch; fetchInit?: DiscordGatewayFetchInit; - wsAgent?: InstanceType>; + wsAgent?: DiscordGatewayWebSocketAgent; runtime?: RuntimeEnv; testing?: GatewayPluginTestingOptions; }): discordGateway.GatewayPlugin { @@ -263,7 +269,9 @@ export function createDiscordGatewayPlugin(params: { env: process.env, }); let fetchImpl = createDiscordGatewayMetadataFetch(debugProxySettings.enabled); - let wsAgent: InstanceType> | undefined; + let wsAgent: DiscordGatewayWebSocketAgent = new HttpsAgent({ + lookup: discordDnsLookup, + }); if (proxy) { try { diff --git a/extensions/discord/src/monitor/provider.proxy.test.ts b/extensions/discord/src/monitor/provider.proxy.test.ts index 5f6750c7982..399181be082 100644 --- a/extensions/discord/src/monitor/provider.proxy.test.ts +++ b/extensions/discord/src/monitor/provider.proxy.test.ts @@ -33,14 +33,18 @@ const { captureWsEventSpy, GatewayPlugin, globalFetchMock, + HttpsAgent, HttpsProxyAgent, getLastAgent, + getLastProxyAgent, resolveDebugProxySettingsMock, resetLastAgent, webSocketSpy, + httpsAgentSpy, wsProxyAgentSpy, } = vi.hoisted(() => { const wsProxyAgentSpy = vi.fn(); + const httpsAgentSpy = vi.fn(); const globalFetchMock = vi.fn(); const baseRegisterClientSpy = vi.fn(); const webSocketSpy = vi.fn(); @@ -78,6 +82,16 @@ const { } } + class HttpsAgent { + static lastCreated: HttpsAgent | undefined; + options: unknown; + constructor(options?: unknown) { + this.options = options; + HttpsAgent.lastCreated = this; + httpsAgentSpy(options); + } + } + class HttpsProxyAgent { static lastCreated: HttpsProxyAgent | undefined; proxyUrl: string; @@ -96,12 +110,16 @@ const { GatewayIntents, GatewayPlugin, globalFetchMock, + HttpsAgent, HttpsProxyAgent, - getLastAgent: () => HttpsProxyAgent.lastCreated, + getLastAgent: () => HttpsAgent.lastCreated, + getLastProxyAgent: () => HttpsProxyAgent.lastCreated, captureHttpExchangeSpy, captureWsEventSpy, + httpsAgentSpy, resolveDebugProxySettingsMock, resetLastAgent: () => { + HttpsAgent.lastCreated = undefined; HttpsProxyAgent.lastCreated = undefined; }, webSocketSpy, @@ -120,6 +138,10 @@ vi.mock("../internal/gateway.js", () => ({ GatewayPlugin, })); +vi.mock("node:https", () => ({ + Agent: HttpsAgent, +})); + vi.mock("https-proxy-agent", () => ({ HttpsProxyAgent, })); @@ -279,6 +301,7 @@ describe("createDiscordGatewayPlugin", () => { vi.useRealTimers(); baseRegisterClientSpy.mockClear(); globalFetchMock.mockClear(); + httpsAgentSpy.mockClear(); wsProxyAgentSpy.mockClear(); webSocketSpy.mockClear(); captureHttpExchangeSpy.mockClear(); @@ -321,9 +344,16 @@ describe("createDiscordGatewayPlugin", () => { .createWebSocket; createWebSocket("wss://gateway.discord.gg"); - expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", { - handshakeTimeout: 30_000, - }); + expect(httpsAgentSpy).toHaveBeenCalledWith( + expect.objectContaining({ lookup: expect.any(Function) }), + ); + expect(webSocketSpy).toHaveBeenCalledWith( + "wss://gateway.discord.gg", + expect.objectContaining({ + agent: getLastAgent(), + handshakeTimeout: 30_000, + }), + ); expect(wsProxyAgentSpy).not.toHaveBeenCalled(); }); @@ -437,7 +467,7 @@ describe("createDiscordGatewayPlugin", () => { expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://127.0.0.1:8080"); expect(webSocketSpy).toHaveBeenCalledWith( "wss://gateway.discord.gg", - expect.objectContaining({ agent: getLastAgent(), handshakeTimeout: 30_000 }), + expect.objectContaining({ agent: getLastProxyAgent(), handshakeTimeout: 30_000 }), ); expect(runtime.log).toHaveBeenCalledWith("discord: gateway proxy enabled"); expect(runtime.error).not.toHaveBeenCalled(); diff --git a/extensions/discord/src/monitor/provider.rest-proxy.test.ts b/extensions/discord/src/monitor/provider.rest-proxy.test.ts index e12d031b578..ad3ee1748db 100644 --- a/extensions/discord/src/monitor/provider.rest-proxy.test.ts +++ b/extensions/discord/src/monitor/provider.rest-proxy.test.ts @@ -1,22 +1,34 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const { undiciFetchMock, proxyAgentSpy } = vi.hoisted(() => ({ +const { undiciFetchMock, agentSpy, proxyAgentSpy } = vi.hoisted(() => ({ undiciFetchMock: vi.fn(), + agentSpy: vi.fn(), proxyAgentSpy: vi.fn(), })); vi.mock("undici", () => { + class Agent { + options: unknown; + constructor(options?: unknown) { + this.options = options; + agentSpy(options); + } + } class ProxyAgent { - proxyUrl: string; - constructor(proxyUrl: string) { - if (proxyUrl === "bad-proxy") { + options: unknown; + uri: string; + constructor(options: string | { uri: string; allowH2?: boolean }) { + const resolved = typeof options === "string" ? { uri: options } : options; + if (resolved.uri === "bad-proxy") { throw new Error("bad proxy"); } - this.proxyUrl = proxyUrl; - proxyAgentSpy(proxyUrl); + this.options = resolved; + this.uri = resolved.uri; + proxyAgentSpy(resolved); } } return { + Agent, ProxyAgent, fetch: undiciFetchMock, }; @@ -32,6 +44,7 @@ describe("resolveDiscordRestFetch", () => { beforeEach(() => { vi.unstubAllEnvs(); undiciFetchMock.mockReset(); + agentSpy.mockReset(); proxyAgentSpy.mockReset(); }); @@ -47,11 +60,19 @@ describe("resolveDiscordRestFetch", () => { await fetcher("https://discord.com/api/v10/oauth2/applications/@me"); - expect(proxyAgentSpy).toHaveBeenCalledWith("http://127.0.0.1:8080"); + expect(proxyAgentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + uri: "http://127.0.0.1:8080", + allowH2: false, + }), + ); expect(undiciFetchMock).toHaveBeenCalledWith( "https://discord.com/api/v10/oauth2/applications/@me", expect.objectContaining({ - dispatcher: expect.objectContaining({ proxyUrl: "http://127.0.0.1:8080" }), + dispatcher: expect.objectContaining({ + uri: "http://127.0.0.1:8080", + options: expect.objectContaining({ allowH2: false }), + }), }), ); expect(runtime.log).toHaveBeenCalledWith("discord: rest proxy enabled"); @@ -98,10 +119,46 @@ describe("resolveDiscordRestFetch", () => { await fetcher("https://discord.com/api/v10/oauth2/applications/@me"); - expect(proxyAgentSpy).toHaveBeenCalledWith("http://[::1]:8080"); + expect(proxyAgentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + uri: "http://[::1]:8080", + allowH2: false, + }), + ); expect(runtime.error).not.toHaveBeenCalled(); }); + it("uses undici Agent with IPv4-first lookup when no discord proxy URL is configured", async () => { + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as const; + undiciFetchMock.mockResolvedValue(new Response("ok", { status: 200 })); + + const fetcher = resolveDiscordRestFetch(undefined, runtime); + await fetcher("https://discord.com/api/v10/oauth2/applications/@me"); + + expect(agentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + allowH2: false, + connect: expect.objectContaining({ lookup: expect.any(Function) }), + }), + ); + expect(undiciFetchMock).toHaveBeenCalledWith( + "https://discord.com/api/v10/oauth2/applications/@me", + expect.objectContaining({ + dispatcher: expect.objectContaining({ + options: expect.objectContaining({ + allowH2: false, + connect: expect.objectContaining({ lookup: expect.any(Function) }), + }), + }), + }), + ); + expect(runtime.log).not.toHaveBeenCalled(); + }); + it("uses debug proxy env when no discord proxy URL is configured", async () => { vi.stubEnv("OPENCLAW_DEBUG_PROXY_ENABLED", "1"); vi.stubEnv("OPENCLAW_DEBUG_PROXY_URL", "http://127.0.0.1:7777"); @@ -115,7 +172,12 @@ describe("resolveDiscordRestFetch", () => { const fetcher = resolveDiscordRestFetch(undefined, runtime); await fetcher("https://discord.com/api/v10/oauth2/applications/@me"); - expect(proxyAgentSpy).toHaveBeenCalledWith("http://127.0.0.1:7777"); + expect(proxyAgentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + uri: "http://127.0.0.1:7777", + allowH2: false, + }), + ); expect(runtime.log).toHaveBeenCalledWith("discord: rest proxy enabled"); }); }); diff --git a/extensions/discord/src/monitor/provider.startup.test.ts b/extensions/discord/src/monitor/provider.startup.test.ts index 13742286909..0cc52a4ffb2 100644 --- a/extensions/discord/src/monitor/provider.startup.test.ts +++ b/extensions/discord/src/monitor/provider.startup.test.ts @@ -92,7 +92,7 @@ vi.mock("./presence.js", () => ({ resolveDiscordPresenceUpdate: vi.fn(() => undefined), })); -import { createDiscordRequestClient, DISCORD_REST_TIMEOUT_MS } from "../proxy-request-client.js"; +import { DISCORD_REST_TIMEOUT_MS } from "../proxy-request-client.js"; import { registerDiscordListener } from "./listeners.js"; import { createDiscordMonitorClient, @@ -104,7 +104,6 @@ describe("createDiscordMonitorClient", () => { beforeEach(() => { registerVoiceClientSpy.mockReset(); waitForDiscordGatewayPluginRegistrationMock.mockReset().mockReturnValue(undefined); - vi.mocked(createDiscordRequestClient).mockClear(); vi.mocked(registerDiscordListener).mockClear(); }); @@ -253,33 +252,40 @@ describe("createDiscordMonitorClient", () => { ); }); - it("passes REST timeout options to proxied Discord fetch", async () => { - const proxyFetch = vi.fn(); + it("passes REST timeout options and fetch to internal Discord REST", async () => { + const restFetch = vi.fn(); + const createClient = vi.fn(createClientWithPlugins); await createDiscordMonitorClient({ accountId: "default", applicationId: "app-1", token: "token-1", - proxyFetch, + restFetch, commands: [], components: [], modals: [], voiceEnabled: false, discordConfig: {}, runtime: createRuntime(), - createClient: createClientWithPlugins, + createClient, createGatewayPlugin: () => ({ id: "gateway" }) as never, createGatewaySupervisor: () => ({ shutdown: vi.fn(), handleError: vi.fn() }) as never, createAutoPresenceController: () => createAutoPresenceController() as never, isDisallowedIntentsError: () => false, }); - expect(createDiscordRequestClient).toHaveBeenCalledWith("token-1", { - fetch: proxyFetch, - timeout: DISCORD_REST_TIMEOUT_MS, - runtimeProfile: "persistent", - maxQueueSize: 1000, - }); + expect(createClient).toHaveBeenCalledWith( + expect.objectContaining({ + requestOptions: { + timeout: DISCORD_REST_TIMEOUT_MS, + runtimeProfile: "persistent", + maxQueueSize: 1000, + fetch: restFetch, + }, + }), + expect.any(Object), + expect.any(Array), + ); }); it("propagates gateway registration failures before supervisor startup", async () => { diff --git a/extensions/discord/src/monitor/provider.startup.ts b/extensions/discord/src/monitor/provider.startup.ts index 18dadd3e54c..7c584bed6ce 100644 --- a/extensions/discord/src/monitor/provider.startup.ts +++ b/extensions/discord/src/monitor/provider.startup.ts @@ -16,7 +16,7 @@ import { import type { GatewayPlugin } from "../internal/gateway.js"; import { VoicePlugin } from "../internal/voice.js"; import { parseApplicationIdFromToken } from "../probe.js"; -import { createDiscordRequestClient, DISCORD_REST_TIMEOUT_MS } from "../proxy-request-client.js"; +import { DISCORD_REST_TIMEOUT_MS } from "../proxy-request-client.js"; import type { DiscordGuildEntryResolved } from "./allow-list.js"; import { createDiscordAutoPresenceController } from "./auto-presence.js"; import type { DiscordDmPolicy } from "./dm-command-auth.js"; @@ -88,7 +88,7 @@ export async function createDiscordMonitorClient(params: { accountId: string; applicationId: string; token: string; - proxyFetch?: typeof fetch; + restFetch?: typeof fetch; commands: BaseCommand[]; components: BaseMessageInteractiveComponent[]; modals: Modal[]; @@ -147,6 +147,7 @@ export async function createDiscordMonitorClient(params: { timeout: DISCORD_REST_TIMEOUT_MS, runtimeProfile: "persistent", maxQueueSize: 1000, + ...(params.restFetch ? { fetch: params.restFetch } : {}), }, eventQueue: eventQueueOpts, }, @@ -161,14 +162,6 @@ export async function createDiscordMonitorClient(params: { if (voicePlugin) { registerLatePlugin(client, voicePlugin); } - if (params.proxyFetch) { - client.rest = createDiscordRequestClient(params.token, { - fetch: params.proxyFetch, - timeout: DISCORD_REST_TIMEOUT_MS, - runtimeProfile: "persistent", - maxQueueSize: 1000, - }); - } const gateway = client.getPlugin("gateway") as MutableDiscordGateway | undefined; await waitForDiscordGatewayPluginRegistration(gateway); const gatewaySupervisor = params.createGatewaySupervisor({ diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 862e126f18f..285def4c0e2 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -29,7 +29,6 @@ import { import { Client } from "../internal/discord.js"; import { GatewayCloseCodes } from "../internal/gateway.js"; import { fetchDiscordApplicationId, parseApplicationIdFromToken } from "../probe.js"; -import { resolveDiscordProxyFetchForAccount } from "../proxy-fetch.js"; import { normalizeDiscordToken } from "../token.js"; import { resolveDiscordVoiceEnabled } from "../voice/config.js"; import { createDiscordAutoPresenceController } from "./auto-presence.js"; @@ -203,7 +202,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const discordAccountThreadBindings = cfg.channels?.discord?.accounts?.[account.accountId]?.threadBindings; const discordRestFetch = resolveDiscordRestFetch(rawDiscordCfg.proxy, runtime); - const discordProxyFetch = resolveDiscordProxyFetchForAccount(account, cfg, runtime); const dmConfig = rawDiscordCfg.dm; const configuredDmAllowFrom = resolveDiscordAccountAllowFrom({ cfg, @@ -429,7 +427,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { accountId: account.accountId, applicationId, token, - proxyFetch: discordProxyFetch, + restFetch: discordRestFetch, commands, components, modals, diff --git a/extensions/discord/src/monitor/rest-fetch.ts b/extensions/discord/src/monitor/rest-fetch.ts index 9e20fc821b8..1711ddcd47d 100644 --- a/extensions/discord/src/monitor/rest-fetch.ts +++ b/extensions/discord/src/monitor/rest-fetch.ts @@ -6,38 +6,61 @@ import { } from "openclaw/plugin-sdk/proxy-capture"; import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { ProxyAgent, fetch as undiciFetch } from "undici"; +import { Agent, ProxyAgent, fetch as undiciFetch } from "undici"; +import { createDiscordDnsLookup } from "../network-config.js"; import { withValidatedDiscordProxy } from "../proxy-fetch.js"; +const discordDnsLookup = createDiscordDnsLookup(); + +type DiscordRestDispatcher = InstanceType | InstanceType; + +function createDiscordRestFetchWithDispatcher(dispatcher: DiscordRestDispatcher): typeof fetch { + return wrapFetchWithAbortSignal(((input: RequestInfo | URL, init?: RequestInit) => + ( + undiciFetch(input as string | URL, { + ...(init as Record), + dispatcher, + }) as unknown as Promise + ).then((response) => { + captureHttpExchange({ + url: resolveRequestUrl(input), + method: init?.method ?? "GET", + requestHeaders: init?.headers as Headers | Record | undefined, + requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null, + response, + flowId: randomUUID(), + meta: { subsystem: "discord-rest" }, + }); + return response; + })) as typeof fetch); +} + export function resolveDiscordRestFetch( proxyUrl: string | undefined, runtime: RuntimeEnv, ): typeof fetch { const effectiveProxyUrl = resolveEffectiveDebugProxyUrl(proxyUrl); - const fetcher = withValidatedDiscordProxy(effectiveProxyUrl, runtime, (proxy) => { - const agent = new ProxyAgent(proxy); - return wrapFetchWithAbortSignal(((input: RequestInfo | URL, init?: RequestInit) => - ( - undiciFetch(input as string | URL, { - ...(init as Record), - dispatcher: agent, - }) as unknown as Promise - ).then((response) => { - captureHttpExchange({ - url: resolveRequestUrl(input), - method: init?.method ?? "GET", - requestHeaders: init?.headers as Headers | Record | undefined, - requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null, - response, - flowId: randomUUID(), - meta: { subsystem: "discord-rest" }, - }); - return response; - })) as typeof fetch); - }); - if (!fetcher) { - return fetch; + if (effectiveProxyUrl) { + const fetcher = withValidatedDiscordProxy(effectiveProxyUrl, runtime, (proxy) => + createDiscordRestFetchWithDispatcher( + new ProxyAgent({ + uri: proxy, + allowH2: false, + }), + ), + ); + if (!fetcher) { + return fetch; + } + runtime.log?.("discord: rest proxy enabled"); + return fetcher; } - runtime.log?.("discord: rest proxy enabled"); + + const fetcher = createDiscordRestFetchWithDispatcher( + new Agent({ + allowH2: false, + connect: { lookup: discordDnsLookup }, + }), + ); return fetcher; } diff --git a/extensions/discord/src/network-config.test.ts b/extensions/discord/src/network-config.test.ts new file mode 100644 index 00000000000..5355935e521 --- /dev/null +++ b/extensions/discord/src/network-config.test.ts @@ -0,0 +1,92 @@ +import type * as dns from "node:dns"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const dnsMocks = vi.hoisted(() => ({ + lookup: vi.fn(), +})); + +vi.mock("node:dns", async () => { + const actual = await vi.importActual("node:dns"); + return { + ...actual, + lookup: dnsMocks.lookup, + }; +}); + +import { createDiscordDnsLookup } from "./network-config.js"; + +describe("createDiscordDnsLookup", () => { + afterEach(() => { + dnsMocks.lookup.mockReset(); + }); + + it("returns reordered address arrays when the caller requests all addresses", async () => { + dnsMocks.lookup.mockImplementation((_hostname: string, options: unknown, callback: unknown) => { + expect(options).toMatchObject({ all: true }); + (callback as (err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void)( + null, + [ + { address: "2606:4700::6810:1234", family: 6 }, + { address: "162.159.135.232", family: 4 }, + ], + ); + }); + + const lookup = createDiscordDnsLookup(); + const addresses = await new Promise((resolve, reject) => { + lookup("discord.com", { all: true }, (err, result) => { + if (err) { + reject(err); + return; + } + resolve(result as dns.LookupAddress[]); + }); + }); + + expect(addresses).toEqual([ + { address: "162.159.135.232", family: 4 }, + { address: "2606:4700::6810:1234", family: 6 }, + ]); + }); + + it("returns the first reordered IPv4 address for scalar lookups", async () => { + dnsMocks.lookup.mockImplementation( + (_hostname: string, _options: unknown, callback: unknown) => { + (callback as (err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void)( + null, + [ + { address: "2606:4700::6810:1234", family: 6 }, + { address: "162.159.135.232", family: 4 }, + ], + ); + }, + ); + + const lookup = createDiscordDnsLookup(); + const result = await new Promise<{ address: string; family: number }>((resolve, reject) => { + lookup("gateway.discord.gg", {}, (err, address, family) => { + if (err) { + reject(err); + return; + } + if (typeof address !== "string" || typeof family !== "number") { + reject(new Error("Expected scalar lookup result")); + return; + } + resolve({ address, family }); + }); + }); + + expect(result).toEqual({ address: "162.159.135.232", family: 4 }); + }); + + it("delegates non-Discord hostnames unchanged", () => { + const callback = vi.fn(); + const options = { all: true }; + const lookup = createDiscordDnsLookup(); + + lookup("example.com", options, callback); + + expect(dnsMocks.lookup).toHaveBeenCalledWith("example.com", options, callback); + }); +}); diff --git a/extensions/discord/src/network-config.ts b/extensions/discord/src/network-config.ts new file mode 100644 index 00000000000..3f5c34faf37 --- /dev/null +++ b/extensions/discord/src/network-config.ts @@ -0,0 +1,79 @@ +import * as dns from "node:dns"; +import type { LookupFunction } from "node:net"; + +const DISCORD_DNS_HOSTS = ["discord.com", "discord.gg", "gateway.discord.gg"]; + +function normalizeHostname(hostname: string): string { + return hostname.trim().toLowerCase(); +} + +function isDiscordTransportHostname(hostname: string): boolean { + const normalized = normalizeHostname(hostname); + if (!normalized) { + return false; + } + return DISCORD_DNS_HOSTS.some( + (target) => normalized === target || normalized.endsWith(`.${target}`), + ); +} + +function reorderLookupAddresses(addresses: dns.LookupAddress[]): dns.LookupAddress[] { + if (!Array.isArray(addresses) || addresses.length < 2) { + return addresses; + } + const ipv4 = addresses.filter((entry) => entry.family === 4); + const ipv6 = addresses.filter((entry) => entry.family === 6); + if (ipv4.length === 0) { + return ipv6; + } + if (ipv6.length === 0) { + return ipv4; + } + return [...ipv4, ...ipv6]; +} + +export function createDiscordDnsLookup(): LookupFunction { + return (hostname, options, callback) => { + if (!isDiscordTransportHostname(hostname)) { + return dns.lookup(hostname, options, callback); + } + + const lookupOptions: dns.LookupOptions = + typeof options === "number" + ? { family: options } + : options === undefined + ? {} + : ({ ...options } as dns.LookupOptions); + + if (lookupOptions.family === 4 || lookupOptions.family === 6) { + return dns.lookup(hostname, lookupOptions, callback as never); + } + + dns.lookup(hostname, { ...lookupOptions, all: true }, (err, addresses) => { + if (err) { + callback(err, "", 4); + return; + } + if (!Array.isArray(addresses)) { + callback(new Error("Expected all lookup addresses to be an array"), "", 4); + return; + } + + const reordered = reorderLookupAddresses(addresses); + if (lookupOptions.all === true) { + (callback as (err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void)( + null, + reordered, + ); + return; + } + + const first = reordered[0]; + if (!first) { + callback(new Error("No Discord DNS addresses resolved"), "", 4); + return; + } + callback(null, first.address, first.family); + }); + }; +} From 03f7e26d540eb9d14bec8a703ad6faa189a63160 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 23:52:58 +0100 Subject: [PATCH 103/107] fix: log gateway model mode defaults --- CHANGELOG.md | 1 + docs/gateway/logging.md | 11 +++++ src/gateway/server-startup-log.test.ts | 61 +++++++++++++++++++++++++- src/gateway/server-startup-log.ts | 48 ++++++++++++++++++-- 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d716f9c781..887bf90d216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/startup: include the resolved thinking, reasoning, and fast-mode defaults in the `agent model` startup log line so operator logs show which mode new sessions will inherit. - Gateway/watch: suppress sync-I/O trace output during `pnpm gateway:watch --benchmark` unless explicitly requested, so CPU profiling no longer floods the terminal with stack traces. - Gateway/watch: when benchmark sync-I/O tracing is explicitly enabled, tee trace blocks to the benchmark output log and filter them from the terminal pane while keeping normal Gateway logs visible. - Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc. diff --git a/docs/gateway/logging.md b/docs/gateway/logging.md index 543623f7d88..27624176054 100644 --- a/docs/gateway/logging.md +++ b/docs/gateway/logging.md @@ -15,6 +15,17 @@ OpenClaw has two log “surfaces”: - **Console output** (what you see in the terminal / Debug UI). - **File logs** (JSON lines) written by the gateway logger. +At startup, the Gateway logs the resolved default agent model together with the +mode defaults that affect new sessions, for example: + +```text +agent model: openai-codex/gpt-5.5 (thinking=medium, reasoning=off, fast=on) +``` + +`thinking` comes from the default agent, model params, or global agent default; +`reasoning` comes from the default agent or global reasoning default; and `fast` +comes from the default agent or model `fastMode` params. + ## File-based logger - Default rolling log file is under `/tmp/openclaw/` (one file per day): `openclaw-YYYY-MM-DD.log` diff --git a/src/gateway/server-startup-log.test.ts b/src/gateway/server-startup-log.test.ts index 4a89820016d..b2db0749ef1 100644 --- a/src/gateway/server-startup-log.test.ts +++ b/src/gateway/server-startup-log.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { logGatewayStartup } from "./server-startup-log.js"; +import { formatAgentModelStartupDetails, logGatewayStartup } from "./server-startup-log.js"; describe("gateway startup log", () => { afterEach(() => { @@ -49,6 +49,65 @@ describe("gateway startup log", () => { expect(warn).not.toHaveBeenCalled(); }); + it("logs configured model mode defaults with the startup model", () => { + const info = vi.fn(); + const warn = vi.fn(); + + logGatewayStartup({ + cfg: { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + models: { + "openai-codex/gpt-5.5": { + params: { + fastMode: true, + thinking: "medium", + }, + }, + }, + reasoningDefault: "stream", + }, + }, + }, + bindHost: "127.0.0.1", + loadedPluginIds: [], + port: 18789, + log: { info, warn }, + isNixMode: false, + }); + + expect(info).toHaveBeenCalledWith( + "agent model: openai-codex/gpt-5.5 (thinking=medium, reasoning=stream, fast=on)", + expect.objectContaining({ + consoleMessage: expect.stringContaining( + "agent model: openai-codex/gpt-5.5 (thinking=medium, reasoning=stream, fast=on)", + ), + }), + ); + }); + + it("uses default agent mode overrides in the startup model details", () => { + expect( + formatAgentModelStartupDetails({ + cfg: { + agents: { + defaults: { + thinkingDefault: "low", + reasoningDefault: "off", + models: { + "openai/gpt-5.5": { params: { fastMode: false } }, + }, + }, + list: [{ id: "alpha", default: true, thinkingDefault: "high", fastModeDefault: true }], + }, + }, + provider: "openai", + model: "gpt-5.5", + }), + ).toBe("thinking=high, reasoning=off, fast=on"); + }); + it("logs a compact listening line with loaded plugin ids and duration", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-03T10:00:16.000Z")); diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts index b510e498e6e..b50fdb48642 100644 --- a/src/gateway/server-startup-log.ts +++ b/src/gateway/server-startup-log.ts @@ -1,6 +1,12 @@ import chalk from "chalk"; +import { resolveDefaultAgentId, resolveAgentConfig } from "../agents/agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { resolveFastModeState } from "../agents/fast-mode.js"; +import { + resolveConfiguredModelRef, + resolveReasoningDefault, + resolveThinkingDefault, +} from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getResolvedLoggerSettings } from "../logging.js"; import { collectEnabledInsecureOrDangerousFlags } from "../security/dangerous-config-flags.js"; @@ -22,8 +28,13 @@ export function logGatewayStartup(params: { defaultModel: DEFAULT_MODEL, }); const modelRef = `${agentProvider}/${agentModel}`; - params.log.info(`agent model: ${modelRef}`, { - consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`, + const modelDetails = formatAgentModelStartupDetails({ + cfg: params.cfg, + provider: agentProvider, + model: agentModel, + }); + params.log.info(`agent model: ${modelRef} (${modelDetails})`, { + consoleMessage: `agent model: ${chalk.whiteBright(modelRef)} (${modelDetails})`, }); const startupDurationMs = typeof params.startupStartedAt === "number" ? Date.now() - params.startupStartedAt : null; @@ -46,6 +57,37 @@ export function logGatewayStartup(params: { } } +export function formatAgentModelStartupDetails(params: { + cfg: OpenClawConfig; + provider: string; + model: string; +}): string { + const defaultAgentId = resolveDefaultAgentId(params.cfg); + const defaultAgentConfig = resolveAgentConfig(params.cfg, defaultAgentId); + const thinking = + defaultAgentConfig?.thinkingDefault ?? + resolveThinkingDefault({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + }); + const reasoning = + defaultAgentConfig?.reasoningDefault ?? + params.cfg.agents?.defaults?.reasoningDefault ?? + resolveReasoningDefault({ + provider: params.provider, + model: params.model, + }); + const fast = resolveFastModeState({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + agentId: defaultAgentId, + }); + + return `thinking=${thinking}, reasoning=${reasoning}, fast=${fast.enabled ? "on" : "off"}`; +} + function formatReadyDetails( loadedPluginIds: readonly string[], startupDurationLabel: string | null, From 34f805a0127ce788ac9a45ac96281e84de3b3eb1 Mon Sep 17 00:00:00 2001 From: Shubhankar Tripathy <95570942+lonexreb@users.noreply.github.com> Date: Mon, 4 May 2026 18:03:51 -0500 Subject: [PATCH 104/107] fix(docker): pin container-side workspace and config dirs in compose Fixes #77436 --- CHANGELOG.md | 1 + docker-compose.yml | 12 ++++++++++++ src/docker-setup.e2e.test.ts | 12 ++++++++++++ 3 files changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 887bf90d216..081c8f7187b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ Docs: https://docs.openclaw.ai - Agents/subagents: refresh deferred final-delivery payloads when same-session completion output changes, so retried parent notifications use the final child summary instead of stale progress text. Thanks @vincentkoc. - active-memory: skip the memory sub-agent gracefully instead of logging a confusing allowlist error when no memory plugin (`memory-core` or `memory-lancedb`) is loaded, so active-memory with no memory backend no longer produces misleading "No callable tools remain" warnings in the gateway log. Fixes #77506. Thanks @hclsys. - Memory/wiki: preserve representation from both corpora in `corpus=all` searches while backfilling unused result capacity, so memory hits are not starved by numerically higher wiki integer scores. Fixes #77337. Thanks @hclsys. +- Docker/compose: pin container-side `OPENCLAW_CONFIG_DIR` and `OPENCLAW_WORKSPACE_DIR` on both gateway and CLI services so the host paths written into `.env` by `scripts/docker/setup.sh` (used as Compose bind-mount sources) cannot leak into runtime code via the `env_file` import. Fixes regressions on macOS Docker setups where the first agent reply died with `EACCES: permission denied, mkdir '/Users'` because the host-style workspace path got persisted into `agents.defaults.workspace`. Fixes #77436. Thanks @lonexreb. - Telegram: clean up tool-only draft previews after assistant message boundaries so transient `Surfacing...` tool-status bubbles do not linger when no matching final preview arrives. Thanks @BunsDev. - Slack: report `unknown error` instead of `undefined` in socket-mode startup retry logs and label the retry reason explicitly. - Telegram: let explicit forum-topic `requireMention` settings override persisted `/activate` and `/deactivate` state, so per-topic mention gates work consistently. Fixes #49864. Thanks @Panniantong. diff --git a/docker-compose.yml b/docker-compose.yml index 8f8193d1f79..5b89f900465 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,14 @@ services: environment: HOME: /home/node TERM: xterm-256color + # Pin container-side workspace and config paths so host values written to + # `.env` (used by Compose for the bind-mount source below) cannot leak + # into runtime code that resolves these env vars inside the container. + # Without this override, a macOS host path like /Users//.openclaw/... + # imported from .env caused first-reply `mkdir '/Users'` EACCES failures + # in Linux Docker (#77436). + OPENCLAW_CONFIG_DIR: /home/node/.openclaw + OPENCLAW_WORKSPACE_DIR: /home/node/.openclaw/workspace OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN:-} OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-} # Empty means auto: Bonjour disables itself in detected containers. @@ -85,6 +93,10 @@ services: environment: HOME: /home/node TERM: xterm-256color + # Pin container-side workspace and config paths so host values written to + # `.env` cannot leak into runtime code via the env_file import (#77436). + OPENCLAW_CONFIG_DIR: /home/node/.openclaw + OPENCLAW_WORKSPACE_DIR: /home/node/.openclaw/workspace OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN:-} OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-} BROWSER: echo diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index 78293f65f5f..5508081cc5a 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -623,4 +623,16 @@ describe("scripts/docker/setup.sh", () => { const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8"); expect(compose.match(/TZ: \$\{OPENCLAW_TZ:-UTC\}/g)).toHaveLength(2); }); + + it("pins container-side workspace and config dirs on both services so host .env paths cannot leak (#77436)", async () => { + const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8"); + // Both gateway and CLI services must override the env_file values with the + // canonical container paths so a host-style OPENCLAW_WORKSPACE_DIR like + // `/Users//.openclaw/workspace` written to `.env` by docker-setup.sh + // cannot reach runtime code inside Linux Docker. + expect(compose.match(/OPENCLAW_CONFIG_DIR: \/home\/node\/\.openclaw$/gm)).toHaveLength(2); + expect( + compose.match(/OPENCLAW_WORKSPACE_DIR: \/home\/node\/\.openclaw\/workspace$/gm), + ).toHaveLength(2); + }); }); From ab032675ce09740f72fe4b8ef757338a4a65a173 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 5 May 2026 00:07:04 +0100 Subject: [PATCH 105/107] fix: simplify gateway model startup modes --- CHANGELOG.md | 2 +- docs/gateway/logging.md | 6 +-- src/gateway/server-startup-log.test.ts | 43 ++++++++++++++++-- src/gateway/server-startup-log.ts | 63 +++++++++++++++++++++----- 4 files changed, 95 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 081c8f7187b..0c6453204a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,7 +59,7 @@ Docs: https://docs.openclaw.ai ### Fixes -- Gateway/startup: include the resolved thinking, reasoning, and fast-mode defaults in the `agent model` startup log line so operator logs show which mode new sessions will inherit. +- Gateway/startup: include resolved thinking and fast-mode defaults in the `agent model` startup log line, defaulting unset startup thinking to `medium` without mixing in reasoning visibility. - Gateway/watch: suppress sync-I/O trace output during `pnpm gateway:watch --benchmark` unless explicitly requested, so CPU profiling no longer floods the terminal with stack traces. - Gateway/watch: when benchmark sync-I/O tracing is explicitly enabled, tee trace blocks to the benchmark output log and filter them from the terminal pane while keeping normal Gateway logs visible. - Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc. diff --git a/docs/gateway/logging.md b/docs/gateway/logging.md index 27624176054..c70a5b7252b 100644 --- a/docs/gateway/logging.md +++ b/docs/gateway/logging.md @@ -19,12 +19,12 @@ At startup, the Gateway logs the resolved default agent model together with the mode defaults that affect new sessions, for example: ```text -agent model: openai-codex/gpt-5.5 (thinking=medium, reasoning=off, fast=on) +agent model: openai-codex/gpt-5.5 (thinking=medium, fast=on) ``` `thinking` comes from the default agent, model params, or global agent default; -`reasoning` comes from the default agent or global reasoning default; and `fast` -comes from the default agent or model `fastMode` params. +when it is unset, the startup summary shows `medium`. `fast` comes from the +default agent or model `fastMode` params. ## File-based logger diff --git a/src/gateway/server-startup-log.test.ts b/src/gateway/server-startup-log.test.ts index b2db0749ef1..781d0e3f3e0 100644 --- a/src/gateway/server-startup-log.test.ts +++ b/src/gateway/server-startup-log.test.ts @@ -49,7 +49,7 @@ describe("gateway startup log", () => { expect(warn).not.toHaveBeenCalled(); }); - it("logs configured model mode defaults with the startup model", () => { + it("logs configured model thinking and fast mode defaults with the startup model", () => { const info = vi.fn(); const warn = vi.fn(); @@ -78,15 +78,50 @@ describe("gateway startup log", () => { }); expect(info).toHaveBeenCalledWith( - "agent model: openai-codex/gpt-5.5 (thinking=medium, reasoning=stream, fast=on)", + "agent model: openai-codex/gpt-5.5 (thinking=medium, fast=on)", expect.objectContaining({ consoleMessage: expect.stringContaining( - "agent model: openai-codex/gpt-5.5 (thinking=medium, reasoning=stream, fast=on)", + "agent model: openai-codex/gpt-5.5 (thinking=medium, fast=on)", ), }), ); }); + it("defaults unset startup thinking to medium", () => { + expect( + formatAgentModelStartupDetails({ + cfg: { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + list: [{ id: "main", default: true, fastModeDefault: true }], + }, + }, + provider: "openai-codex", + model: "gpt-5.5", + }), + ).toBe("thinking=medium, fast=on"); + }); + + it("preserves explicit startup thinking off", () => { + expect( + formatAgentModelStartupDetails({ + cfg: { + agents: { + defaults: { + models: { + "openai-codex/gpt-5.5": { params: { thinking: "off", fastMode: true } }, + }, + }, + }, + }, + provider: "openai-codex", + model: "gpt-5.5", + }), + ).toBe("thinking=off, fast=on"); + }); + it("uses default agent mode overrides in the startup model details", () => { expect( formatAgentModelStartupDetails({ @@ -105,7 +140,7 @@ describe("gateway startup log", () => { provider: "openai", model: "gpt-5.5", }), - ).toBe("thinking=high, reasoning=off, fast=on"); + ).toBe("thinking=high, fast=on"); }); it("logs a compact listening line with loaded plugin ids and duration", () => { diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts index b50fdb48642..1e355072394 100644 --- a/src/gateway/server-startup-log.ts +++ b/src/gateway/server-startup-log.ts @@ -4,13 +4,24 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveFastModeState } from "../agents/fast-mode.js"; import { resolveConfiguredModelRef, - resolveReasoningDefault, resolveThinkingDefault, + legacyModelKey, + modelKey, } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getResolvedLoggerSettings } from "../logging.js"; import { collectEnabledInsecureOrDangerousFlags } from "../security/dangerous-config-flags.js"; +type StartupThinkLevel = + | "off" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "adaptive" + | "max"; + export function logGatewayStartup(params: { cfg: OpenClawConfig; bindHost: string; @@ -57,6 +68,36 @@ export function logGatewayStartup(params: { } } +function normalizeStartupThinkLevel(value: unknown): StartupThinkLevel | undefined { + return value === "off" || + value === "minimal" || + value === "low" || + value === "medium" || + value === "high" || + value === "xhigh" || + value === "adaptive" || + value === "max" + ? value + : undefined; +} + +function resolveExplicitStartupThinking(params: { + cfg: OpenClawConfig; + provider: string; + model: string; + defaultAgentThinking: unknown; +}): StartupThinkLevel | undefined { + const models = params.cfg.agents?.defaults?.models; + const canonicalKey = modelKey(params.provider, params.model); + const legacyKey = legacyModelKey(params.provider, params.model); + return ( + normalizeStartupThinkLevel(params.defaultAgentThinking) ?? + normalizeStartupThinkLevel(models?.[canonicalKey]?.params?.thinking) ?? + normalizeStartupThinkLevel(legacyKey ? models?.[legacyKey]?.params?.thinking : undefined) ?? + normalizeStartupThinkLevel(params.cfg.agents?.defaults?.thinkingDefault) + ); +} + export function formatAgentModelStartupDetails(params: { cfg: OpenClawConfig; provider: string; @@ -64,20 +105,20 @@ export function formatAgentModelStartupDetails(params: { }): string { const defaultAgentId = resolveDefaultAgentId(params.cfg); const defaultAgentConfig = resolveAgentConfig(params.cfg, defaultAgentId); - const thinking = - defaultAgentConfig?.thinkingDefault ?? + const explicitThinking = resolveExplicitStartupThinking({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + defaultAgentThinking: defaultAgentConfig?.thinkingDefault, + }); + const resolvedThinking = + explicitThinking ?? resolveThinkingDefault({ cfg: params.cfg, provider: params.provider, model: params.model, }); - const reasoning = - defaultAgentConfig?.reasoningDefault ?? - params.cfg.agents?.defaults?.reasoningDefault ?? - resolveReasoningDefault({ - provider: params.provider, - model: params.model, - }); + const thinking = explicitThinking ?? (resolvedThinking === "off" ? "medium" : resolvedThinking); const fast = resolveFastModeState({ cfg: params.cfg, provider: params.provider, @@ -85,7 +126,7 @@ export function formatAgentModelStartupDetails(params: { agentId: defaultAgentId, }); - return `thinking=${thinking}, reasoning=${reasoning}, fast=${fast.enabled ? "on" : "off"}`; + return `thinking=${thinking}, fast=${fast.enabled ? "on" : "off"}`; } function formatReadyDetails( From edb697e389556537ac15315698329b3280337bb7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 15:23:38 -0700 Subject: [PATCH 106/107] test(extensions): refresh dependency-backed assertions --- extensions/bonjour/manifest.test.ts | 4 ++-- extensions/opencode-go/index.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/bonjour/manifest.test.ts b/extensions/bonjour/manifest.test.ts index 8ae64c5759c..1e8ba0e246c 100644 --- a/extensions/bonjour/manifest.test.ts +++ b/extensions/bonjour/manifest.test.ts @@ -15,8 +15,8 @@ describe("bonjour package manifest", () => { fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"), ) as PackageManifest; - expect(pluginPackageJson.dependencies?.["@homebridge/ciao"]).toBe("^1.3.7"); - expect(rootPackageJson.dependencies?.["@homebridge/ciao"]).toBe("^1.3.7"); + expect(pluginPackageJson.dependencies?.["@homebridge/ciao"]).toBe("^1.3.8"); + expect(rootPackageJson.dependencies?.["@homebridge/ciao"]).toBe("^1.3.8"); expect(pluginPackageJson.devDependencies?.["@homebridge/ciao"]).toBeUndefined(); }); }); diff --git a/extensions/opencode-go/index.test.ts b/extensions/opencode-go/index.test.ts index 3201d1278b0..cb66a3694e3 100644 --- a/extensions/opencode-go/index.test.ts +++ b/extensions/opencode-go/index.test.ts @@ -97,8 +97,8 @@ describe("opencode-go provider plugin", () => { maxTokens: 65_536, }); expect(models.get("minimax-m2.7")).toMatchObject({ - api: "anthropic-messages", - baseUrl: "https://opencode.ai/zen/go", + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/go/v1", reasoning: true, contextWindow: 204_800, maxTokens: 131_072, From 9eed48fde5a9e742b69803b4895bd6f3f45ca821 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 16:14:50 -0700 Subject: [PATCH 107/107] test(docker): align published upgrade timeout --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c82af822f5c..2b6916311e1 100644 --- a/package.json +++ b/package.json @@ -1562,7 +1562,7 @@ "test:docker:plugin-lifecycle-matrix": "bash scripts/e2e/plugin-lifecycle-matrix-docker.sh", "test:docker:plugin-update": "bash scripts/e2e/plugin-update-unchanged-docker.sh", "test:docker:plugins": "bash scripts/e2e/plugins-docker.sh", - "test:docker:published-upgrade-survivor": "env OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@latest} bash scripts/e2e/upgrade-survivor-docker.sh", + "test:docker:published-upgrade-survivor": "env OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@latest} OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT=${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-1500s} bash scripts/e2e/upgrade-survivor-docker.sh", "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", "test:docker:rerun": "node scripts/docker-e2e-rerun.mjs", "test:docker:session-runtime-context": "bash scripts/e2e/session-runtime-context-docker.sh",