Skip to content

Commit deac98e

Browse files
steipeteUditDewancxbAsDevSimon-XYDTsunlit-deng
authored
fix: harden small runtime and installer edge cases (#100258)
* fix(media): preserve dollar sequences in transcript echoes Co-authored-by: uditDewan <[email protected]> * fix(mcp): catch rejected gateway event handlers Co-authored-by: 陈宪彪0668000387 <[email protected]> * fix(auto-reply): support punctuated silent token prefixes Co-authored-by: simon-w <[email protected]> * fix(discord): keep voice log previews UTF-16 safe Co-authored-by: sunlit-deng <[email protected]> * fix(clawrouter): bound usage response reads Co-authored-by: 杨浩宇0668001029 <[email protected]> * fix(browser): bound CDP JSON response reads Co-authored-by: hailory <[email protected]> * fix(status): include current time in session status Co-authored-by: connermo <[email protected]> * fix(installer): require Arch detection before pacman Co-authored-by: Iliya Abolghasemi <[email protected]> * fix(apps): default TLS gateway deep links to port 443 Co-authored-by: ben.li <[email protected]> * fix(infra): keep Undici terminated exceptions nonfatal Co-authored-by: harjoth <[email protected]> * fix(auto-reply): avoid Unicode-unsafe token spread --------- Co-authored-by: uditDewan <[email protected]> Co-authored-by: 陈宪彪0668000387 <[email protected]> Co-authored-by: simon-w <[email protected]> Co-authored-by: sunlit-deng <[email protected]> Co-authored-by: 杨浩宇0668001029 <[email protected]> Co-authored-by: hailory <[email protected]> Co-authored-by: connermo <[email protected]> Co-authored-by: Iliya Abolghasemi <[email protected]> Co-authored-by: ben.li <[email protected]> Co-authored-by: harjoth <[email protected]>
1 parent 43fd44a commit deac98e

24 files changed

Lines changed: 357 additions & 74 deletions

File tree

apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,8 @@ public enum DeepLinkParser {
253253
else {
254254
return nil
255255
}
256-
let port = query["port"].flatMap { Int($0) } ?? 18789
257256
let tls = (query["tls"] as NSString?)?.boolValue ?? false
257+
let port = query["port"].flatMap { Int($0) } ?? (tls ? 443 : 18789)
258258
if !tls, !LoopbackHost.isLocalNetworkHost(hostParam) {
259259
return nil
260260
}

apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ private func setupCode(from payload: String) -> String {
2121
#expect(DeepLinkParser.parse(url) == .dashboard)
2222
}
2323

24+
@Test func gatewayDeepLinkUsesTlsDefaultPortWhenPortMissing() {
25+
let url = URL(string: "openclaw://gateway?host=gateway.example.com&tls=1&token=abc")!
26+
#expect(
27+
DeepLinkParser.parse(url) == .gateway(
28+
.init(
29+
host: "gateway.example.com",
30+
port: 443,
31+
tls: true,
32+
bootstrapToken: nil,
33+
token: "abc",
34+
password: nil)))
35+
}
36+
2437
@Test func gatewayDeepLinkRejectsInsecureNonLoopbackWs() {
2538
let url = URL(
2639
string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")!

extensions/browser/src/browser/cdp.helpers.test.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,16 @@ describe("cdp helpers", () => {
3838

3939
it("releases guarded CDP fetches after the response body is consumed", async () => {
4040
const release = vi.fn(async () => {});
41-
const json = vi.fn(async () => {
41+
const arrayBuffer = vi.fn(async () => {
4242
expect(release).not.toHaveBeenCalled();
43-
return { ok: true };
43+
return new TextEncoder().encode(JSON.stringify({ ok: true })).buffer;
4444
});
4545
fetchWithSsrFGuardMock.mockResolvedValueOnce({
4646
response: {
4747
ok: true,
4848
status: 200,
49-
json,
49+
body: null,
50+
arrayBuffer,
5051
},
5152
release,
5253
});
@@ -58,7 +59,20 @@ describe("cdp helpers", () => {
5859
}),
5960
).resolves.toEqual({ ok: true });
6061

61-
expect(json).toHaveBeenCalledTimes(1);
62+
expect(arrayBuffer).toHaveBeenCalledTimes(1);
63+
expect(release).toHaveBeenCalledTimes(1);
64+
});
65+
66+
it("rejects oversized CDP JSON responses before parsing", async () => {
67+
const release = vi.fn(async () => {});
68+
fetchWithSsrFGuardMock.mockResolvedValueOnce({
69+
response: new Response(new Uint8Array(16 * 1024 * 1024 + 1)),
70+
release,
71+
});
72+
73+
await expect(fetchJson("http://127.0.0.1:9222/json/version")).rejects.toThrow(
74+
"cdp-json: JSON response exceeds 16777216 bytes",
75+
);
6276
expect(release).toHaveBeenCalledTimes(1);
6377
});
6478

extensions/browser/src/browser/cdp.helpers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* redaction/headers, and request/response correlation over WebSocket.
66
*/
77
import { parseBrowserHttpUrl, redactCdpUrl } from "openclaw/plugin-sdk/browser-config";
8+
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
89
import { sleep } from "openclaw/plugin-sdk/runtime-env";
910
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
1011
import WebSocket from "ws";
@@ -317,7 +318,7 @@ export async function fetchJson<T>(
317318
): Promise<T> {
318319
const { response, release } = await fetchCdpChecked(url, timeoutMs, init, ssrfPolicy);
319320
try {
320-
return (await response.json()) as T;
321+
return await readProviderJsonResponse<T>(response, "cdp-json");
321322
} finally {
322323
await release();
323324
}

extensions/browser/src/browser/chrome.diagnostics.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
12
/**
23
* Chrome CDP diagnostics.
34
*
@@ -110,7 +111,7 @@ export async function readChromeVersion(
110111
ssrfPolicy,
111112
);
112113
try {
113-
const data = (await response.json()) as ChromeVersion;
114+
const data = await readProviderJsonResponse<ChromeVersion>(response, "cdp-version");
114115
if (!data || typeof data !== "object") {
115116
throw new Error("CDP /json/version returned non-object JSON");
116117
}
@@ -250,7 +251,11 @@ function classifyChromeVersionError(error: unknown): {
250251
if (/^HTTP \d+/.test(message)) {
251252
return { code: "http_status_failed", message };
252253
}
253-
if (error instanceof SyntaxError || message.includes("non-object JSON")) {
254+
if (
255+
error instanceof SyntaxError ||
256+
message.includes("cdp-version: malformed JSON response") ||
257+
message.includes("non-object JSON")
258+
) {
254259
return { code: "invalid_json", message };
255260
}
256261
return { code: "http_unreachable", message };

extensions/browser/src/browser/chrome.test.ts

Lines changed: 31 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ function expectReadyChromeCdpDiagnostic(
5959
return diagnostic;
6060
}
6161

62+
function jsonResponse(payload: unknown, status = 200): Response {
63+
return new Response(JSON.stringify(payload), {
64+
status,
65+
headers: { "content-type": "application/json" },
66+
});
67+
}
68+
6269
async function readJson(filePath: string): Promise<Record<string, unknown>> {
6370
const raw = await fsp.readFile(filePath, "utf-8");
6471
return JSON.parse(raw) as Record<string, unknown>;
@@ -467,49 +474,47 @@ describe("browser chrome helpers", () => {
467474
it("reports reachability based on /json/version", async () => {
468475
vi.stubGlobal(
469476
"fetch",
470-
vi.fn().mockResolvedValue({
471-
ok: true,
472-
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
473-
} as unknown as Response),
477+
vi.fn().mockResolvedValue(jsonResponse({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" })),
474478
);
475479
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(true);
476480

477-
vi.stubGlobal(
478-
"fetch",
479-
vi.fn().mockResolvedValue({
480-
ok: false,
481-
json: async () => ({}),
482-
} as unknown as Response),
483-
);
481+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({}, 500)));
484482
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false);
485483

486484
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom")));
487485
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false);
488486
});
489487

490488
it("diagnoses /json/version responses that omit the websocket URL", async () => {
489+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ Browser: "Chrome/Mock" })));
490+
491+
const diagnostic = expectFailedChromeCdpDiagnostic(
492+
await diagnoseChromeCdp("http://127.0.0.1:12345", 50, 50),
493+
);
494+
expect(diagnostic.code).toBe("missing_websocket_debugger_url");
495+
expect(diagnostic.cdpUrl).toBe("http://127.0.0.1:12345");
496+
});
497+
498+
it("preserves invalid-json diagnostics for bounded /json/version reads", async () => {
491499
vi.stubGlobal(
492500
"fetch",
493-
vi.fn().mockResolvedValue({
494-
ok: true,
495-
json: async () => ({ Browser: "Chrome/Mock" }),
496-
} as unknown as Response),
501+
vi.fn().mockResolvedValue(
502+
new Response("{", {
503+
headers: { "content-type": "application/json" },
504+
}),
505+
),
497506
);
498507

499508
const diagnostic = expectFailedChromeCdpDiagnostic(
500509
await diagnoseChromeCdp("http://127.0.0.1:12345", 50, 50),
501510
);
502-
expect(diagnostic.code).toBe("missing_websocket_debugger_url");
503-
expect(diagnostic.cdpUrl).toBe("http://127.0.0.1:12345");
511+
expect(diagnostic.code).toBe("invalid_json");
504512
});
505513

506514
it("allows loopback CDP probes while still blocking non-loopback private targets in strict SSRF mode", async () => {
507515
const fetchSpy = vi
508516
.fn()
509-
.mockResolvedValueOnce({
510-
ok: true,
511-
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
512-
} as unknown as Response)
517+
.mockResolvedValueOnce(jsonResponse({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }))
513518
.mockRejectedValue(new Error("should not be called"));
514519
vi.stubGlobal("fetch", fetchSpy);
515520

@@ -794,10 +799,7 @@ describe("browser chrome helpers", () => {
794799
// connections (Browserless/Browserbase-style provider).
795800
vi.stubGlobal(
796801
"fetch",
797-
vi.fn().mockResolvedValue({
798-
ok: true,
799-
json: async () => ({}), // empty — no webSocketDebuggerUrl
800-
} as unknown as Response),
802+
vi.fn().mockResolvedValue(jsonResponse({})), // empty — no webSocketDebuggerUrl
801803
);
802804
// A real WS server accepts the handshake.
803805
const wss = new WebSocketServer({
@@ -819,13 +821,7 @@ describe("browser chrome helpers", () => {
819821
});
820822

821823
it("falls back to a direct WS readiness check when /json/version has no debugger URL", async () => {
822-
vi.stubGlobal(
823-
"fetch",
824-
vi.fn().mockResolvedValue({
825-
ok: true,
826-
json: async () => ({}),
827-
} as unknown as Response),
828-
);
824+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({})));
829825
const wss = new WebSocketServer({
830826
port: 0,
831827
host: "127.0.0.1",
@@ -859,13 +855,7 @@ describe("browser chrome helpers", () => {
859855
it("returns the original ws:// URL from getChromeWebSocketUrl when /json/version provides no debugger URL", async () => {
860856
// Covers the getChromeWebSocketUrl WS-fallback: discovery succeeds but
861857
// webSocketDebuggerUrl is absent — the original URL is returned as-is.
862-
vi.stubGlobal(
863-
"fetch",
864-
vi.fn().mockResolvedValue({
865-
ok: true,
866-
json: async () => ({}),
867-
} as unknown as Response),
868-
);
858+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({})));
869859
await expect(getChromeWebSocketUrl("ws://127.0.0.1:12345", 50)).resolves.toBe(
870860
"ws://127.0.0.1:12345",
871861
);
@@ -887,10 +877,7 @@ describe("browser chrome helpers", () => {
887877
it("stopOpenClawChrome escalates to SIGKILL when CDP stays reachable", async () => {
888878
vi.stubGlobal(
889879
"fetch",
890-
vi.fn().mockResolvedValue({
891-
ok: true,
892-
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
893-
} as unknown as Response),
880+
vi.fn().mockResolvedValue(jsonResponse({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" })),
894881
);
895882
const proc = makeChromeTestProc();
896883
await stopChromeWithProc(proc, 1);
@@ -915,10 +902,7 @@ describe("browser chrome helpers", () => {
915902
it("stopOpenClawChrome still releases the bypass when the SIGKILL fallback fires", async () => {
916903
vi.stubGlobal(
917904
"fetch",
918-
vi.fn().mockResolvedValue({
919-
ok: true,
920-
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
921-
} as unknown as Response),
905+
vi.fn().mockResolvedValue(jsonResponse({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" })),
922906
);
923907
const proc = makeChromeTestProc();
924908
const release = vi.fn();

extensions/clawrouter/usage.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,23 @@ describe("ClawRouter usage", () => {
8282
}),
8383
).rejects.toThrow("ClawRouter usage request failed (HTTP 403)");
8484
});
85+
86+
it("bounds successful usage response bodies", async () => {
87+
const oversizedPayload = JSON.stringify({
88+
budget: { configured: false },
89+
usage: { summary: { requestCount: 1 } },
90+
padding: "x".repeat(1024 * 1024),
91+
});
92+
93+
await expect(
94+
fetchClawRouterUsage({
95+
token: "proxy-key",
96+
timeoutMs: 5000,
97+
fetchFn: async () =>
98+
new Response(oversizedPayload, {
99+
headers: { "content-type": "application/json" },
100+
}),
101+
}),
102+
).rejects.toThrow("ClawRouter usage response exceeds");
103+
});
85104
});

extensions/clawrouter/usage.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { ProviderUsageSnapshot } from "openclaw/plugin-sdk/provider-usage";
2+
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
23
import { normalizeClawRouterRootUrl } from "./provider-catalog.js";
34

5+
const CLAWROUTER_USAGE_RESPONSE_MAX_BYTES = 1024 * 1024;
6+
47
type ClawRouterBudget = {
58
configured?: unknown;
69
ledger?: unknown;
@@ -62,6 +65,19 @@ function buildSummary(payload: ClawRouterUsagePayload): string | undefined {
6265
return parts.length > 0 ? parts.join(" · ") : undefined;
6366
}
6467

68+
async function readClawRouterUsagePayload(
69+
response: Response,
70+
timeoutMs: number,
71+
): Promise<ClawRouterUsagePayload> {
72+
const buffer = await readResponseWithLimit(response, CLAWROUTER_USAGE_RESPONSE_MAX_BYTES, {
73+
chunkTimeoutMs: timeoutMs,
74+
onOverflow: ({ maxBytes }) => new Error(`ClawRouter usage response exceeds ${maxBytes} bytes`),
75+
onIdleTimeout: ({ chunkTimeoutMs }) =>
76+
new Error(`ClawRouter usage response stalled: no data received for ${chunkTimeoutMs}ms`),
77+
});
78+
return JSON.parse(new TextDecoder().decode(buffer)) as ClawRouterUsagePayload;
79+
}
80+
6581
export async function fetchClawRouterUsage(params: {
6682
token: string;
6783
baseUrl?: string;
@@ -78,7 +94,7 @@ export async function fetchClawRouterUsage(params: {
7894
if (!response.ok) {
7995
throw new Error(`ClawRouter usage request failed (HTTP ${response.status})`);
8096
}
81-
const payload = (await response.json()) as ClawRouterUsagePayload;
97+
const payload = await readClawRouterUsagePayload(response, params.timeoutMs);
8298
const budget = payload.budget;
8399
const limitMicros = nonNegativeNumber(budget?.limitMicros);
84100
const spentMicros = nonNegativeNumber(budget?.spentMicros);

extensions/discord/src/voice/log-preview.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,10 @@ describe("formatVoiceLogPreview", () => {
1010
const preview = formatVoiceLogPreview("x".repeat(501));
1111
expect(preview).toBe(`${"x".repeat(500)}...`);
1212
});
13+
14+
it("does not split emoji when the preview limit lands inside a surrogate pair", () => {
15+
const preview = formatVoiceLogPreview(`${"x".repeat(499)}😀tail`);
16+
expect(preview).toBe(`${"x".repeat(499)}...`);
17+
expect(preview).not.toMatch(/[\uD800-\uDFFF]/u);
18+
});
1319
});
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
2+
13
const DISCORD_VOICE_LOG_PREVIEW_CHARS = 500;
24

35
export function formatVoiceLogPreview(text: string): string {
46
const oneLine = text.replace(/\s+/g, " ").trim();
57
if (oneLine.length <= DISCORD_VOICE_LOG_PREVIEW_CHARS) {
68
return oneLine;
79
}
8-
return `${oneLine.slice(0, DISCORD_VOICE_LOG_PREVIEW_CHARS)}...`;
10+
return `${truncateUtf16Safe(oneLine, DISCORD_VOICE_LOG_PREVIEW_CHARS)}...`;
911
}

0 commit comments

Comments
 (0)