mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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__ };
|
||||
|
||||
Reference in New Issue
Block a user