fix: harden small runtime and installer edge cases (#100258)

* fix(media): preserve dollar sequences in transcript echoes

Co-authored-by: uditDewan <udit.dewan21@gmail.com>

* fix(mcp): catch rejected gateway event handlers

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

* fix(auto-reply): support punctuated silent token prefixes

Co-authored-by: simon-w <weng.qimeng@xydigit.com>

* fix(discord): keep voice log previews UTF-16 safe

Co-authored-by: sunlit-deng <yang.jiajun1@xydigit.com>

* fix(clawrouter): bound usage response reads

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

* fix(browser): bound CDP JSON response reads

Co-authored-by: hailory <hailory@xydigit.com>

* fix(status): include current time in session status

Co-authored-by: connermo <conner.mo@gmail.com>

* fix(installer): require Arch detection before pacman

Co-authored-by: Iliya Abolghasemi <gfaerny@gmail.com>

* fix(apps): default TLS gateway deep links to port 443

Co-authored-by: ben.li <li.yang6@xydigit.com>

* fix(infra): keep Undici terminated exceptions nonfatal

Co-authored-by: harjoth <harjoth.khara@gmail.com>

* fix(auto-reply): avoid Unicode-unsafe token spread

---------

Co-authored-by: uditDewan <udit.dewan21@gmail.com>
Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>
Co-authored-by: simon-w <weng.qimeng@xydigit.com>
Co-authored-by: sunlit-deng <yang.jiajun1@xydigit.com>
Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>
Co-authored-by: hailory <hailory@xydigit.com>
Co-authored-by: connermo <conner.mo@gmail.com>
Co-authored-by: Iliya Abolghasemi <gfaerny@gmail.com>
Co-authored-by: ben.li <li.yang6@xydigit.com>
Co-authored-by: harjoth <harjoth.khara@gmail.com>
This commit is contained in:
Peter Steinberger
2026-07-05 05:33:11 -07:00
committed by GitHub
parent 43fd44a28c
commit deac98eb72
24 changed files with 358 additions and 75 deletions

View File

@@ -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
}

View File

@@ -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")!

View File

@@ -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);
});

View File

@@ -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<T>(
): Promise<T> {
const { response, release } = await fetchCdpChecked(url, timeoutMs, init, ssrfPolicy);
try {
return (await response.json()) as T;
return await readProviderJsonResponse<T>(response, "cdp-json");
} finally {
await release();
}

View File

@@ -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<ChromeVersion>(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 };

View File

@@ -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<Record<string, unknown>> {
const raw = await fsp.readFile(filePath, "utf-8");
return JSON.parse(raw) as Record<string, unknown>;
@@ -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();

View File

@@ -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");
});
});

View File

@@ -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<ClawRouterUsagePayload> {
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);

View File

@@ -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);
});
});

View File

@@ -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)}...`;
}

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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";
}

View File

@@ -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"),

View File

@@ -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;

View File

@@ -34,6 +34,10 @@ type BridgeInternals = {
event: string;
payload?: Record<string, unknown>;
}) => Promise<void>;
dispatchGatewayEvent: (event: {
event: string;
payload?: Record<string, unknown>;
}) => Promise<void>;
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 {

View File

@@ -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<void> {
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<void> {
switch (event.event) {
case "session.message":

View File

@@ -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" }),

View File

@@ -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. */

View File

@@ -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({

View File

@@ -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,

View File

@@ -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