Skip to content

Commit cc31140

Browse files
committed
Extract execution tool group into agent-tools/execution.ts
Moves run_command/run_code, the dangerous-command blocklist, and the background-command lifecycle (start/get_output/stop/list/kill*) out of agent-tools.ts. Pure move, executeTool's dispatch is unchanged.
1 parent 990e405 commit cc31140

2 files changed

Lines changed: 300 additions & 276 deletions

File tree

‎app/src/agent-tools.ts‎

Lines changed: 23 additions & 276 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,7 @@
1-
import * as fs from "node:fs";
2-
import * as os from "node:os";
3-
import * as path from "node:path";
4-
import { exec, spawn, type ChildProcess } from "node:child_process";
5-
import { promisify } from "node:util";
6-
import { randomUUID } from "node:crypto";
71
import { getAccountToken } from "./accounts";
82
import { capturePageScreenshot } from "./browser-capture";
9-
import { killProcessTree } from "./process-tree";
10-
import { applySandbox, shellQuote } from "./command-sandbox";
11-
import { monitorProcess } from "./resource-monitor";
3+
import { shellQuote } from "./command-sandbox";
124
import * as settingsStore from "./settings-store";
13-
import { resolveSafePath } from "./workspace-path";
145
import * as terminalManager from "./terminal-manager";
156
import { AGENT_TOOLS } from "./agent-tools/schema";
167
import {
@@ -33,6 +24,17 @@ import {
3324
detectProjectScripts,
3425
} from "./agent-tools/filesystem";
3526
import type { RollbackResult, SearchMatch, ProjectScripts } from "./agent-tools/filesystem";
27+
import {
28+
findDangerousCommandReason,
29+
runCommand,
30+
runCode,
31+
startBackgroundCommand,
32+
getBackgroundOutput,
33+
stopBackgroundCommand,
34+
listBackgroundCommands,
35+
killAllBackgroundCommands,
36+
killBackgroundCommandsForWorkspace,
37+
} from "./agent-tools/execution";
3638

3739
export { AGENT_TOOLS };
3840
export {
@@ -54,272 +56,17 @@ export {
5456
detectProjectScripts,
5557
};
5658
export type { RollbackResult, SearchMatch, ProjectScripts };
57-
58-
const execAsync = promisify(exec);
59-
60-
const MAX_COMMAND_OUTPUT_CHARS = 50_000;
61-
const COMMAND_TIMEOUT_MS = 60_000;
62-
63-
// Defense in depth for `run_command`: the workspace-root sandboxing above
64-
// only constrains our own read_file/write_file/list_dir/search_files
65-
// implementations, which build and validate paths themselves. A shell
66-
// command is opaque text — it can reference any path on disk (`rm -rf ~`,
67-
// `del C:\Windows`) regardless of the `cwd` we launch it in, so `cwd`
68-
// alone is not a real sandbox against a destructive command. This can't
69-
// catch everything a shell is capable of, but it blocks the common,
70-
// catastrophic patterns outright — even if the user already clicked
71-
// "Allow" without noticing what the command actually does.
72-
const DANGEROUS_COMMAND_PATTERNS: RegExp[] = [
73-
/\brm\s+(-\w*r\w*f\w*|-\w*f\w*r\w*)\s+(\/|~|\*|\$HOME|\.\.)/i, // rm -rf /, ~, *, ..
74-
/\bdel\s+\/[sf]\s.*[a-z]:\\/i, // del /s /q C:\...
75-
/\brd\s+\/s\s+\/q\s+[a-z]:\\/i, // rd /s /q C:\...
76-
/\bformat\s+[a-z]:/i,
77-
/\bdiskpart\b/i,
78-
/\bmkfs(\.\w+)?\b/i,
79-
/\bdd\s+if=.*\bof=\/dev\//i,
80-
/\b(shutdown|reboot)\b/i,
81-
/\bRestart-Computer\b/i,
82-
/\bStop-Computer\b/i,
83-
/:\(\)\s*\{\s*:\|\s*:&\s*\}\s*;\s*:/, // classic fork bomb
84-
/\breg(\.exe)?\s+delete\b/i,
85-
/\bregedit\b/i,
86-
/\bsudo\b/i,
87-
/\brunas\b/i,
88-
/\bchmod\s+-R\s+777\s+\//i,
89-
/\bcurl\b[^|]*\|\s*(sh|bash|zsh)\b/i, // curl ... | sh
90-
/\b(iwr|Invoke-WebRequest)\b[^|]*\|\s*(iex|Invoke-Expression)\b/i,
91-
];
92-
93-
export function findDangerousCommandReason(command: string): string | null {
94-
const match = DANGEROUS_COMMAND_PATTERNS.find((pattern) => pattern.test(command));
95-
return match
96-
? "This command was blocked because it matches a pattern that could affect your whole system rather than just the workspace folder (e.g. deleting outside it, a system shutdown, or a privilege-escalation attempt)."
97-
: null;
98-
}
99-
100-
function truncateOutput(text: string): string {
101-
return text.length > MAX_COMMAND_OUTPUT_CHARS
102-
? `${text.slice(0, MAX_COMMAND_OUTPUT_CHARS)}\n[truncated]`
103-
: text;
104-
}
105-
106-
function formatCommandResult(stdout: string, stderr: string, exitCode: number | null): string {
107-
const parts = [`Exit code: ${exitCode}`];
108-
if (stdout) parts.push(`--- stdout ---\n${truncateOutput(stdout)}`);
109-
if (stderr) parts.push(`--- stderr ---\n${truncateOutput(stderr)}`);
110-
return parts.join("\n\n");
111-
}
112-
113-
export async function runCommand(
114-
workspaceRoot: string,
115-
command: string,
116-
relativeCwd = ".",
117-
network = false
118-
): Promise<string> {
119-
const dangerReason = findDangerousCommandReason(command);
120-
if (dangerReason) throw new Error(dangerReason);
121-
122-
const cwd = resolveSafePath(workspaceRoot, relativeCwd);
123-
const wrappedCommand = applySandbox(command, { workspaceRoot, allowNetwork: network, cwd });
124-
const settings = settingsStore.getSettings();
125-
let stopMonitor = () => {};
126-
try {
127-
const execPromise = execAsync(wrappedCommand, {
128-
cwd,
129-
timeout: COMMAND_TIMEOUT_MS,
130-
maxBuffer: 10 * 1024 * 1024,
131-
});
132-
// execPromise.child is a documented feature of promisify(exec) — the
133-
// returned promise carries the underlying ChildProcess, which is the
134-
// only way to get its pid for resource-monitor.ts to watch.
135-
if (execPromise.child.pid) {
136-
stopMonitor = monitorProcess(
137-
execPromise.child.pid,
138-
{ maxMemoryMB: settings.sandboxMaxMemoryMB, maxCpuPercent: settings.sandboxMaxCpuPercent },
139-
() => execPromise.child.kill()
140-
);
141-
}
142-
const { stdout, stderr } = await execPromise;
143-
return formatCommandResult(stdout, stderr, 0);
144-
} catch (err) {
145-
const e = err as { stdout?: string; stderr?: string; code?: number; killed?: boolean; message: string };
146-
if (e.killed) {
147-
return `Command timed out after ${COMMAND_TIMEOUT_MS / 1000}s.\n\n${formatCommandResult(e.stdout ?? "", e.stderr ?? "", e.code ?? null)}`;
148-
}
149-
return formatCommandResult(e.stdout ?? "", e.stderr ?? e.message, e.code ?? null);
150-
} finally {
151-
stopMonitor();
152-
}
153-
}
154-
155-
// run_code is a thin convenience wrapper over run_command for multi-line
156-
// snippets (avoids shell-quoting hell for real code) — it carries the exact
157-
// same risk and is checked against the exact same blocklist as run_command,
158-
// applied to the source text too since dangerous *content* (not just the
159-
// invocation) could otherwise slip past a check that only looks at the
160-
// command line.
161-
export async function runCode(
162-
workspaceRoot: string,
163-
language: "python" | "javascript",
164-
code: string,
165-
relativeCwd = ".",
166-
network = false
167-
): Promise<string> {
168-
const dangerReason = findDangerousCommandReason(code);
169-
if (dangerReason) throw new Error(dangerReason);
170-
171-
const ext = language === "python" ? "py" : "js";
172-
const tmpFile = path.join(os.tmpdir(), `modelforge-code-${randomUUID()}.${ext}`);
173-
fs.writeFileSync(tmpFile, code);
174-
try {
175-
const interpreter = language === "python" ? "python3" : "node";
176-
return await runCommand(workspaceRoot, `${interpreter} "${tmpFile}"`, relativeCwd, network);
177-
} finally {
178-
fs.rmSync(tmpFile, { force: true });
179-
}
180-
}
181-
182-
interface BackgroundTask {
183-
id: string;
184-
name: string;
185-
command: string;
186-
process: ChildProcess;
187-
// Absolute path — lets killBackgroundCommandsForWorkspace() target only
188-
// the tasks that belong to a workspace being switched away from, rather
189-
// than every background task the process has ever started.
190-
workspaceRoot: string;
191-
// Rolling tail of combined stdout+stderr — capped so a chatty dev server
192-
// can't grow memory unboundedly over a long session.
193-
output: string;
194-
exitCode: number | null;
195-
startedAt: number;
196-
}
197-
198-
const MAX_BACKGROUND_TASKS = 5;
199-
const MAX_BACKGROUND_OUTPUT_CHARS = 100_000;
200-
const BACKGROUND_OUTPUT_TAIL_CHARS = 20_000;
201-
const backgroundTasks = new Map<string, BackgroundTask>();
202-
203-
export function startBackgroundCommand(
204-
workspaceRoot: string,
205-
command: string,
206-
relativeCwd = ".",
207-
name?: string,
208-
network = false
209-
): { taskId: string; name: string } {
210-
const dangerReason = findDangerousCommandReason(command);
211-
if (dangerReason) throw new Error(dangerReason);
212-
const runningCount = [...backgroundTasks.values()].filter((t) => t.exitCode === null).length;
213-
if (runningCount >= MAX_BACKGROUND_TASKS) {
214-
throw new Error(`Already running ${MAX_BACKGROUND_TASKS} background commands — stop one first.`);
215-
}
216-
217-
const cwd = resolveSafePath(workspaceRoot, relativeCwd);
218-
const wrappedCommand = applySandbox(command, { workspaceRoot, allowNetwork: network, cwd });
219-
// detached so the shell becomes its own process group leader — lets
220-
// killProcessTree() below signal the whole group (shell + whatever it
221-
// spawned, e.g. `npm run dev` spawning `node`) instead of just the shell
222-
// itself, which is all a plain .kill() would reach. No effect on Windows,
223-
// where killProcessTree uses `taskkill /t` instead.
224-
const child = spawn(wrappedCommand, {
225-
cwd,
226-
shell: true,
227-
stdio: ["ignore", "pipe", "pipe"],
228-
detached: process.platform !== "win32",
229-
});
230-
const id = randomUUID().slice(0, 8);
231-
const task: BackgroundTask = {
232-
id,
233-
name: name?.trim() || command.slice(0, 40),
234-
command,
235-
process: child,
236-
workspaceRoot: path.resolve(workspaceRoot),
237-
output: "",
238-
exitCode: null,
239-
startedAt: Date.now(),
240-
};
241-
const append = (chunk: Buffer) => {
242-
task.output += chunk.toString();
243-
if (task.output.length > MAX_BACKGROUND_OUTPUT_CHARS) {
244-
task.output = task.output.slice(-MAX_BACKGROUND_OUTPUT_CHARS);
245-
}
246-
};
247-
child.stdout?.on("data", append);
248-
child.stderr?.on("data", append);
249-
child.on("error", (err) => {
250-
task.output += `\n[failed to start: ${err.message}]`;
251-
task.exitCode = -1;
252-
});
253-
const settings = settingsStore.getSettings();
254-
const stopMonitor = child.pid
255-
? monitorProcess(
256-
child.pid,
257-
{ maxMemoryMB: settings.sandboxMaxMemoryMB, maxCpuPercent: settings.sandboxMaxCpuPercent },
258-
(reason) => append(Buffer.from(`\n[background task stopped: ${reason}]`))
259-
)
260-
: () => {};
261-
child.on("exit", (code) => {
262-
stopMonitor();
263-
task.exitCode = code ?? -1;
264-
});
265-
backgroundTasks.set(id, task);
266-
return { taskId: id, name: task.name };
267-
}
268-
269-
export function getBackgroundOutput(taskId: string): string {
270-
const task = backgroundTasks.get(taskId);
271-
if (!task) throw new Error(`No background task with id "${taskId}".`);
272-
const status =
273-
task.exitCode === null
274-
? `running (${Math.round((Date.now() - task.startedAt) / 1000)}s)`
275-
: `exited with code ${task.exitCode}`;
276-
const tail =
277-
task.output.length > BACKGROUND_OUTPUT_TAIL_CHARS
278-
? `[...earlier output trimmed]\n${task.output.slice(-BACKGROUND_OUTPUT_TAIL_CHARS)}`
279-
: task.output;
280-
return `Task ${task.id} (${task.name}): ${status}\n--- output ---\n${tail || "(no output yet)"}`;
281-
}
282-
283-
export function stopBackgroundCommand(taskId: string): string {
284-
const task = backgroundTasks.get(taskId);
285-
if (!task) throw new Error(`No background task with id "${taskId}".`);
286-
if (task.exitCode !== null) return `Task ${task.id} had already exited with code ${task.exitCode}.`;
287-
if (task.process.pid) killProcessTree(task.process.pid);
288-
return `Task ${task.id} (${task.name}) stopped.`;
289-
}
290-
291-
export function listBackgroundCommands(): { id: string; name: string; command: string; status: string }[] {
292-
return [...backgroundTasks.values()].map((t) => ({
293-
id: t.id,
294-
name: t.name,
295-
command: t.command,
296-
status: t.exitCode === null ? "running" : `exited (${t.exitCode})`,
297-
}));
298-
}
299-
300-
export function killAllBackgroundCommands(): void {
301-
for (const task of backgroundTasks.values()) {
302-
if (task.exitCode === null && task.process.pid) killProcessTree(task.process.pid);
303-
}
304-
backgroundTasks.clear();
305-
}
306-
307-
// Only tears down tasks belonging to the workspace being switched away from
308-
// — background commands are otherwise never cleaned up until app quit
309-
// (killAllBackgroundCommands, called from window-all-closed/before-quit),
310-
// so switching to a different workspace mid-session used to leave the old
311-
// one's tasks running indefinitely, silently eating into MAX_BACKGROUND_TASKS.
312-
export function killBackgroundCommandsForWorkspace(workspaceRoot: string): number {
313-
const root = path.resolve(workspaceRoot);
314-
let killed = 0;
315-
for (const [id, task] of backgroundTasks) {
316-
if (task.workspaceRoot !== root) continue;
317-
if (task.exitCode === null && task.process.pid) killProcessTree(task.process.pid);
318-
backgroundTasks.delete(id);
319-
killed++;
320-
}
321-
return killed;
322-
}
59+
export {
60+
findDangerousCommandReason,
61+
runCommand,
62+
runCode,
63+
startBackgroundCommand,
64+
getBackgroundOutput,
65+
stopBackgroundCommand,
66+
listBackgroundCommands,
67+
killAllBackgroundCommands,
68+
killBackgroundCommandsForWorkspace,
69+
};
32370

32471
function gitCommand(workspaceRoot: string, args: string): Promise<string> {
32572
return runCommand(workspaceRoot, `git ${args}`, ".");

0 commit comments

Comments
 (0)