fix(computer-use-mcp): bound terminal output capture (#1802)

## 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>
This commit is contained in:
duyua9
2026-05-13 12:47:57 +08:00
committed by GitHub
co-authored by 刘梓恒
parent a161badad8
commit 5ab8b0e33e
3 changed files with 79 additions and 9 deletions
@@ -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({
@@ -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,
+4
View File
@@ -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