mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user