diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift index caee76a5100f..bdcaeb9d3cce 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -253,8 +253,8 @@ public enum DeepLinkParser { else { return nil } - let port = query["port"].flatMap { Int($0) } ?? 18789 let tls = (query["tls"] as NSString?)?.boolValue ?? false + let port = query["port"].flatMap { Int($0) } ?? (tls ? 443 : 18789) if !tls, !LoopbackHost.isLocalNetworkHost(hostParam) { return nil } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift index c93eabbbcfe5..cfadb583585a 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift @@ -21,6 +21,19 @@ private func setupCode(from payload: String) -> String { #expect(DeepLinkParser.parse(url) == .dashboard) } + @Test func gatewayDeepLinkUsesTlsDefaultPortWhenPortMissing() { + let url = URL(string: "openclaw://gateway?host=gateway.example.com&tls=1&token=abc")! + #expect( + DeepLinkParser.parse(url) == .gateway( + .init( + host: "gateway.example.com", + port: 443, + tls: true, + bootstrapToken: nil, + token: "abc", + password: nil))) + } + @Test func gatewayDeepLinkRejectsInsecureNonLoopbackWs() { let url = URL( string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")! diff --git a/extensions/browser/src/browser/cdp.helpers.test.ts b/extensions/browser/src/browser/cdp.helpers.test.ts index 1699fb1defd5..59015df9625b 100644 --- a/extensions/browser/src/browser/cdp.helpers.test.ts +++ b/extensions/browser/src/browser/cdp.helpers.test.ts @@ -38,15 +38,16 @@ describe("cdp helpers", () => { it("releases guarded CDP fetches after the response body is consumed", async () => { const release = vi.fn(async () => {}); - const json = vi.fn(async () => { + const arrayBuffer = vi.fn(async () => { expect(release).not.toHaveBeenCalled(); - return { ok: true }; + return new TextEncoder().encode(JSON.stringify({ ok: true })).buffer; }); fetchWithSsrFGuardMock.mockResolvedValueOnce({ response: { ok: true, status: 200, - json, + body: null, + arrayBuffer, }, release, }); @@ -58,7 +59,20 @@ describe("cdp helpers", () => { }), ).resolves.toEqual({ ok: true }); - expect(json).toHaveBeenCalledTimes(1); + expect(arrayBuffer).toHaveBeenCalledTimes(1); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("rejects oversized CDP JSON responses before parsing", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(new Uint8Array(16 * 1024 * 1024 + 1)), + release, + }); + + await expect(fetchJson("http://127.0.0.1:9222/json/version")).rejects.toThrow( + "cdp-json: JSON response exceeds 16777216 bytes", + ); expect(release).toHaveBeenCalledTimes(1); }); diff --git a/extensions/browser/src/browser/cdp.helpers.ts b/extensions/browser/src/browser/cdp.helpers.ts index 531931d63ff8..12041a24796a 100644 --- a/extensions/browser/src/browser/cdp.helpers.ts +++ b/extensions/browser/src/browser/cdp.helpers.ts @@ -5,6 +5,7 @@ * redaction/headers, and request/response correlation over WebSocket. */ import { parseBrowserHttpUrl, redactCdpUrl } from "openclaw/plugin-sdk/browser-config"; +import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; import { sleep } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import WebSocket from "ws"; @@ -317,7 +318,7 @@ export async function fetchJson( ): Promise { const { response, release } = await fetchCdpChecked(url, timeoutMs, init, ssrfPolicy); try { - return (await response.json()) as T; + return await readProviderJsonResponse(response, "cdp-json"); } finally { await release(); } diff --git a/extensions/browser/src/browser/chrome.diagnostics.ts b/extensions/browser/src/browser/chrome.diagnostics.ts index b2432e8a85c2..06e5a205575a 100644 --- a/extensions/browser/src/browser/chrome.diagnostics.ts +++ b/extensions/browser/src/browser/chrome.diagnostics.ts @@ -1,3 +1,4 @@ +import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; /** * Chrome CDP diagnostics. * @@ -110,7 +111,7 @@ export async function readChromeVersion( ssrfPolicy, ); try { - const data = (await response.json()) as ChromeVersion; + const data = await readProviderJsonResponse(response, "cdp-version"); if (!data || typeof data !== "object") { throw new Error("CDP /json/version returned non-object JSON"); } @@ -250,7 +251,11 @@ function classifyChromeVersionError(error: unknown): { if (/^HTTP \d+/.test(message)) { return { code: "http_status_failed", message }; } - if (error instanceof SyntaxError || message.includes("non-object JSON")) { + if ( + error instanceof SyntaxError || + message.includes("cdp-version: malformed JSON response") || + message.includes("non-object JSON") + ) { return { code: "invalid_json", message }; } return { code: "http_unreachable", message }; diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index f89e26406539..6a12f9ac1363 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -59,6 +59,13 @@ function expectReadyChromeCdpDiagnostic( return diagnostic; } +function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }); +} + async function readJson(filePath: string): Promise> { const raw = await fsp.readFile(filePath, "utf-8"); return JSON.parse(raw) as Record; @@ -467,20 +474,11 @@ describe("browser chrome helpers", () => { it("reports reachability based on /json/version", async () => { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }), - } as unknown as Response), + vi.fn().mockResolvedValue(jsonResponse({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" })), ); await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(true); - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: false, - json: async () => ({}), - } as unknown as Response), - ); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({}, 500))); await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false); vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom"))); @@ -488,13 +486,7 @@ describe("browser chrome helpers", () => { }); it("diagnoses /json/version responses that omit the websocket URL", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ Browser: "Chrome/Mock" }), - } as unknown as Response), - ); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ Browser: "Chrome/Mock" }))); const diagnostic = expectFailedChromeCdpDiagnostic( await diagnoseChromeCdp("http://127.0.0.1:12345", 50, 50), @@ -503,13 +495,26 @@ describe("browser chrome helpers", () => { expect(diagnostic.cdpUrl).toBe("http://127.0.0.1:12345"); }); + it("preserves invalid-json diagnostics for bounded /json/version reads", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response("{", { + headers: { "content-type": "application/json" }, + }), + ), + ); + + const diagnostic = expectFailedChromeCdpDiagnostic( + await diagnoseChromeCdp("http://127.0.0.1:12345", 50, 50), + ); + expect(diagnostic.code).toBe("invalid_json"); + }); + it("allows loopback CDP probes while still blocking non-loopback private targets in strict SSRF mode", async () => { const fetchSpy = vi .fn() - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }), - } as unknown as Response) + .mockResolvedValueOnce(jsonResponse({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" })) .mockRejectedValue(new Error("should not be called")); vi.stubGlobal("fetch", fetchSpy); @@ -794,10 +799,7 @@ describe("browser chrome helpers", () => { // connections (Browserless/Browserbase-style provider). vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({}), // empty β€” no webSocketDebuggerUrl - } as unknown as Response), + vi.fn().mockResolvedValue(jsonResponse({})), // empty β€” no webSocketDebuggerUrl ); // A real WS server accepts the handshake. const wss = new WebSocketServer({ @@ -819,13 +821,7 @@ describe("browser chrome helpers", () => { }); it("falls back to a direct WS readiness check when /json/version has no debugger URL", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({}), - } as unknown as Response), - ); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({}))); const wss = new WebSocketServer({ port: 0, host: "127.0.0.1", @@ -859,13 +855,7 @@ describe("browser chrome helpers", () => { it("returns the original ws:// URL from getChromeWebSocketUrl when /json/version provides no debugger URL", async () => { // Covers the getChromeWebSocketUrl WS-fallback: discovery succeeds but // webSocketDebuggerUrl is absent β€” the original URL is returned as-is. - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({}), - } as unknown as Response), - ); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({}))); await expect(getChromeWebSocketUrl("ws://127.0.0.1:12345", 50)).resolves.toBe( "ws://127.0.0.1:12345", ); @@ -887,10 +877,7 @@ describe("browser chrome helpers", () => { it("stopOpenClawChrome escalates to SIGKILL when CDP stays reachable", async () => { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }), - } as unknown as Response), + vi.fn().mockResolvedValue(jsonResponse({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" })), ); const proc = makeChromeTestProc(); await stopChromeWithProc(proc, 1); @@ -915,10 +902,7 @@ describe("browser chrome helpers", () => { it("stopOpenClawChrome still releases the bypass when the SIGKILL fallback fires", async () => { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }), - } as unknown as Response), + vi.fn().mockResolvedValue(jsonResponse({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" })), ); const proc = makeChromeTestProc(); const release = vi.fn(); diff --git a/extensions/clawrouter/usage.test.ts b/extensions/clawrouter/usage.test.ts index ddf924336cb7..e3708b5998dd 100644 --- a/extensions/clawrouter/usage.test.ts +++ b/extensions/clawrouter/usage.test.ts @@ -82,4 +82,23 @@ describe("ClawRouter usage", () => { }), ).rejects.toThrow("ClawRouter usage request failed (HTTP 403)"); }); + + it("bounds successful usage response bodies", async () => { + const oversizedPayload = JSON.stringify({ + budget: { configured: false }, + usage: { summary: { requestCount: 1 } }, + padding: "x".repeat(1024 * 1024), + }); + + await expect( + fetchClawRouterUsage({ + token: "proxy-key", + timeoutMs: 5000, + fetchFn: async () => + new Response(oversizedPayload, { + headers: { "content-type": "application/json" }, + }), + }), + ).rejects.toThrow("ClawRouter usage response exceeds"); + }); }); diff --git a/extensions/clawrouter/usage.ts b/extensions/clawrouter/usage.ts index 5d31c053be61..0c78abd9c90b 100644 --- a/extensions/clawrouter/usage.ts +++ b/extensions/clawrouter/usage.ts @@ -1,6 +1,9 @@ import type { ProviderUsageSnapshot } from "openclaw/plugin-sdk/provider-usage"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { normalizeClawRouterRootUrl } from "./provider-catalog.js"; +const CLAWROUTER_USAGE_RESPONSE_MAX_BYTES = 1024 * 1024; + type ClawRouterBudget = { configured?: unknown; ledger?: unknown; @@ -62,6 +65,19 @@ function buildSummary(payload: ClawRouterUsagePayload): string | undefined { return parts.length > 0 ? parts.join(" Β· ") : undefined; } +async function readClawRouterUsagePayload( + response: Response, + timeoutMs: number, +): Promise { + const buffer = await readResponseWithLimit(response, CLAWROUTER_USAGE_RESPONSE_MAX_BYTES, { + chunkTimeoutMs: timeoutMs, + onOverflow: ({ maxBytes }) => new Error(`ClawRouter usage response exceeds ${maxBytes} bytes`), + onIdleTimeout: ({ chunkTimeoutMs }) => + new Error(`ClawRouter usage response stalled: no data received for ${chunkTimeoutMs}ms`), + }); + return JSON.parse(new TextDecoder().decode(buffer)) as ClawRouterUsagePayload; +} + export async function fetchClawRouterUsage(params: { token: string; baseUrl?: string; @@ -78,7 +94,7 @@ export async function fetchClawRouterUsage(params: { if (!response.ok) { throw new Error(`ClawRouter usage request failed (HTTP ${response.status})`); } - const payload = (await response.json()) as ClawRouterUsagePayload; + const payload = await readClawRouterUsagePayload(response, params.timeoutMs); const budget = payload.budget; const limitMicros = nonNegativeNumber(budget?.limitMicros); const spentMicros = nonNegativeNumber(budget?.spentMicros); diff --git a/extensions/discord/src/voice/log-preview.test.ts b/extensions/discord/src/voice/log-preview.test.ts index 04e011613125..8a51c194a0df 100644 --- a/extensions/discord/src/voice/log-preview.test.ts +++ b/extensions/discord/src/voice/log-preview.test.ts @@ -10,4 +10,10 @@ describe("formatVoiceLogPreview", () => { const preview = formatVoiceLogPreview("x".repeat(501)); expect(preview).toBe(`${"x".repeat(500)}...`); }); + + it("does not split emoji when the preview limit lands inside a surrogate pair", () => { + const preview = formatVoiceLogPreview(`${"x".repeat(499)}πŸ˜€tail`); + expect(preview).toBe(`${"x".repeat(499)}...`); + expect(preview).not.toMatch(/[\uD800-\uDFFF]/u); + }); }); diff --git a/extensions/discord/src/voice/log-preview.ts b/extensions/discord/src/voice/log-preview.ts index dccb618343ac..fda0554a3ff0 100644 --- a/extensions/discord/src/voice/log-preview.ts +++ b/extensions/discord/src/voice/log-preview.ts @@ -1,3 +1,5 @@ +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; + const DISCORD_VOICE_LOG_PREVIEW_CHARS = 500; export function formatVoiceLogPreview(text: string): string { @@ -5,5 +7,5 @@ export function formatVoiceLogPreview(text: string): string { if (oneLine.length <= DISCORD_VOICE_LOG_PREVIEW_CHARS) { return oneLine; } - return `${oneLine.slice(0, DISCORD_VOICE_LOG_PREVIEW_CHARS)}...`; + return `${truncateUtf16Safe(oneLine, DISCORD_VOICE_LOG_PREVIEW_CHARS)}...`; } diff --git a/extensions/matrix/src/matrix/monitor/preflight-audio.test.ts b/extensions/matrix/src/matrix/monitor/preflight-audio.test.ts index 6d4baa3138f5..786d3248caa5 100644 --- a/extensions/matrix/src/matrix/monitor/preflight-audio.test.ts +++ b/extensions/matrix/src/matrix/monitor/preflight-audio.test.ts @@ -164,6 +164,24 @@ describe("sendMatrixPreflightAudioTranscriptEcho", () => { }); }); + it("keeps dollar sequences in the transcript literal", async () => { + sendDurableMessageBatchMock.mockResolvedValue({ status: "sent", results: [] }); + await sendMatrixPreflightAudioTranscriptEcho({ + transcript: "tickets cost $$40, confirm with $&", + cfg: { + tools: { media: { audio: { echoTranscript: true, echoFormat: "heard: {transcript}" } } }, + } as import("openclaw/plugin-sdk/config-contracts").OpenClawConfig, + accountId: "ops", + originatingTo: "room:!room:example.org", + }); + + expect(sendDurableMessageBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ text: "heard: tickets cost $$40, confirm with $&" }], + }), + ); + }); + it("does not echo when transcript echo is disabled", async () => { await sendMatrixPreflightAudioTranscriptEcho({ transcript: "hello bot", diff --git a/extensions/matrix/src/matrix/monitor/preflight-audio.ts b/extensions/matrix/src/matrix/monitor/preflight-audio.ts index b93a1f6d75b5..08d0195a4bef 100644 --- a/extensions/matrix/src/matrix/monitor/preflight-audio.ts +++ b/extensions/matrix/src/matrix/monitor/preflight-audio.ts @@ -12,7 +12,9 @@ export function formatMatrixAudioTranscript(transcript: string): string { } function formatMatrixAudioTranscriptEcho(transcript: string, format: string): string { - return format.replace("{transcript}", transcript); + // Function replacer keeps `$` sequences in the transcript literal instead of + // being parsed as String.prototype.replace substitution patterns. + return format.replace("{transcript}", () => transcript); } function suppressMatrixPreflightAudioEcho(cfg: OpenClawConfig): OpenClawConfig { diff --git a/scripts/install.sh b/scripts/install.sh index b63b821a4301..6a7a3bf22d99 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -627,10 +627,6 @@ is_arch_linux() { return 0 fi fi - # Fallback: check for pacman - if command -v pacman &> /dev/null; then - return 0 - fi return 1 } @@ -677,7 +673,7 @@ install_build_tools_linux() { return 0 fi - if command -v pacman &> /dev/null || is_arch_linux; then + if command -v pacman &> /dev/null && is_arch_linux; then if is_root; then run_quiet_step "Installing build tools" pacman -Sy --noconfirm base-devel python make cmake gcc else @@ -1814,7 +1810,7 @@ install_node() { fi # Arch-based distros: use pacman with official repos - if command -v pacman &> /dev/null || is_arch_linux; then + if command -v pacman &> /dev/null && is_arch_linux; then ui_info "Installing Node.js via pacman (Arch-based distribution detected)" if is_root; then run_required_step "Installing Node.js" pacman -Sy --noconfirm nodejs npm @@ -1922,7 +1918,7 @@ install_git() { elif command -v apt-get &> /dev/null; then run_quiet_step "Updating package index" apt_get_update run_quiet_step "Installing Git" apt_get_install git - elif command -v pacman &> /dev/null || is_arch_linux; then + elif command -v pacman &> /dev/null && is_arch_linux; then if is_root; then run_quiet_step "Installing Git" pacman -Sy --noconfirm git else diff --git a/src/auto-reply/tokens.test.ts b/src/auto-reply/tokens.test.ts index 48c991f16706..2d597284e812 100644 --- a/src/auto-reply/tokens.test.ts +++ b/src/auto-reply/tokens.test.ts @@ -312,4 +312,30 @@ describe("isSilentReplyPrefixText", () => { expect(isSilentReplyPrefixText("NO_REPLY more")).toBe(false); expect(isSilentReplyPrefixText("NO-")).toBe(false); }); + + it("matches custom tokens with digits", () => { + expect(isSilentReplyPrefixText("NOREPLY2", "NOREPLY2")).toBe(true); + expect(isSilentReplyPrefixText("NOREPLY", "NOREPLY2")).toBe(false); + expect(isSilentReplyPrefixText("NORE", "NOREPLY2")).toBe(false); + expect(isSilentReplyPrefixText("NOR", "NOREPLY2")).toBe(false); + }); + + it("matches custom tokens with hyphens", () => { + expect(isSilentReplyPrefixText("NO-ANSWER", "NO-ANSWER")).toBe(true); + expect(isSilentReplyPrefixText("NO-AN", "NO-ANSWER")).toBe(true); + expect(isSilentReplyPrefixText("NO-", "NO-ANSWER")).toBe(true); + }); + + it("rejects non-matching prefixes for custom tokens", () => { + expect(isSilentReplyPrefixText("HE", "NOREPLY2")).toBe(false); + expect(isSilentReplyPrefixText("HELLO", "NO-ANSWER")).toBe(false); + expect(isSilentReplyPrefixText("NO-", "NOREPLY2")).toBe(false); + }); + + it("rejects pure-letter prefixes for punctuated tokens to avoid false suppression", () => { + expect(isSilentReplyPrefixText("HE", "HELP-QUIET")).toBe(false); + expect(isSilentReplyPrefixText("HELP", "HELP-QUIET")).toBe(false); + expect(isSilentReplyPrefixText("HELP-", "HELP-QUIET")).toBe(true); + expect(isSilentReplyPrefixText("HELP-QUIET", "HELP-QUIET")).toBe(true); + }); }); diff --git a/src/auto-reply/tokens.ts b/src/auto-reply/tokens.ts index 1ffde718dfb7..6f28a938b365 100644 --- a/src/auto-reply/tokens.ts +++ b/src/auto-reply/tokens.ts @@ -310,9 +310,6 @@ export function isSilentReplyPrefixText( if (normalized.length < 2) { return false; } - if (/[^A-Z_]/.test(normalized)) { - return false; - } const tokenUpper = token.toUpperCase(); if (!tokenUpper.startsWith(normalized)) { return false; @@ -320,8 +317,16 @@ export function isSilentReplyPrefixText( if (normalized.includes("_")) { return true; } - // Keep underscore guard for generic tokens to avoid suppressing unrelated - // uppercase words (e.g. HEART/HE with HEARTBEAT_OK). Only allow bare "NO" - // because NO_REPLY streaming can transiently emit that fragment. + // Full-token match is safe for any token. + if (normalized === tokenUpper) { + return true; + } + // For custom tokens containing non-letter characters (digits, hyphens), + // only match if the prefix includes at least one non-letter character + // from the token. Otherwise, a pure-letter prefix like "HE" for "HELP-QUIET" + // would suppress natural language that happens to share that prefix (#100007). + if (/[^A-Z_]/.test(tokenUpper)) { + return /[^A-Z_]/.test(normalized); + } return tokenUpper === SILENT_REPLY_TOKEN && normalized === "NO"; } diff --git a/src/infra/unhandled-rejections.test.ts b/src/infra/unhandled-rejections.test.ts index 0a4f7ecb258a..14141eed468e 100644 --- a/src/infra/unhandled-rejections.test.ts +++ b/src/infra/unhandled-rejections.test.ts @@ -364,6 +364,10 @@ describe("isTransientUnhandledRejectionError", () => { const wrappedWsPreHandshakeClose = Object.assign(new Error("feishu reconnect failed"), { cause: wsPreHandshakeClose, }); + const undiciTerminated = new TypeError("terminated"); + const wrappedUndiciTerminated = Object.assign(new Error("model fetch failed"), { + cause: undiciTerminated, + }); const generic = new Error("boom"); expect(isBenignUncaughtExceptionError(epipe)).toBe(true); @@ -380,6 +384,10 @@ describe("isTransientUnhandledRejectionError", () => { expect(isBenignUncaughtExceptionError(new Error("ERR_HTTP2_INVALID_SESSION"))).toBe(true); expect(isBenignUncaughtExceptionError(wsPreHandshakeClose)).toBe(true); expect(isBenignUncaughtExceptionError(wrappedWsPreHandshakeClose)).toBe(true); + expect(isBenignUncaughtExceptionError(undiciTerminated)).toBe(true); + expect(isBenignUncaughtExceptionError(wrappedUndiciTerminated)).toBe(true); + expect(isBenignUncaughtExceptionError(new Error("terminated"))).toBe(false); + expect(isBenignUncaughtExceptionError(new TypeError("terminated unexpectedly"))).toBe(false); expect( isBenignUncaughtExceptionError( new Error("WebSocket error: WebSocket was closed before the connection was established"), diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index 8d961766b172..30c79e5fd75f 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -114,6 +114,7 @@ const TRANSIENT_NETWORK_MESSAGE_CODE_RE = const BENIGN_UNCAUGHT_EXCEPTION_NETWORK_MESSAGE_CODE_RE = /\b(ECONNREFUSED|ENETDOWN|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT|ERR_HTTP2_INVALID_SESSION)\b/i; const WS_PRE_HANDSHAKE_CLOSE_MESSAGE = "websocket was closed before the connection was established"; +const UNDICI_TERMINATED_TYPE_ERROR_MESSAGE = "terminated"; const TRANSIENT_SQLITE_MESSAGE_CODE_RE = /\b(SQLITE_BUSY|SQLITE_CANTOPEN|SQLITE_IOERR|SQLITE_LOCKED)\b/i; @@ -422,6 +423,15 @@ export function isTransientUnhandledRejectionError(err: unknown): boolean { function isBenignUncaughtNetworkException(err: unknown): boolean { for (const candidate of collectNestedUnhandledErrorCandidates(err)) { + // Undici emits this bare TypeError when a response body aborts after request start. + // Keep the shape exact so unrelated "terminated" errors still take the fatal path. + if ( + candidate instanceof TypeError && + normalizeLowercaseStringOrEmpty(candidate.message) === UNDICI_TERMINATED_TYPE_ERROR_MESSAGE + ) { + return true; + } + const code = extractErrorCodeOrErrno(candidate); if (code && BENIGN_UNCAUGHT_EXCEPTION_NETWORK_CODES.has(code)) { return true; diff --git a/src/mcp/channel-bridge.test.ts b/src/mcp/channel-bridge.test.ts index 420f55065d37..ede51f583b64 100644 --- a/src/mcp/channel-bridge.test.ts +++ b/src/mcp/channel-bridge.test.ts @@ -34,6 +34,10 @@ type BridgeInternals = { event: string; payload?: Record; }) => Promise; + dispatchGatewayEvent: (event: { + event: string; + payload?: Record; + }) => Promise; handleSessionMessageEvent: (payload: { sessionKey: string; senderIsOwner?: boolean; @@ -304,6 +308,52 @@ describe("OpenClawChannelBridge β€” pendingClaudePermissions / pendingApprovals } }); + test("a rejected gateway event still emits exactly one diagnostic record with verbose off", async () => { + const bridge = makeBridge(false); + const writes: string[] = []; + const writeSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk: string | Uint8Array): boolean => { + writes.push(String(chunk)); + return true; + }); + vi.spyOn(bridge, "handleGatewayEvent").mockRejectedValue(new Error("handler boom")); + try { + await bridge.dispatchGatewayEvent({ event: "exec.approval.requested", payload: {} }); + + expect(writes).toHaveLength(1); + expect(writes[0]).toBe("openclaw mcp: gateway event exec.approval.requested failed\n"); + expect(writes[0]).not.toContain("handler boom"); + } finally { + writeSpy.mockRestore(); + await bridge.close(); + } + }); + + test("a rejected gateway event includes error detail with verbose on", async () => { + const bridge = makeBridge(true); + const writes: string[] = []; + const writeSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk: string | Uint8Array): boolean => { + writes.push(String(chunk)); + return true; + }); + vi.spyOn(bridge, "handleGatewayEvent").mockRejectedValue(new Error("handler boom")); + try { + await bridge.dispatchGatewayEvent({ event: "exec.approval.requested", payload: {} }); + + expect(writes).toHaveLength(2); + expect(writes[0]).toBe("openclaw mcp: gateway event exec.approval.requested failed\n"); + expect(writes[1]).toBe( + "openclaw mcp: gateway event exec.approval.requested error: Error: handler boom\n", + ); + } finally { + writeSpy.mockRestore(); + await bridge.close(); + } + }); + test("sweeper interval is not started before any pending entry is added", async () => { const bridge = makeBridge(); try { diff --git a/src/mcp/channel-bridge.ts b/src/mcp/channel-bridge.ts index b8c03d666549..eb869af8af99 100644 --- a/src/mcp/channel-bridge.ts +++ b/src/mcp/channel-bridge.ts @@ -148,7 +148,7 @@ export class OpenClawChannelBridge { scopes: [READ_SCOPE, WRITE_SCOPE, APPROVALS_SCOPE], requestTimeoutMs: 180_000, onEvent: (event) => { - void this.handleGatewayEvent(event); + void this.dispatchGatewayEvent(event); }, onHelloOk: () => { this.retryingInitialConnect = false; @@ -521,6 +521,21 @@ export class OpenClawChannelBridge { } } + private async dispatchGatewayEvent(event: EventFrame): Promise { + try { + await this.handleGatewayEvent(event); + } catch (error) { + // Always surface a single low-noise record so swallowed gateway event + // failures remain observable; the spammy error detail stays behind --verbose. + process.stderr.write(`openclaw mcp: gateway event ${event.event} failed\n`); + if (this.verbose) { + process.stderr.write( + `openclaw mcp: gateway event ${event.event} error: ${String(error)}\n`, + ); + } + } + } + private async handleGatewayEvent(event: EventFrame): Promise { switch (event.event) { case "session.message": diff --git a/src/media-understanding/echo-transcript.test.ts b/src/media-understanding/echo-transcript.test.ts index baef2e207f12..c6ff33234fb0 100644 --- a/src/media-understanding/echo-transcript.test.ts +++ b/src/media-understanding/echo-transcript.test.ts @@ -88,6 +88,20 @@ describe("sendTranscriptEcho", () => { }); }); + it("keeps dollar sequences in the transcript literal", async () => { + await sendTranscriptEcho({ + ctx: createCtx(), + cfg: EMPTY_CONFIG, + transcript: "tickets cost $$40, wait for the deal & confirm with $&", + }); + + expect(mockDeliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ text: 'πŸ“ "tickets cost $$40, wait for the deal & confirm with $&"' }], + }), + ); + }); + it("skips non-deliverable channels", async () => { await sendTranscriptEcho({ ctx: createCtx({ Provider: "internal-system", From: "some-source" }), diff --git a/src/media-understanding/echo-transcript.ts b/src/media-understanding/echo-transcript.ts index e25c615218be..56c5355cefef 100644 --- a/src/media-understanding/echo-transcript.ts +++ b/src/media-understanding/echo-transcript.ts @@ -15,7 +15,9 @@ const loadMessageRuntime = createLazyRuntimeModule(() => import("../channels/mes export const DEFAULT_ECHO_TRANSCRIPT_FORMAT = 'πŸ“ "{transcript}"'; function formatEchoTranscript(transcript: string, format: string): string { - return format.replace("{transcript}", transcript); + // Function replacer keeps `$` sequences in the transcript literal instead of + // being parsed as String.prototype.replace substitution patterns. + return format.replace("{transcript}", () => transcript); } /** Sends a best-effort transcript echo back to the originating deliverable chat. */ diff --git a/src/status/status-message.test.ts b/src/status/status-message.test.ts index ce3f64a5e944..e9ba872b5b56 100644 --- a/src/status/status-message.test.ts +++ b/src/status/status-message.test.ts @@ -20,6 +20,26 @@ afterEach(() => { cliBackendsTesting.resetDepsForTest(); }); +describe("buildStatusMessage current time", () => { + it("surfaces a live current-time line so session_status returns the date/time", () => { + // 2025-07-03T08:00:00Z; the Reference UTC line is timezone-independent. + const now = 1_751_529_600_000; + const text = buildStatusMessage({ + now, + config: { agents: { defaults: { userTimezone: "UTC", timeFormat: "24" } } }, + agent: { model: "anthropic/claude-haiku-4-5" }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + queue: { mode: "steer", depth: 0 }, + modelAuth: "api-key", + }); + + expect(text).toContain("Current time:"); + expect(text).toContain("(UTC)"); + expect(text).toContain("Reference UTC: 2025-07-03 08:00 UTC"); + }); +}); + describe("buildStatusMessage context window", () => { it("ignores stale runtime context after a manual session model switch", () => { const text = buildStatusMessage({ diff --git a/src/status/status-message.ts b/src/status/status-message.ts index cb729299b87c..3c182f687277 100644 --- a/src/status/status-message.ts +++ b/src/status/status-message.ts @@ -7,6 +7,7 @@ import { normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; import { resolveContextTokensForModel } from "../agents/context.js"; +import { resolveCronStyleNow } from "../agents/current-time.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveExtraParams } from "../agents/embedded-agent-runner/extra-params.js"; import { resolveFastModeState } from "../agents/fast-mode.js"; @@ -563,6 +564,10 @@ function hasUserPinnedModelSelection(entry: SessionEntry | undefined): boolean { export function buildStatusMessage(args: StatusArgs): string { const now = args.now ?? Date.now(); + // Derive the live wall clock here so both /status and session_status expose + // the same configured timezone without duplicating formatting at each caller. + const timeLine = + args.timeLine ?? (args.config ? resolveCronStyleNow(args.config, now).timeLine : undefined); const entry = args.sessionEntry; const selectionConfig = { agents: { @@ -1113,7 +1118,7 @@ export function buildStatusMessage(args: StatusArgs): string { return [ versionLine, - args.timeLine, + timeLine, args.uptimeLine, ...modelLines, configuredFallbacksLine, diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts index 111182a22bab..a68b549848a7 100644 --- a/test/scripts/install-sh.test.ts +++ b/test/scripts/install-sh.test.ts @@ -194,6 +194,58 @@ NODE expect(result.stdout).not.toContain("Installing Node.js via NodeSource"); }); + it("ignores an unrelated pacman command on Debian", () => { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + OS=linux + require_sudo() { :; } + install_build_tools_linux() { return 0; } + is_root() { return 0; } + is_arch_linux() { return 1; } + is_alpine_linux() { return 1; } + pacman() { printf 'pacman:%s\\n' "$*"; } + apt-get() { :; } + ui_info() { printf 'info:%s\\n' "$*"; } + ui_success() { :; } + run_required_step() { printf 'step:%s|%s\\n' "$1" "\${*:2}"; } + finish_linux_node_install() { printf 'finish-linux-node\\n'; } + install_node + `); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("info:Installing Node.js via NodeSource"); + expect(result.stdout).toContain("step:Installing Node.js|apt_get_install nodejs"); + expect(result.stdout).toContain("finish-linux-node"); + expect(result.stdout).not.toContain("pacman:"); + }); + + it("uses pacman for Node.js on Arch Linux", () => { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + OS=linux + require_sudo() { :; } + install_build_tools_linux() { return 0; } + is_root() { return 0; } + is_arch_linux() { return 0; } + pacman() { :; } + ui_info() { printf 'info:%s\\n' "$*"; } + ui_success() { :; } + run_required_step() { printf 'step:%s|%s\\n' "$1" "\${*:2}"; } + finish_linux_node_install() { printf 'finish-linux-node\\n'; } + install_node + `); + + expect(result.status).toBe(0); + expect(result.stdout).toContain( + "info:Installing Node.js via pacman (Arch-based distribution detected)", + ); + expect(result.stdout).toContain("step:Installing Node.js|pacman -Sy --noconfirm nodejs npm"); + expect(result.stdout).toContain("finish-linux-node"); + expect(result.stdout).not.toContain("Installing Node.js via NodeSource"); + }); + it("tries nodejs-current when Alpine nodejs is below the runtime floor", () => { const result = runInstallShell(` set -euo pipefail