diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index a4543aa91ccb..85c4a6deb856 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -132,6 +132,24 @@ private struct ExecHostRunResult: Codable { var error: String? } +enum ExecHostOutputLimiter { + static let maxJsonlResponseBytes = 16 * 1024 * 1024 + static let maxOutputFieldBytes = 1024 * 1024 + private static let truncationMarker = "... (truncated) " + + static func truncate(_ value: String) -> String { + let bytes = value.utf8 + guard bytes.count > self.maxOutputFieldBytes else { return value } + + let tailBudget = self.maxOutputFieldBytes - self.truncationMarker.utf8.count + var start = bytes.index(bytes.endIndex, offsetBy: -tailBudget) + while start < bytes.endIndex, (bytes[start] & 0xC0) == 0x80 { + start = bytes.index(after: start) + } + return self.truncationMarker + String(decoding: bytes[start...], as: UTF8.self) + } +} + struct ExecHostError: Codable, Error { var code: String var message: String @@ -621,8 +639,8 @@ private enum ExecHostExecutor { exitCode: result.exitCode, timedOut: result.timedOut, success: result.success, - stdout: result.stdout, - stderr: result.stderr, + stdout: ExecHostOutputLimiter.truncate(result.stdout), + stderr: ExecHostOutputLimiter.truncate(result.stderr), error: result.errorMessage) return self.successResponse(payload) } diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsSocketAuthTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsSocketAuthTests.swift index ee0ead1f9026..553310ccc562 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsSocketAuthTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsSocketAuthTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import OpenClaw @@ -18,4 +19,73 @@ struct ExecApprovalsSocketAuthTests { func `timing safe hex compare rejects different length strings`() { #expect(!timingSafeHexStringEquals(String(repeating: "a", count: 64), "deadbeef")) } + + @Test + func `exec host limiter preserves small output`() { + #expect(ExecHostOutputLimiter.truncate("hello") == "hello") + } + + @Test + func `exec host limiter preserves a valid utf8 tail`() { + let input = String(repeating: "x", count: 2 * 1024 * 1024) + "✅" + let limited = ExecHostOutputLimiter.truncate(input) + + #expect(limited.hasPrefix("... (truncated) ")) + #expect(limited.hasSuffix("✅")) + #expect(limited.utf8.count <= ExecHostOutputLimiter.maxOutputFieldBytes) + } + + @Test + func `exec host limiter keeps escaped output below the jsonl cap`() throws { + let escaped = String(repeating: "\u{0}", count: 2 * 1024 * 1024) + let limited = ExecHostOutputLimiter.truncate(escaped) + let response = EncodedExecHostResponse( + type: "exec-res", + id: "test", + ok: true, + payload: EncodedExecHostRunResult( + exitCode: 0, + timedOut: false, + success: true, + stdout: limited, + stderr: limited, + error: nil), + error: nil) + + #expect(try JSONEncoder().encode(response).count < ExecHostOutputLimiter.maxJsonlResponseBytes) + } + + @Test + func `exec host limiter bounds real command output`() async throws { + let result = await ShellExecutor.runDetailed( + command: [ + "/usr/bin/perl", + "-e", + "print 'x' x (2 * 1024 * 1024); print STDERR 'y' x (2 * 1024 * 1024);", + ], + cwd: nil, + env: nil, + timeout: 10) + + #expect(ExecHostOutputLimiter.truncate(result.stdout).utf8.count <= ExecHostOutputLimiter.maxOutputFieldBytes) + #expect(ExecHostOutputLimiter.truncate(result.stderr).utf8.count <= ExecHostOutputLimiter.maxOutputFieldBytes) + #expect(result.exitCode == 0) + } + + private struct EncodedExecHostResponse: Codable { + var type: String + var id: String + var ok: Bool + var payload: EncodedExecHostRunResult? + var error: String? + } + + private struct EncodedExecHostRunResult: Codable { + var exitCode: Int? + var timedOut: Bool + var success: Bool + var stdout: String + var stderr: String + var error: String? + } } diff --git a/src/infra/jsonl-socket.test.ts b/src/infra/jsonl-socket.test.ts index 59d8f3ec4fdf..e54f062fd75a 100644 --- a/src/infra/jsonl-socket.test.ts +++ b/src/infra/jsonl-socket.test.ts @@ -161,4 +161,69 @@ describe.runIf(process.platform !== "win32")("requestJsonlSocket", () => { } }); }); + + it("accepts a complete response line even when trailing data would exceed the cap", async () => { + await withTempDir({ prefix: "openclaw-jsonl-socket-" }, async (dir) => { + const socketPath = path.join(dir, "socket.sock"); + const server = net.createServer((socket) => { + socket.on("data", () => { + socket.end(`{"type":"done","value":7}\n${"x".repeat(65)}`); + }); + }); + const listening = await listenOnSocket(server, socketPath); + if (!listening) { + return; + } + + try { + await expect( + testApi.requestJsonlSocketWithMaxLineBytes( + { + socketPath, + requestLine: "{}", + timeoutMs: 500, + accept: acceptDoneValue, + }, + 64, + ), + ).resolves.toBe(7); + } finally { + server.close(); + } + }); + }); + + it("rejects oversized complete and unterminated response lines before timeout", async () => { + for (const response of ["x".repeat(65), `${"x".repeat(65)}\n{"type":"done","value":9}\n`]) { + await withTempDir({ prefix: "openclaw-jsonl-socket-" }, async (dir) => { + const socketPath = path.join(dir, "socket.sock"); + const server = net.createServer((socket) => { + socket.on("data", () => { + socket.write(response); + }); + }); + const listening = await listenOnSocket(server, socketPath); + if (!listening) { + return; + } + + try { + const startMs = Date.now(); + const result = await testApi.requestJsonlSocketWithMaxLineBytes( + { + socketPath, + requestLine: "{}", + timeoutMs: 500, + accept: acceptDoneValue, + }, + 64, + ); + expect(result).toBeNull(); + expect(Date.now() - startMs).toBeLessThan(250); + } finally { + server.close(); + } + }); + } + }); }); diff --git a/src/infra/jsonl-socket.ts b/src/infra/jsonl-socket.ts index 043178b0d879..3082d9e7d766 100644 --- a/src/infra/jsonl-socket.ts +++ b/src/infra/jsonl-socket.ts @@ -3,6 +3,15 @@ import net from "node:net"; import { clearTimeout as clearNodeTimeout, setTimeout as setNodeTimeout } from "node:timers"; import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; +const JSONL_SOCKET_MAX_LINE_BYTES = 16 * 1024 * 1024; + +type JsonlSocketRequest = { + socketPath: string; + requestLine: string; + timeoutMs: number; + accept: (msg: unknown) => T | null | undefined; +}; + /** * Sends one JSONL request line, half-closes the write side, and waits for an accepted response line. */ @@ -10,18 +19,19 @@ function resolveJsonlSocketTimeoutMs(timeoutMs: number): number { return resolveTimerTimeoutMs(timeoutMs, 1); } -export async function requestJsonlSocket(params: { - socketPath: string; - requestLine: string; - timeoutMs: number; - accept: (msg: unknown) => T | null | undefined; -}): Promise { +async function requestJsonlSocketWithMaxLineBytes( + params: JsonlSocketRequest, + maxLineBytes: number, +): Promise { const { socketPath, requestLine, accept } = params; const timeoutMs = resolveJsonlSocketTimeoutMs(params.timeoutMs); return await new Promise((resolve) => { const client = new net.Socket(); let settled = false; - let buffer = ""; + // Keep raw bytes until a line is complete so chunk boundaries cannot split + // a UTF-8 code point before JSON parsing. + let lineChunks: Buffer[] = []; + let lineBytes = 0; const finish = (value: T | null) => { if (settled) { @@ -37,6 +47,25 @@ export async function requestJsonlSocket(params: { resolve(value); }; + const appendLineChunk = (chunk: Buffer): boolean => { + if (lineBytes + chunk.byteLength > maxLineBytes) { + finish(null); + return false; + } + if (chunk.byteLength > 0) { + lineChunks.push(chunk); + lineBytes += chunk.byteLength; + } + return true; + }; + + const takeLine = (): string => { + const line = Buffer.concat(lineChunks, lineBytes).toString("utf8").trim(); + lineChunks = []; + lineBytes = 0; + return line; + }; + const timer = setNodeTimeout(() => finish(null), timeoutMs); client.on("error", () => finish(null)); @@ -45,13 +74,21 @@ export async function requestJsonlSocket(params: { client.connect(socketPath, () => { client.end(`${requestLine}\n`); }); - client.on("data", (data) => { - buffer += data.toString("utf8"); - let idx = buffer.indexOf("\n"); - while (idx !== -1) { - const line = buffer.slice(0, idx).trim(); - buffer = buffer.slice(idx + 1); - idx = buffer.indexOf("\n"); + client.on("data", (data: Buffer) => { + let offset = 0; + while (offset < data.byteLength) { + const newlineIndex = data.indexOf(0x0a, offset); + if (newlineIndex === -1) { + appendLineChunk(data.subarray(offset)); + return; + } + // Bound bytes before concatenating or parsing; both complete and unterminated + // peer-controlled lines must stay below the same allocation ceiling. + if (!appendLineChunk(data.subarray(offset, newlineIndex))) { + return; + } + const line = takeLine(); + offset = newlineIndex + 1; if (!line) { continue; } @@ -71,5 +108,13 @@ export async function requestJsonlSocket(params: { }); } -export const testApi = { resolveJsonlSocketTimeoutMs }; +export async function requestJsonlSocket(params: JsonlSocketRequest): Promise { + return await requestJsonlSocketWithMaxLineBytes(params, JSONL_SOCKET_MAX_LINE_BYTES); +} + +export const testApi = { + JSONL_SOCKET_MAX_LINE_BYTES, + requestJsonlSocketWithMaxLineBytes, + resolveJsonlSocketTimeoutMs, +}; export { testApi as __test__ };