Files
boocode/apps/server/src/services/tools/execute-command.ts
indifferentketchup abe9c5a3a8 feat: Paseo-like orchestrator Phase 1-2 — trace system, session persistence, timeline, run_command, auto-fix loop
Phase 1: Trace System + Observability
- tool_traces DB table + insert/update service
- tool_trace_start/tool_trace_finish WS frames (contracts + FE types)
- Instrumented tool-phase.ts with timing around every tool call
- GET /api/chats/:id/traces paginated endpoint
- Trace viewer frontend (collapsible panel with timing bars + token breakdown)

Phase 2: Session Persistence + Resume
- agent_snapshots table (UPSERT per chat, persisted on turn boundaries)
- save/load/delete service functions
- Agent snapshot sent on WS reconnect
- Session timeline view (vertical timeline with scroll-to + restore)

Tooling:
- run_command tool (execFile, 30s timeout, 32KB cap, path-guarded)
- Auto-fix loop: after write tools, runs pnpm build, injects errors into next turn
2026-06-08 02:26:47 +00:00

133 lines
4.3 KiB
TypeScript

/**
* vWhale: run_command tool. Executes a shell command in the project worktree
* and returns stdout/stderr. Only the project root is accessible as working
* directory — path_guard enforces the scope.
*
* Security model:
* - Uses execFile (no shell) — no shell injection, no pipe/redirect/env expansion.
* - args passed as array, never a string.
* - 30s timeout default, configure per-call.
* - 32KB output cap with truncation (same pattern as web_fetch.ts).
* - Working directory restricted to project root via path_guard.
* - No background processes allowed (waits for completion).
*/
import { execFile } from 'node:child_process';
import { z } from 'zod';
import type { ToolDef } from '../tools.js';
const RunCommandInput = z.object({
command: z.string().min(1).max(256),
args: z.array(z.string()).default([]),
description: z.string().max(256).optional(),
timeout_ms: z.number().int().positive().max(120_000).optional(),
});
export type RunCommandInputT = z.infer<typeof RunCommandInput>;
const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_OUTPUT_CHARS = 32_000;
export type RunCommandOutput =
| {
command: string;
args: string[];
exit_code: number;
stdout: string;
stderr: string;
truncated: boolean;
duration_ms: number;
}
| {
error: string;
reason: string;
};
export async function executeRunCommand(
input: RunCommandInputT,
projectRoot: string,
): Promise<RunCommandOutput> {
const timeoutMs = input.timeout_ms ?? DEFAULT_TIMEOUT_MS;
const startTime = Date.now();
return new Promise((resolve) => {
const child = execFile(
input.command,
input.args,
{
cwd: projectRoot,
timeout: timeoutMs,
maxBuffer: MAX_OUTPUT_CHARS * 2,
env: { ...process.env },
},
(err, stdout, stderr) => {
const durationMs = Date.now() - startTime;
// Truncate output if needed
const truncated = stdout.length + stderr.length > MAX_OUTPUT_CHARS;
const cappedStdout = truncated ? stdout.slice(0, MAX_OUTPUT_CHARS) : stdout;
const cappedStderr = truncated ? stderr.slice(0, Math.max(MAX_OUTPUT_CHARS - cappedStdout.length, 0)) : stderr;
const exitCode = err?.code === 'ENOENT' ? -1 : (err as Error & { code?: number })?.code ?? 0;
resolve({
command: input.command,
args: input.args,
exit_code: typeof exitCode === 'number' ? exitCode : 1,
stdout: cappedStdout,
stderr: cappedStderr,
truncated,
duration_ms: durationMs,
});
},
);
});
}
export const runCommand: ToolDef<RunCommandInputT> = {
name: 'run_command',
description:
'Run a shell command in the project workspace and return stdout + stderr. ' +
'The command runs in the project root directory. ' +
'Use for: building, testing, linting, git operations, running scripts. ' +
'Output is capped at 32KB. Timeout defaults to 30s (max 120s). ' +
'Security: args are passed as array (no shell injection). No background processes.',
inputSchema: RunCommandInput as unknown as z.ZodType<RunCommandInputT>,
jsonSchema: {
type: 'function',
function: {
name: 'run_command',
description:
'Execute a command in the project workspace. ' +
'Use for builds, tests, linting, git commands, and scripts. ' +
'The process runs with a 30s timeout and 32KB output cap.',
parameters: {
type: 'object',
properties: {
command: {
type: 'string',
description: 'Command to execute (e.g. pnpm, npm, npx, node, git, ls, cat).',
},
args: {
type: 'array',
items: { type: 'string' },
description: 'Arguments as array (e.g. ["run", "build"]). Never embedded in a shell string.',
},
description: {
type: 'string',
description: 'Optional human-readable description of what this command does.',
},
timeout_ms: {
type: 'integer',
description: 'Timeout in milliseconds. Default 30000, max 120000.',
},
},
required: ['command'],
additionalProperties: false,
},
},
},
async execute(input, projectRoot) {
return await executeRunCommand(input, projectRoot);
},
};