11import * as pty from 'node-pty' ;
2- import { execFileSync } from 'child_process' ;
2+ import { execFileSync , execFile } from 'child_process' ;
33import fs from 'fs' ;
44import type { BrowserWindow } from 'electron' ;
55import { RingBuffer } from '../remote/ring-buffer.js' ;
@@ -13,6 +13,8 @@ interface PtySession {
1313 flushTimer : ReturnType < typeof setTimeout > | null ;
1414 subscribers : Set < ( encoded : string ) => void > ;
1515 scrollback : RingBuffer ;
16+ /** Assigned container name when running in Docker mode, null otherwise. */
17+ containerName : string | null ;
1618}
1719
1820const sessions = new Map < string , PtySession > ( ) ;
@@ -158,13 +160,34 @@ export function spawnAgent(
158160 let spawnCommand : string ;
159161 let spawnArgs : string [ ] ;
160162
163+ // Derive a predictable, unique container name from the agentId so we can
164+ // reliably stop it later without having to parse docker inspect output.
165+ const containerName = args . dockerMode
166+ ? `parallel-code-${ args . agentId . slice ( 0 , 8 ) } `
167+ : null ;
168+
161169 if ( args . dockerMode ) {
162170 const image = args . dockerImage || 'ubuntu:latest' ;
163171 spawnCommand = 'docker' ;
164172 spawnArgs = [
165173 'run' ,
166174 '--rm' ,
167175 '-it' ,
176+ // Predictable name so we can stop the container on kill
177+ '--name' ,
178+ containerName ! ,
179+ // Label so we can identify all containers owned by this app
180+ '--label' ,
181+ 'parallel-code=true' ,
182+ // Host networking — agents need internet access for API calls and package installs.
183+ // Filesystem isolation (volume mounts) is the primary safety goal, not network isolation.
184+ '--network' ,
185+ 'host' ,
186+ // Resource limits to prevent runaway containers
187+ '--memory' ,
188+ '8g' ,
189+ '--pids-limit' ,
190+ '512' ,
168191 // Mount the project directory as the only writable volume
169192 '-v' ,
170193 `${ cwd } :${ cwd } ` ,
@@ -200,6 +223,7 @@ export function spawnAgent(
200223 flushTimer : null ,
201224 subscribers : new Set ( ) ,
202225 scrollback : new RingBuffer ( ) ,
226+ containerName,
203227 } ;
204228 sessions . set ( args . agentId , session ) ;
205229
@@ -324,6 +348,12 @@ export function killAgent(agentId: string): void {
324348 // notify stale listeners. Let onExit handle sessions.delete
325349 // and emitPtyEvent to avoid the race condition.
326350 session . subscribers . clear ( ) ;
351+ // Stop the Docker container first so it doesn't keep running after the
352+ // local PTY process (docker run) is killed. Fire-and-forget; the PTY kill
353+ // below is the authoritative termination signal.
354+ if ( session . containerName ) {
355+ stopDockerContainer ( session . containerName ) ;
356+ }
327357 session . proc . kill ( ) ;
328358 }
329359}
@@ -336,6 +366,9 @@ export function killAllAgents(): void {
336366 for ( const [ , session ] of sessions ) {
337367 if ( session . flushTimer ) clearTimeout ( session . flushTimer ) ;
338368 session . subscribers . clear ( ) ;
369+ if ( session . containerName ) {
370+ stopDockerContainer ( session . containerName ) ;
371+ }
339372 session . proc . kill ( ) ;
340373 }
341374 // Let onExit handlers clean up sessions individually
@@ -382,29 +415,63 @@ export function getAgentCols(agentId: string): number {
382415
383416// --- Docker mode helpers ---
384417
385- /** Env vars to forward into the Docker container (API keys, git identity, etc.). */
386- const DOCKER_ENV_FORWARD = [
387- 'ANTHROPIC_API_KEY' ,
388- 'OPENAI_API_KEY' ,
389- 'GEMINI_API_KEY' ,
390- 'GOOGLE_API_KEY' ,
391- 'GIT_AUTHOR_NAME' ,
392- 'GIT_AUTHOR_EMAIL' ,
393- 'GIT_COMMITTER_NAME' ,
394- 'GIT_COMMITTER_EMAIL' ,
395- 'TERM' ,
396- 'COLORTERM' ,
397- 'LANG' ,
398- 'HOME' ,
399- 'USER' ,
400- 'PATH' ,
401- ] ;
418+ /**
419+ * Env vars that are desktop/host-specific and must NOT be forwarded into the
420+ * container. Everything else is forwarded so agents can use arbitrary vars
421+ * (custom API keys, feature flags, tool config, etc.) without needing an
422+ * ever-growing allowlist.
423+ */
424+ const DOCKER_ENV_BLOCK_LIST = new Set ( [
425+ // Display / desktop session
426+ 'DISPLAY' ,
427+ 'WAYLAND_DISPLAY' ,
428+ 'DBUS_SESSION_BUS_ADDRESS' ,
429+ 'DBUS_SYSTEM_BUS_ADDRESS' ,
430+ 'DESKTOP_SESSION' ,
431+ 'XDG_CURRENT_DESKTOP' ,
432+ 'XDG_RUNTIME_DIR' ,
433+ 'XDG_SESSION_CLASS' ,
434+ 'XDG_SESSION_ID' ,
435+ 'XDG_SESSION_TYPE' ,
436+ 'XDG_VTNR' ,
437+ 'WINDOWID' ,
438+ 'XAUTHORITY' ,
439+ // Electron / Node host internals
440+ 'ELECTRON_RUN_AS_NODE' ,
441+ 'ELECTRON_NO_ATTACH_CONSOLE' ,
442+ 'ELECTRON_ENABLE_LOGGING' ,
443+ 'ELECTRON_ENABLE_STACK_DUMPING' ,
444+ // Host-specific paths / linker
445+ 'LD_PRELOAD' ,
446+ 'LD_LIBRARY_PATH' ,
447+ 'DYLD_INSERT_LIBRARIES' ,
448+ 'DYLD_LIBRARY_PATH' ,
449+ // Session / PAM
450+ 'LOGNAME' ,
451+ 'MAIL' ,
452+ 'XDG_DATA_DIRS' ,
453+ 'XDG_CONFIG_DIRS' ,
454+ // Active Claude Code session markers (prevent nested session confusion)
455+ 'CLAUDECODE' ,
456+ 'CLAUDE_CODE_SESSION' ,
457+ 'CLAUDE_CODE_ENTRYPOINT' ,
458+ ] ) ;
459+
460+ /** Returns true for env var names that should be blocked from Docker forwarding. */
461+ function isBlockedDockerEnvKey ( key : string ) : boolean {
462+ if ( DOCKER_ENV_BLOCK_LIST . has ( key ) ) return true ;
463+ // Block all remaining XDG_* vars not explicitly listed above
464+ if ( key . startsWith ( 'XDG_' ) ) return true ;
465+ // Block all ELECTRON_* vars not explicitly listed above
466+ if ( key . startsWith ( 'ELECTRON_' ) ) return true ;
467+ return false ;
468+ }
402469
403470function buildDockerEnvFlags ( env : Record < string , string > ) : string [ ] {
404471 const flags : string [ ] = [ ] ;
405- for ( const key of DOCKER_ENV_FORWARD ) {
406- if ( env [ key ] ) {
407- flags . push ( '-e' , `${ key } =${ env [ key ] } ` ) ;
472+ for ( const [ key , value ] of Object . entries ( env ) ) {
473+ if ( ! isBlockedDockerEnvKey ( key ) && value !== undefined ) {
474+ flags . push ( '-e' , `${ key } =${ value } ` ) ;
408475 }
409476 }
410477 return flags ;
@@ -415,27 +482,51 @@ function buildDockerCredentialMounts(): string[] {
415482 const home = process . env . HOME ;
416483 if ( ! home ) return mounts ;
417484
418- // Mount SSH directory read-only for git push/pull
419- const sshDir = `${ home } /.ssh` ;
420- try {
421- fs . accessSync ( sshDir , fs . constants . R_OK ) ;
422- mounts . push ( '-v' , `${ sshDir } :${ sshDir } :ro` ) ;
423- } catch {
424- // No .ssh dir — skip
425- }
485+ /** Mount a path read-only if it is readable; silently skip if absent. */
486+ const mountIfExists = ( hostPath : string ) : void => {
487+ try {
488+ fs . accessSync ( hostPath , fs . constants . R_OK ) ;
489+ mounts . push ( '-v' , `${ hostPath } :${ hostPath } :ro` ) ;
490+ } catch {
491+ // Path absent or unreadable — skip
492+ }
493+ } ;
426494
427- // Mount git config read-only
428- const gitconfig = `${ home } /.gitconfig` ;
429- try {
430- fs . accessSync ( gitconfig , fs . constants . R_OK ) ;
431- mounts . push ( '-v' , `${ gitconfig } :${ gitconfig } :ro` ) ;
432- } catch {
433- // No .gitconfig — skip
495+ // SSH keys for git push/pull
496+ mountIfExists ( `${ home } /.ssh` ) ;
497+
498+ // Git identity / config
499+ mountIfExists ( `${ home } /.gitconfig` ) ;
500+
501+ // GitHub CLI auth tokens (~/.config/gh/)
502+ mountIfExists ( `${ home } /.config/gh` ) ;
503+
504+ // npm auth token
505+ mountIfExists ( `${ home } /.npmrc` ) ;
506+
507+ // General HTTP/git HTTPS credentials (used by git credential helper)
508+ mountIfExists ( `${ home } /.netrc` ) ;
509+
510+ // Google Application Credentials file (for Vertex AI / gcloud)
511+ const googleCredsFile = process . env . GOOGLE_APPLICATION_CREDENTIALS ;
512+ if ( googleCredsFile ) {
513+ mountIfExists ( googleCredsFile ) ;
434514 }
435515
436516 return mounts ;
437517}
438518
519+ /**
520+ * Asynchronously stop a Docker container by name. Fire-and-forget — errors are
521+ * silently swallowed because the container may have already exited by the time
522+ * this is called.
523+ */
524+ function stopDockerContainer ( name : string ) : void {
525+ execFile ( 'docker' , [ 'stop' , name ] , { timeout : 10_000 } , ( ) => {
526+ // Intentionally ignore errors: container may not exist or may have already stopped.
527+ } ) ;
528+ }
529+
439530/** Check if Docker is available on the system. */
440531export async function isDockerAvailable ( ) : Promise < boolean > {
441532 try {
0 commit comments