Files
AirCoding/packages/runtime/src/tools/shell/index.ts
AirCoding ddefcbb2b1 fix: integrate audit findings round 1 - tools, worker, scheduler, main agent
- Unify ToolResultEnvelope (output vs content) for built-in tools
- Fix shell.run AsyncGenerator consumption in ToolRegistry.call/streaming
- Scheduler: consume WorkerResult.status instead of marking all running tasks completed
- WorkerProcess/WorkerManager: surface exit events and generate failed/cancelled result
- MainAgent: integrate ContextAssembler, Chinese destructive regex, ArchitectureDesigner impact gate
- run.ts: pendingConfirmation flow, dispatch extracted, .air files filtered from /results
- CapabilityRegistry wired into RuntimeApp and ServiceRegistry; DoctorService uses it
- release.ts: findRepoRoot/findBun, run air e2e + depcruise + runtime regression
- New gates: release-critical-gates, CLI run command regression
- 14/14 e2e gates pass; 3/3 release dry-run pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 18:39:10 +08:00

146 lines
4.6 KiB
TypeScript
Executable File

/**
* Shell Tool - Command execution
*
* Implements T-207: shell.run
* Emits command.started/completed events; streaming stdout/stderr.
*
* @module packages/runtime/src/tools/shell
*/
import { spawn } from 'child_process'
import type { ToolDefinition, ToolCall, ToolResultEnvelope, ISOTimeString } from '@aircoding/contracts'
export const shell_run: ToolDefinition = {
name: 'shell.run',
category: 'shell',
description: 'Run a shell command',
version: 1,
output_schema: { type: 'object', properties: {}, required: [] },
input_schema: {
type: 'object',
properties: {
command: { type: 'string', description: 'Command to execute' },
workdir: { type: 'string', description: 'Working directory' },
timeout: { type: 'number', default: 300000, description: 'Timeout in milliseconds' },
env: { type: 'object', description: 'Environment variables to add' }
},
required: ['command']
},
permissions: { network: true },
streaming: true
}
export function createShellExecutor(project_root: string) {
return {
'shell.run': async function* (call: ToolCall, context: ToolExecutionContext): AsyncGenerator<ToolResultEnvelope> {
const { command, workdir, timeout = 300000, env = {} } = call.arguments as {
command: string
workdir?: string
timeout?: number
env?: Record<string, string>
}
const cwd = workdir || project_root
const timestamp = new Date().toISOString() as ISOTimeString
yield {
status: 'ok',
output: { event: 'command.started', command, cwd },
metadata: { timestamp, streaming: true, call_id: call.call_id, tool_name: 'shell.run' }
}
const proc = spawn(command, [], {
cwd,
shell: true,
env: { ...process.env, ...env }
})
let stdout = ''
let stderr = ''
let timed_out = false
const chunks: ToolResultEnvelope[] = []
proc.stdout.on('data', (data) => {
const text = data.toString()
stdout += text
chunks.push({
status: 'ok',
output: { event: 'command.stdout', text },
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' }
})
})
proc.stderr.on('data', (data) => {
const text = data.toString()
stderr += text
chunks.push({
status: 'ok',
output: { event: 'command.stderr', text },
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' }
})
})
const timeout_id = setTimeout(() => {
timed_out = true
proc.kill('SIGKILL')
}, timeout)
const exit_code = await new Promise<number>((resolve) => {
proc.on('exit', (code) => resolve(code ?? 0))
proc.on('error', () => resolve(1))
})
clearTimeout(timeout_id)
if (stdout) {
yield {
status: 'ok',
output: { event: 'command.stdout', text: stdout.slice(-50000) },
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' }
}
}
if (stderr) {
yield {
status: 'ok',
output: { event: 'command.stderr', text: stderr.slice(-10000) },
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' }
}
}
while (chunks.length > 0) {
yield chunks.shift()!
}
if (timed_out) {
stderr += `\n[Command timed out after ${timeout}ms]`
}
yield {
status: exit_code === 0 ? 'ok' : 'error',
output: {
event: 'command.completed',
exit_code,
stdout: stdout.slice(-50000),
stderr: stderr.slice(-10000),
timed_out
},
error: exit_code === 0 ? undefined : {
error_id: call.call_id,
kind: 'tool_error',
severity: 'error',
message: timed_out ? `Command timed out after ${timeout}ms` : `Command exited with code ${exit_code}`,
retryability: timed_out ? 'retryable' : 'not_retryable',
semantic_signature: 'shell.run'
},
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: false, is_final: true, call_id: call.call_id, tool_name: 'shell.run' }
}
}
}
}
interface ToolExecutionContext {
session_id: string
project_id: string
project_root: string
agent_id: string
agent_type: string
}