diff --git a/packages/code/README.md b/packages/code/README.md index a38523a3..c7cff6e1 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -219,6 +219,20 @@ SRT with: - bounded time and aggregate output, with best-effort process-group termination on cancellation, timeout, and completion. +Native command output keeps a prefix and rolling suffix for each stream, so late +summaries and errors survive truncation. Each stream stores at most +`maxOutputBytes` of copied raw bytes while the command runs, independent of output +volume or chunk count. When both streams are noisy they split the existing combined +response budget equally, with the odd byte reserved for stderr; a quiet stream gives +its unused allowance to the other. Sandbox violation annotations enter the same +stderr window before rendering. UTF-8 boundaries and inline +`[... N bytes omitted ...]` markers count toward the combined byte limit. The count +reports omitted raw bytes for that stream, including annotation bytes. If a stream's +allowance cannot fit its marker, only the retained text and the existing `truncated` +flag are returned. Truncation does not stop execution. Exit codes, timeout/signal +fields, and cancellation errors keep their existing semantics, and no new request, +result, or capability keys are introduced. + SRT restrictions remain inherited by descendants. Windows additionally uses a kill-on-close Job Object. Native macOS does not provide an equivalent hard process-lifetime boundary: a deliberately daemonized descendant can outlive diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 5b79aa1a..b61041a7 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -33,6 +33,7 @@ import { } from './native-sandbox.js'; import { restoreScratchTraversal } from './native-scratch.js'; import { WorkspaceToolError } from './workspace.js'; +import { isWorkspaceToolResult } from './protocol.js'; const request = { protocolVersion: 1 as const, @@ -1355,14 +1356,104 @@ test('executes in the canonical workspace and bounds aggregate output', async t operation: 'execute_command', workspaceId: 'primary', exitCode: 0, - stdout: '1234567890', - stderr: 'ab', + stdout: '123890', + stderr: 'abchij', truncated: true, timedOut: false, }, ); }); +test('retains real command summaries on both streams under the legacy combined budget', async t => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + manager: fakeManager().manager, + }); + t.after(() => sandbox.close()); + const commandRequest = { + ...request, + maxOutputBytes: 256, + command: + "printf 'OUT\\n'; printf '%20000d' 0; printf '\\n42 tests passed\\n'; printf 'ERR\\n' >&2; printf '%20000d' 0 >&2; printf '\\nlate stderr summary\\n' >&2", + }; + const result = await sandbox.execute(commandRequest); + assert.equal(result.exitCode, 0); + assert.equal(result.timedOut, false); + assert.equal(result.truncated, true); + assert.ok(result.stdout.startsWith('OUT\n')); + assert.ok(result.stderr.startsWith('ERR\n')); + assert.ok(result.stdout.endsWith('\n42 tests passed\n')); + assert.ok(result.stderr.endsWith('\nlate stderr summary\n')); + assert.ok(result.stdout.includes('bytes omitted')); + assert.ok(result.stderr.includes('bytes omitted')); + assert.equal(isWorkspaceToolResult(commandRequest, result), true); +}); + +test('sandbox annotations survive full stdout and remain bounded when stderr is noisy', async t => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); + t.after(() => rm(root, { recursive: true, force: true })); + const fake = fakeManager(); + fake.manager.annotateStderrWithSandboxFailures = (_commandId, stderr) => + stderr + '\n\ndenied write\n'; + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + manager: fake.manager, + }); + t.after(() => sandbox.close()); + for (const stderrCommand of ['', "printf '%20000d' 0 >&2;"]) { + const commandRequest = { + ...request, + maxOutputBytes: 512, + command: `printf '%20000d' 0; ${stderrCommand} true`, + }; + const result = await sandbox.execute(commandRequest); + assert.ok( + result.stderr.endsWith( + '\ndenied write\n' + ) + ); + assert.equal(isWorkspaceToolResult(commandRequest, result), true); + assert.equal(result.truncated, true); + } + fake.manager.annotateStderrWithSandboxFailures = () => { + throw new Error('annotation unavailable'); + }; + const result = await sandbox.execute({ + ...request, + maxOutputBytes: 128, + command: "printf '%20000d' 0; printf 'child failure' >&2", + }); + assert.equal(result.stderr, 'child failure'); +}); + +test('timeout settlement keeps late diagnostics, signal, and truncation without widening the result', async t => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + manager: fakeManager().manager, + }); + t.after(() => sandbox.close()); + const commandRequest = { + ...request, + maxOutputBytes: 128, + timeoutMs: 100, + command: + "printf 'START\\n'; printf '%20000d' 0; printf '\\nlast progress\\n'; printf 'last failure' >&2; sleep 30", + }; + const result = await sandbox.execute(commandRequest); + assert.ok(result.stdout.startsWith('START\n')); + assert.ok(result.stdout.endsWith('\nlast progress\n')); + assert.equal(result.stderr, 'last failure'); + assert.equal(result.timedOut, true); + assert.equal(result.truncated, true); + assert.equal(result.exitCode, null); + assert.equal(result.signal, 'SIGKILL'); + assert.equal(isWorkspaceToolResult(commandRequest, result), true); +}); + test('rejects an escaping or unavailable command working directory', async t => { const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); t.after(() => rm(root, { recursive: true, force: true })); diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 49fe6ecf..6cd5ebd7 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -31,6 +31,7 @@ import { removePrivateStorageAcl, } from './private-storage.js'; import { WorkspaceToolError } from './workspace.js'; +import { OutputBuffer, renderCommandOutput } from './output.js'; import { restoreScratchTraversal } from './native-scratch.js'; import { writeLinkedWorktreeGitGuard } from './linked-worktree-git-guard.js'; @@ -1127,28 +1128,10 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox } let settled = false; let timedOut = false; - let outputBytes = 0; - let truncated = false; - const stdout: Buffer[] = []; - const stderr: Buffer[] = []; - const append = (target: Buffer[], chunk: Buffer): void => { - const remaining = outputLimit - outputBytes; - if (remaining <= 0) { - truncated = true; - return; - } - const accepted = chunk.subarray(0, remaining); - target.push(accepted); - outputBytes += accepted.byteLength; - if (accepted.byteLength !== chunk.byteLength) - truncated = true; - }; - child.stdout.on('data', (chunk: Buffer) => - append(stdout, chunk), - ); - child.stderr.on('data', (chunk: Buffer) => - append(stderr, chunk), - ); + const stdout = new OutputBuffer(outputLimit); + const stderr = new OutputBuffer(outputLimit); + child.stdout.on('data', (chunk: Buffer) => stdout.append(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.append(chunk)); const abort = (): void => { if (settled) return; this.killCommandTree(child); @@ -1197,29 +1180,17 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ); return; } - const stdoutValue = boundedUtf8( - Buffer.concat(stdout), - outputLimit, - ); - const stderrBudget = Math.max( - 0, - outputLimit - Buffer.byteLength(stdoutValue), - ); - const rawStderr = Buffer.concat(stderr).toString('utf8'); - let annotatedStderr = rawStderr; try { - annotatedStderr = - this.manager.annotateStderrWithSandboxFailures( - commandId, - rawStderr, + // SRT appends violations to its input. Capture them in the same bounded stderr window. + stderr.append( + Buffer.from( + this.manager.annotateStderrWithSandboxFailures(commandId, ''), + ), ); } catch { // Preserve the bounded child error if optional violation annotation fails. } - const stderrValue = boundedUtf8( - Buffer.from(annotatedStderr), - stderrBudget, - ); + const output = renderCommandOutput(stdout, stderr, outputLimit); resolvePromise({ protocolVersion: BRIDGE_PROTOCOL_VERSION, operation: 'execute_command', @@ -1229,11 +1200,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ? null : this.protocolExitCode(code), ...(childSignal ? { signal: childSignal } : {}), - stdout: stdoutValue, - stderr: stderrValue, - truncated: - truncated || - Buffer.byteLength(annotatedStderr) > stderrBudget, + ...output, timedOut, }); }); diff --git a/packages/code/src/output.test.ts b/packages/code/src/output.test.ts new file mode 100644 index 00000000..3af3c360 --- /dev/null +++ b/packages/code/src/output.test.ts @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { OutputBuffer, renderCommandOutput } from './output.js'; + +function collect( + content: Buffer, + budget: number, + chunkSize = content.length || 1 +): OutputBuffer { + const output = new OutputBuffer(budget); + for (let offset = 0; offset < content.length; offset += chunkSize) { + output.append(content.subarray(offset, offset + chunkSize)); + } + return output; +} + +function assertBounded(text: string, budget: number): void { + assert.ok(Buffer.byteLength(text) <= budget); + assert.equal(Buffer.from(text).toString('utf8'), text); +} + +test('small output stays byte-for-byte unchanged, including split UTF-8 and exact limits', () => { + for (const content of ['', '1234567', 'hello\n世界🌍\n']) { + const bytes = Buffer.from(content); + const budget = Math.max(1, bytes.length); + for (const chunkSize of [1, 2, 7, 64]) { + assert.deepEqual(collect(bytes, budget, chunkSize).render(budget), { + text: content, + truncated: false, + }); + } + } +}); + +test('a rolling suffix keeps the last summary and reports the exact dropped-byte count', () => { + const budget = 128; + const content = Buffer.from( + 'START\n' + 'x'.repeat(10_000) + '\n42 tests passed\n' + ); + for (const chunkSize of [1, 3, 63, 64, 65, 127, 1024, content.length]) { + const result = collect(content, budget, chunkSize).render(budget); + assert.equal(result.truncated, true); + assert.ok(result.text.startsWith('START\n')); + assert.ok(result.text.endsWith('\n42 tests passed\n')); + const marker = /\n\[\.\.\. (\d+) bytes omitted \.\.\.\]\n/.exec( + result.text + ); + assert.ok(marker); + assert.equal( + Number(marker[1]), + content.length - + Buffer.byteLength(result.text.replace(marker[0], '')) + ); + assertBounded(result.text, budget); + } +}); + +test('either quiet stream donates its budget, and stdout cannot starve stderr', () => { + const budget = 256; + const empty = collect(Buffer.alloc(0), budget); + const exact = collect(Buffer.alloc(budget, 'a'), budget, 1); + assert.deepEqual(renderCommandOutput(exact, empty, budget), { + stdout: 'a'.repeat(budget), + stderr: '', + truncated: false, + }); + assert.deepEqual(renderCommandOutput(empty, exact, budget), { + stdout: '', + stderr: 'a'.repeat(budget), + truncated: false, + }); + const stdout = collect( + Buffer.from('OUT\n' + 'x'.repeat(4096) + '\nstdout summary'), + budget + ); + const stderr = collect( + Buffer.from('ERR\n' + 'y'.repeat(4096) + '\nstderr summary'), + budget + ); + const result = renderCommandOutput(stdout, stderr, budget); + assert.ok(result.stdout.startsWith('OUT\n')); + assert.ok(result.stderr.startsWith('ERR\n')); + assert.ok(result.stdout.endsWith('stdout summary')); + assert.ok(result.stderr.endsWith('stderr summary')); + assert.ok(result.stdout.includes('bytes omitted')); + assert.ok(result.stderr.includes('bytes omitted')); + assert.ok( + Buffer.byteLength(result.stdout) + Buffer.byteLength(result.stderr) <= + budget + ); + assert.equal(result.truncated, true); + + const shortError = collect(Buffer.from('late failure'), budget); + assert.equal( + renderCommandOutput(stdout, shortError, budget).stderr, + 'late failure' + ); + assert.equal( + renderCommandOutput(shortError, stdout, budget).stdout, + 'late failure' + ); +}); + +test('budgeting includes UTF-8 boundaries and marker overhead for every small limit', () => { + const content = Buffer.from('世界🌍 café Ελληνικά\n'.repeat(100)); + for (let budget = 1; budget <= 256; budget += 1) { + const result = collect(content, budget, 1).render(budget); + assertBounded(result.text, budget); + assert.equal(result.truncated, true); + assert.ok( + !result.text.includes('\ufffd'), + `split code point at budget ${budget}` + ); + const marker = /\n\[\.\.\. (\d+) bytes omitted \.\.\.\]\n/.exec( + result.text + ); + if (marker) { + assert.equal( + Number(marker[1]), + content.length - + Buffer.byteLength(result.text.replace(marker[0], '')) + ); + } + const streams = renderCommandOutput( + collect(content, budget, 7), + collect(content, budget, 3), + budget + ); + assert.ok( + Buffer.byteLength(streams.stdout) + + Buffer.byteLength(streams.stderr) <= + budget + ); + assert.ok( + !streams.stdout.includes('\ufffd') && + !streams.stderr.includes('\ufffd') + ); + } +}); + +test('malformed output cannot expand past the byte budget during decoding', () => { + for (const content of [ + Buffer.alloc(1024, 0xff), + Buffer.from([0xf0, 0x80, 0xff, 0xc2, 0xa2, 0xe0, 0xa0]), + Buffer.from('a\ufffdb'.repeat(300)), + ]) { + for (let budget = 1; budget <= 128; budget += 1) { + const result = collect(content, budget, 1).render(budget); + assertBounded(result.text, budget); + if (content.length > budget) assert.equal(result.truncated, true); + } + } +}); + +test('short decoded output is preserved when replacement characters fit the combined budget', () => { + const budget = 32; + assert.deepEqual( + renderCommandOutput( + collect(Buffer.from([0xff, 0xff]), budget), + collect(Buffer.from('child failure'), budget), + budget + ), + { stdout: '\ufffd\ufffd', stderr: 'child failure', truncated: false } + ); +}); + +test('tiny budgets still flag truncation and give stderr the odd byte', () => { + for (let budget = 1; budget < 32; budget += 1) { + const result = renderCommandOutput( + collect(Buffer.from('a'.repeat(256)), budget), + collect(Buffer.from('b'.repeat(256)), budget), + budget + ); + assert.equal(result.truncated, true); + assert.ok(result.stderr.length > 0); + assert.ok( + Buffer.byteLength(result.stdout) + + Buffer.byteLength(result.stderr) <= + budget + ); + } +}); + +test('copies bounded windows instead of retaining or repeatedly concatenating source chunks', () => { + const budget = 128; + const output = new OutputBuffer(budget); + const oversized = Buffer.alloc(1024 * 1024, 'a'); + output.append(oversized); + oversized.fill('z'); + const byte = Buffer.from('b'); + for (let i = 0; i < 100_000; i += 1) output.append(byte); + byte[0] = 0x7a; + const result = output.render(budget); + assert.ok(result.text.startsWith('a'.repeat(40))); + assert.ok(result.text.endsWith('b'.repeat(40))); + assert.ok(!result.text.includes('z')); + assert.equal(output.bytes, 1024 * 1024 + 100_000); + assertBounded(result.text, budget); +}); diff --git a/packages/code/src/output.ts b/packages/code/src/output.ts new file mode 100644 index 00000000..72e26291 --- /dev/null +++ b/packages/code/src/output.ts @@ -0,0 +1,187 @@ +interface OutputWindow { + text: string; + bytes: number; +} + +function utf8Window( + buffer: Buffer, + length: number, + tail: boolean +): OutputWindow { + let start = tail ? buffer.length - length : 0; + let end = tail ? buffer.length : length; + if (tail) { + while (start < end && (buffer[start] & 0xc0) === 0x80) start += 1; + } else if (end > 0) { + let last = end - 1; + while (last > 0 && (buffer[last] & 0xc0) === 0x80) last -= 1; + const lead = buffer[last]; + const width = + lead >= 0xc2 && lead <= 0xdf + ? 2 + : lead >= 0xe0 && lead <= 0xef + ? 3 + : lead >= 0xf0 && lead <= 0xf4 + ? 4 + : 1; + if (end - last < width) end = last; + } + return { + text: buffer.subarray(start, end).toString('utf8'), + bytes: end - start, + }; +} + +function boundedWindow( + buffer: Buffer, + budget: number, + tail: boolean +): OutputWindow { + const length = Math.min(buffer.length, budget); + const window = utf8Window(buffer, length, tail); + if (Buffer.byteLength(window.text) <= budget) return window; + + // Malformed bytes expand to replacement characters. Valid UTF-8 takes the fast path. + let low = 0; + let high = length; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if ( + Buffer.byteLength(utf8Window(buffer, middle, tail).text) <= budget + ) { + low = middle; + } else { + high = middle - 1; + } + } + return utf8Window(buffer, low, tail); +} + +/** Copies a prefix and rolling suffix, never retaining child-process chunk buffers. */ +export class OutputBuffer { + private readonly headCapacity: number; + private readonly tailCapacity: number; + private head?: Buffer; + private tail?: Buffer; + private headLength = 0; + private tailLength = 0; + private tailOffset = 0; + private totalBytes = 0; + + constructor(capacity: number) { + this.headCapacity = Math.floor(capacity / 2); + this.tailCapacity = capacity - this.headCapacity; + } + + get bytes(): number { + return this.totalBytes; + } + + append(chunk: Buffer): void { + this.totalBytes += chunk.length; + const headBytes = Math.min( + chunk.length, + this.headCapacity - this.headLength + ); + if (headBytes > 0) { + this.head ??= Buffer.allocUnsafe(this.headCapacity); + chunk.copy(this.head, this.headLength, 0, headBytes); + this.headLength += headBytes; + } + const remaining = chunk.length - headBytes; + if (remaining === 0) return; + this.tail ??= Buffer.allocUnsafe(this.tailCapacity); + if (remaining >= this.tailCapacity) { + chunk.copy(this.tail, 0, chunk.length - this.tailCapacity); + this.tailOffset = 0; + this.tailLength = this.tailCapacity; + return; + } + const first = Math.min(remaining, this.tailCapacity - this.tailOffset); + chunk.copy(this.tail, this.tailOffset, headBytes, headBytes + first); + chunk.copy(this.tail, 0, headBytes + first); + this.tailOffset = (this.tailOffset + remaining) % this.tailCapacity; + this.tailLength = Math.min( + this.tailCapacity, + this.tailLength + remaining + ); + } + + private suffix(): Buffer { + if (!this.tail) return Buffer.alloc(0); + if (this.tailLength < this.tailCapacity) + return this.tail.subarray(0, this.tailLength); + if (this.tailOffset === 0) return this.tail; + return Buffer.concat([ + this.tail.subarray(this.tailOffset), + this.tail.subarray(0, this.tailOffset), + ]); + } + + render(budget: number): { text: string; truncated: boolean } { + const head = this.head?.subarray(0, this.headLength) ?? Buffer.alloc(0); + const tail = this.suffix(); + if (this.totalBytes <= budget) { + const text = Buffer.concat([head, tail]).toString('utf8'); + if (Buffer.byteLength(text) <= budget) + return { text, truncated: false }; + } + const marker = (bytes: number): string => + `\n[... ${bytes} bytes omitted ...]\n`; + const markerBytes = Buffer.byteLength(marker(this.totalBytes)); + const includeMarker = budget >= markerBytes; + const contentBudget = includeMarker ? budget - markerBytes : budget; + const prefix = boundedWindow( + head, + Math.floor(contentBudget / 2), + false + ); + const suffix = boundedWindow(tail, Math.ceil(contentBudget / 2), true); + const omitted = this.totalBytes - prefix.bytes - suffix.bytes; + return { + text: + prefix.text + + (includeMarker ? marker(omitted) : '') + + suffix.text, + truncated: omitted > 0, + }; + } +} + +/** Each stream can use the whole budget when the other is quiet; stderr cannot be starved. */ +export function renderCommandOutput( + stdout: OutputBuffer, + stderr: OutputBuffer, + budget: number +): { + stdout: string; + stderr: string; + truncated: boolean; +} { + if (stdout.bytes + stderr.bytes <= budget) { + const out = stdout.render(budget); + const err = stderr.render(budget); + if ( + Buffer.byteLength(out.text) + Buffer.byteLength(err.text) <= + budget + ) { + return { + stdout: out.text, + stderr: err.text, + truncated: out.truncated || err.truncated, + }; + } + } + const stderrReserve = Math.ceil(budget / 2); + const stdoutBudget = Math.min( + stdout.bytes, + budget - stderrReserve + Math.max(0, stderrReserve - stderr.bytes) + ); + const out = stdout.render(stdoutBudget); + const err = stderr.render(budget - stdoutBudget); + return { + stdout: out.text, + stderr: err.text, + truncated: out.truncated || err.truncated, + }; +}