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" ;
71import { getAccountToken } from "./accounts" ;
82import { 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" ;
124import * as settingsStore from "./settings-store" ;
13- import { resolveSafePath } from "./workspace-path" ;
145import * as terminalManager from "./terminal-manager" ;
156import { AGENT_TOOLS } from "./agent-tools/schema" ;
167import {
@@ -33,6 +24,17 @@ import {
3324 detectProjectScripts ,
3425} from "./agent-tools/filesystem" ;
3526import 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
3739export { AGENT_TOOLS } ;
3840export {
@@ -54,272 +56,17 @@ export {
5456 detectProjectScripts ,
5557} ;
5658export 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- / \b r m \s + ( - \w * r \w * f \w * | - \w * f \w * r \w * ) \s + ( \/ | ~ | \* | \$ H O M E | \. \. ) / i, // rm -rf /, ~, *, ..
74- / \b d e l \s + \/ [ s f ] \s .* [ a - z ] : \\ / i, // del /s /q C:\...
75- / \b r d \s + \/ s \s + \/ q \s + [ a - z ] : \\ / i, // rd /s /q C:\...
76- / \b f o r m a t \s + [ a - z ] : / i,
77- / \b d i s k p a r t \b / i,
78- / \b m k f s ( \. \w + ) ? \b / i,
79- / \b d d \s + i f = .* \b o f = \/ d e v \/ / i,
80- / \b ( s h u t d o w n | r e b o o t ) \b / i,
81- / \b R e s t a r t - C o m p u t e r \b / i,
82- / \b S t o p - C o m p u t e r \b / i,
83- / : \( \) \s * \{ \s * : \| \s * : & \s * \} \s * ; \s * : / , // classic fork bomb
84- / \b r e g ( \. e x e ) ? \s + d e l e t e \b / i,
85- / \b r e g e d i t \b / i,
86- / \b s u d o \b / i,
87- / \b r u n a s \b / i,
88- / \b c h m o d \s + - R \s + 7 7 7 \s + \/ / i,
89- / \b c u r l \b [ ^ | ] * \| \s * ( s h | b a s h | z s h ) \b / i, // curl ... | sh
90- / \b ( i w r | I n v o k e - W e b R e q u e s t ) \b [ ^ | ] * \| \s * ( i e x | I n v o k e - E x p r e s s i o n ) \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
32471function gitCommand ( workspaceRoot : string , args : string ) : Promise < string > {
32572 return runCommand ( workspaceRoot , `git ${ args } ` , "." ) ;
0 commit comments