fix(infra): bound jsonl-socket response buffer to prevent OOM (#98130)

* fix(infra): bound jsonl-socket response buffer to prevent OOM

* fix(macos): cap exec host socket output

* fix(macos): satisfy swiftformat for exec limiter

* test(macos): prove exec host output cap on command output

* chore: keep jsonl socket cap scoped to infra

* fix(infra): raise jsonl socket buffer cap

* test(infra): prove jsonl socket cap beats timeout

* ci: rerun jsonl socket buffer bound checks

* fix(macos): cap exec host response output

* test(macos): avoid private exec response fixtures

* fix(macos): satisfy exec output limiter formatting

* fix(macos): avoid static self formatting conflict

* chore: keep jsonl socket guard infra-only

* fix(jsonl-socket): parse complete lines before enforcing the buffer cap

* chore: remove unnecessary return after finish(null)

* fix(infra): frame bounded JSONL socket lines

* fix(infra): frame bounded JSONL socket lines

* style(macos): keep exec limiter patch focused

* style(macos): keep exec limiter patch focused

* style(macos): satisfy exec limiter formatting

* style(infra): satisfy socket loop lint

---------

Co-authored-by: Peter Steinberger <peter@steipete.me>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
(cherry picked from commit b2620d7153)
This commit is contained in:
pick-cat
2026-07-03 23:15:47 +08:00
committed by Dallin Romney
parent 2d80d940d5
commit ce0640a784
4 changed files with 215 additions and 17 deletions
@@ -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)
}
@@ -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?
}
}
+65
View File
@@ -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();
}
});
}
});
});
+60 -15
View File
@@ -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<T> = {
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<T>(params: {
socketPath: string;
requestLine: string;
timeoutMs: number;
accept: (msg: unknown) => T | null | undefined;
}): Promise<T | null> {
async function requestJsonlSocketWithMaxLineBytes<T>(
params: JsonlSocketRequest<T>,
maxLineBytes: number,
): Promise<T | null> {
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<T>(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<T>(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<T>(params: {
});
}
export const testApi = { resolveJsonlSocketTimeoutMs };
export async function requestJsonlSocket<T>(params: JsonlSocketRequest<T>): Promise<T | null> {
return await requestJsonlSocketWithMaxLineBytes(params, JSONL_SOCKET_MAX_LINE_BYTES);
}
export const testApi = {
JSONL_SOCKET_MAX_LINE_BYTES,
requestJsonlSocketWithMaxLineBytes,
resolveJsonlSocketTimeoutMs,
};
export { testApi as __test__ };