diff --git a/src/process/supervisor/supervisor.test.ts b/src/process/supervisor/supervisor.test.ts index a5aaabd3edd..08bd10ab0f4 100644 --- a/src/process/supervisor/supervisor.test.ts +++ b/src/process/supervisor/supervisor.test.ts @@ -290,4 +290,42 @@ describe("process supervisor", () => { expect(streamed).toBe("streamed"); expect(exit.stdout).toBe(""); }); + + it("bounds retained stdout and stderr while streaming full chunks", async () => { + const adapter = createStubChildAdapter(); + createChildAdapterMock.mockResolvedValue(adapter); + + const supervisor = createProcessSupervisor(); + let streamedStdout = ""; + let streamedStderr = ""; + const stdoutChunk = `${"a".repeat(300)}stdout-tail`; + const stderrChunk = `${"b".repeat(300)}stderr-tail`; + const run = await spawnChild(supervisor, { + sessionId: "s-capture-cap", + argv: createWriteStdoutArgv(stdoutChunk), + timeoutMs: 1_000, + stdinMode: "pipe-closed", + maxCapturedOutputChars: 256, + onStdout: (chunk) => { + streamedStdout += chunk; + }, + onStderr: (chunk) => { + streamedStderr += chunk; + }, + }); + + adapter.emitStdout(stdoutChunk); + adapter.emitStderr(stderrChunk); + adapter.settle(0); + + const exit = await run.wait(); + expect(streamedStdout).toBe(stdoutChunk); + expect(streamedStderr).toBe(stderrChunk); + expect(exit.stdout.length).toBeLessThanOrEqual(256); + expect(exit.stderr.length).toBeLessThanOrEqual(256); + expect(exit.stdout).toContain("captured stdout truncated"); + expect(exit.stderr).toContain("captured stderr truncated"); + expect(exit.stdout.endsWith("stdout-tail")).toBe(true); + expect(exit.stderr.endsWith("stderr-tail")).toBe(true); + }); }); diff --git a/src/process/supervisor/supervisor.ts b/src/process/supervisor/supervisor.ts index a111ec67170..3f7ef1587cd 100644 --- a/src/process/supervisor/supervisor.ts +++ b/src/process/supervisor/supervisor.ts @@ -21,6 +21,7 @@ type ActiveRun = { }; const GRACEFUL_CANCEL_TIMEOUT_MS = 5000; +const DEFAULT_MAX_CAPTURED_OUTPUT_CHARS = 1024 * 1024; let supervisorLogRuntimePromise: Promise | undefined; @@ -36,6 +37,28 @@ function clampTimeout(value?: number): number | undefined { return Math.max(1, Math.floor(value)); } +function clampCapturedOutputChars(value?: number): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return DEFAULT_MAX_CAPTURED_OUTPUT_CHARS; + } + return Math.max(256, Math.floor(value)); +} + +function appendCapturedOutput( + current: string, + chunk: string, + stream: "stdout" | "stderr", + maxChars: number, +) { + const next = current + chunk; + if (next.length <= maxChars) { + return next; + } + const marker = `[openclaw: captured ${stream} truncated to last ${maxChars} chars]\n`; + const tailChars = Math.max(0, maxChars - marker.length); + return `${marker}${next.slice(-tailChars)}`; +} + function isTimeoutReason(reason: TerminationReason) { return reason === "overall-timeout" || reason === "no-output-timeout"; } @@ -95,6 +118,7 @@ export function createProcessSupervisor(): ProcessSupervisor { let noOutputTimer: NodeJS.Timeout | null = null; let forceKillTimer: NodeJS.Timeout | null = null; const captureOutput = input.captureOutput !== false; + const maxCapturedOutputChars = clampCapturedOutputChars(input.maxCapturedOutputChars); const overallTimeoutMs = clampTimeout(input.timeoutMs); const noOutputTimeoutMs = clampTimeout(input.noOutputTimeoutMs); @@ -198,14 +222,14 @@ export function createProcessSupervisor(): ProcessSupervisor { adapter.onStdout((chunk) => { if (captureOutput) { - stdout += chunk; + stdout = appendCapturedOutput(stdout, chunk, "stdout", maxCapturedOutputChars); } input.onStdout?.(chunk); touchOutput(); }); adapter.onStderr((chunk) => { if (captureOutput) { - stderr += chunk; + stderr = appendCapturedOutput(stderr, chunk, "stderr", maxCapturedOutputChars); } input.onStderr?.(chunk); touchOutput(); diff --git a/src/process/supervisor/types.ts b/src/process/supervisor/types.ts index 24a4f03bfff..ce06299a588 100644 --- a/src/process/supervisor/types.ts +++ b/src/process/supervisor/types.ts @@ -81,6 +81,11 @@ type SpawnBaseInput = { * When false, stdout/stderr are streamed via callbacks only and not retained in RunExit payload. */ captureOutput?: boolean; + /** + * Maximum retained stdout/stderr characters per stream when captureOutput is enabled. + * Streaming callbacks still receive full chunks. + */ + maxCapturedOutputChars?: number; onStdout?: (chunk: string) => void; onStderr?: (chunk: string) => void; };