From 5ab8b0e33ebde99f511ea9ec006b54d1725e59c9 Mon Sep 17 00:00:00 2001 From: duyua9 Date: Wed, 13 May 2026 12:47:57 +0800 Subject: [PATCH] fix(computer-use-mcp): bound terminal output capture (#1802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - cap local `terminal_exec` stdout/stderr capture at a fixed per-stream limit - report whether each stream was truncated, along with the original captured length - add runner coverage for commands that emit large stdout and stderr payloads ## Why The local shell runner currently appends stdout/stderr without a boundary before returning `TerminalCommandResult`. Large command output can grow the MCP response and stored terminal state far beyond what is useful for the agent. This keeps command execution semantics the same while bounding the returned text and making truncation explicit to callers. ## Tests - `pnpm -F @proj-airi/computer-use-mcp test -- src/terminal/runner.test.ts` - `pnpm -F @proj-airi/computer-use-mcp typecheck` - `git diff --check origin/main...HEAD` Co-authored-by: 刘梓恒 <160735726+3361559784@users.noreply.github.com> --- .../src/terminal/runner.test.ts | 19 +++++- .../computer-use-mcp/src/terminal/runner.ts | 65 ++++++++++++++++--- services/computer-use-mcp/src/types.ts | 4 ++ 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/services/computer-use-mcp/src/terminal/runner.test.ts b/services/computer-use-mcp/src/terminal/runner.test.ts index 44e511ae3..b90d3b047 100644 --- a/services/computer-use-mcp/src/terminal/runner.test.ts +++ b/services/computer-use-mcp/src/terminal/runner.test.ts @@ -1,7 +1,9 @@ +import { execPath } from 'node:process' + import { describe, expect, it } from 'vitest' import { createTestConfig } from '../test-fixtures' -import { createLocalShellRunner } from './runner' +import { createLocalShellRunner, TERMINAL_OUTPUT_MAX_CHARS } from './runner' describe('createLocalShellRunner', () => { it('executes commands and keeps cwd sticky across calls', async () => { @@ -34,6 +36,21 @@ describe('createLocalShellRunner', () => { expect(runner.getState().lastExitCode).toBe(7) }) + it('bounds captured stdout and stderr for large command output', async () => { + const runner = createLocalShellRunner(createTestConfig()) + const result = await runner.execute({ + command: `${JSON.stringify(execPath)} -e "process.stdout.write('o'.repeat(20000)); process.stderr.write('e'.repeat(20000))"`, + }) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toHaveLength(TERMINAL_OUTPUT_MAX_CHARS) + expect(result.stderr).toHaveLength(TERMINAL_OUTPUT_MAX_CHARS) + expect(result.stdoutTruncated).toBe(true) + expect(result.stderrTruncated).toBe(true) + expect(result.stdoutOriginalLength).toBe(20_000) + expect(result.stderrOriginalLength).toBe(20_000) + }) + it('resets the tracked state', async () => { const runner = createLocalShellRunner(createTestConfig()) await runner.execute({ diff --git a/services/computer-use-mcp/src/terminal/runner.ts b/services/computer-use-mcp/src/terminal/runner.ts index 5714e8951..1dd111fbf 100644 --- a/services/computer-use-mcp/src/terminal/runner.ts +++ b/services/computer-use-mcp/src/terminal/runner.ts @@ -10,6 +10,46 @@ import type { import { spawn } from 'node:child_process' import { env, cwd as processCwd } from 'node:process' +export const TERMINAL_OUTPUT_MAX_CHARS = 16_384 + +interface OutputCapture { + value: string + originalLength: number + truncated: boolean +} + +function createOutputCapture(): OutputCapture { + return { + value: '', + originalLength: 0, + truncated: false, + } +} + +function appendOutput(capture: OutputCapture, chunk: string) { + capture.originalLength += chunk.length + + const remaining = TERMINAL_OUTPUT_MAX_CHARS - capture.value.length + if (remaining > 0) + capture.value += chunk.slice(0, remaining) + + if (chunk.length > remaining || capture.originalLength > TERMINAL_OUTPUT_MAX_CHARS) + capture.truncated = true +} + +function appendTimeoutMessage(capture: OutputCapture, timeoutMs: number): OutputCapture { + const message = `process timeout after ${timeoutMs}ms` + const separator = capture.value ? '\n' : '' + const combined = `${capture.value}${separator}${message}`.trim() + const combinedOriginalLength = capture.originalLength + separator.length + message.length + + return { + value: combined.slice(0, TERMINAL_OUTPUT_MAX_CHARS), + originalLength: combinedOriginalLength, + truncated: capture.truncated || combined.length > TERMINAL_OUTPUT_MAX_CHARS, + } +} + function summarizeCommand(command: string) { const compact = command.replace(/\s+/g, ' ').trim() return compact.length > 160 ? `${compact.slice(0, 157)}...` : compact @@ -50,8 +90,8 @@ export function createLocalShellRunner(config: ComputerUseConfig): TerminalRunne stdio: ['ignore', 'pipe', 'pipe'], }) - let stdout = '' - let stderr = '' + const stdout = createOutputCapture() + const stderr = createOutputCapture() let finished = false let timedOut = false @@ -62,10 +102,15 @@ export function createLocalShellRunner(config: ComputerUseConfig): TerminalRunne timedOut = true finished = true child.kill('SIGTERM') + const timeoutStderr = appendTimeoutMessage(stderr, timeoutMs) resolve({ command: input.command, - stdout, - stderr: `${stderr}${stderr ? '\n' : ''}process timeout after ${timeoutMs}ms`.trim(), + stdout: stdout.value, + stderr: timeoutStderr.value, + stdoutTruncated: stdout.truncated, + stderrTruncated: timeoutStderr.truncated, + stdoutOriginalLength: stdout.originalLength, + stderrOriginalLength: timeoutStderr.originalLength, exitCode: 124, effectiveCwd, durationMs: Date.now() - startedAt, @@ -76,11 +121,11 @@ export function createLocalShellRunner(config: ComputerUseConfig): TerminalRunne const cleanup = () => clearTimeout(stopTimer) child.stdout.on('data', (chunk) => { - stdout += chunk.toString('utf-8') + appendOutput(stdout, chunk.toString('utf-8')) }) child.stderr.on('data', (chunk) => { - stderr += chunk.toString('utf-8') + appendOutput(stderr, chunk.toString('utf-8')) }) child.on('error', (error) => { @@ -100,8 +145,12 @@ export function createLocalShellRunner(config: ComputerUseConfig): TerminalRunne cleanup() resolve({ command: input.command, - stdout, - stderr, + stdout: stdout.value, + stderr: stderr.value, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + stdoutOriginalLength: stdout.originalLength, + stderrOriginalLength: stderr.originalLength, exitCode: typeof code === 'number' ? code : 1, effectiveCwd, durationMs: Date.now() - startedAt, diff --git a/services/computer-use-mcp/src/types.ts b/services/computer-use-mcp/src/types.ts index 0d866f11c..ee7db8597 100644 --- a/services/computer-use-mcp/src/types.ts +++ b/services/computer-use-mcp/src/types.ts @@ -300,6 +300,10 @@ export interface TerminalCommandResult { command: string stdout: string stderr: string + stdoutTruncated?: boolean + stderrTruncated?: boolean + stdoutOriginalLength?: number + stderrOriginalLength?: number exitCode: number effectiveCwd: string durationMs: number