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
133 lines
4.3 KiB
TypeScript
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);
|
|
},
|
|
};
|