77 * - Uses docker exec for command execution
88 * - Hardcoded paths: srcBaseDir=/src, bgOutputDir=/tmp/mux-bashes
99 * - Managed lifecycle: container created/destroyed with workspace
10+ *
11+ * Extends RemoteRuntime for shared exec/file operations.
1012 */
1113
1214import { spawn , exec } from "child_process" ;
13- import { Readable , Writable } from "stream" ;
1415import * as path from "path" ;
1516import type {
16- Runtime ,
1717 ExecOptions ,
18- ExecStream ,
19- FileStat ,
2018 WorkspaceCreationParams ,
2119 WorkspaceCreationResult ,
2220 WorkspaceInitParams ,
@@ -26,13 +24,10 @@ import type {
2624 InitLogger ,
2725} from "./Runtime" ;
2826import { RuntimeError } from "./Runtime" ;
29- import { EXIT_CODE_ABORTED , EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes" ;
30- import { log } from "@/node/services/log" ;
27+ import { RemoteRuntime , type SpawnResult } from "./RemoteRuntime" ;
3128import { checkInitHookExists , getMuxEnv , runInitHookOnRuntime } from "./initHook" ;
32- import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env" ;
3329import { getProjectName } from "@/node/utils/runtime/helpers" ;
3430import { getErrorMessage } from "@/common/utils/errors" ;
35- import { DisposableProcess } from "@/node/utils/disposableExec" ;
3631import { streamToString , shescape } from "./streamUtils" ;
3732
3833/** Hardcoded source directory inside container */
@@ -125,13 +120,15 @@ export function getContainerName(projectPath: string, workspaceName: string): st
125120
126121/**
127122 * Docker runtime implementation that executes commands inside Docker containers.
123+ * Extends RemoteRuntime for shared exec/file operations.
128124 */
129- export class DockerRuntime implements Runtime {
125+ export class DockerRuntime extends RemoteRuntime {
130126 private readonly config : DockerRuntimeConfig ;
131127 /** Container name - set during construction (for existing) or createWorkspace (for new) */
132128 private containerName ?: string ;
133129
134130 constructor ( config : DockerRuntimeConfig ) {
131+ super ( ) ;
135132 this . config = config ;
136133 // If container name is provided (existing workspace), store it
137134 if ( config . containerName ) {
@@ -146,19 +143,24 @@ export class DockerRuntime implements Runtime {
146143 return this . config . image ;
147144 }
148145
149- /**
150- * Execute command inside Docker container with streaming I/O
151- */
152- exec ( command : string , options : ExecOptions ) : Promise < ExecStream > {
153- const startTime = performance . now ( ) ;
146+ // ===== RemoteRuntime abstract method implementations =====
154147
155- // Short-circuit if already aborted
156- if ( options . abortSignal ?. aborted ) {
157- throw new RuntimeError ( "Operation aborted before execution" , "exec" ) ;
158- }
148+ protected readonly commandPrefix = "Docker" ;
149+
150+ protected getBasePath ( ) : string {
151+ return CONTAINER_SRC_DIR ;
152+ }
153+
154+ protected quoteForRemote ( filePath : string ) : string {
155+ return shescape . quote ( filePath ) ;
156+ }
159157
160- // Verify container name is available (set in constructor for existing workspaces,
161- // or set in createWorkspace for new workspaces)
158+ protected cdCommand ( cwd : string ) : string {
159+ return `cd ${ shescape . quote ( cwd ) } ` ;
160+ }
161+
162+ protected spawnRemoteProcess ( fullCommand : string , options : ExecOptions ) : SpawnResult {
163+ // Verify container name is available
162164 if ( ! this . containerName ) {
163165 throw new RuntimeError (
164166 "Docker runtime not initialized with container name. " +
@@ -167,234 +169,28 @@ export class DockerRuntime implements Runtime {
167169 "exec"
168170 ) ;
169171 }
170- const containerName = this . containerName ;
171-
172- // Build command parts
173- const parts : string [ ] = [ ] ;
174-
175- // Add cd command if cwd is specified
176- parts . push ( `cd ${ shescape . quote ( options . cwd ) } ` ) ;
177-
178- // Add environment variable exports (user env first, then non-interactive overrides)
179- const envVars = { ...options . env , ...NON_INTERACTIVE_ENV_VARS } ;
180- for ( const [ key , value ] of Object . entries ( envVars ) ) {
181- parts . push ( `export ${ key } =${ shescape . quote ( value ) } ` ) ;
182- }
183-
184- // Add the actual command
185- parts . push ( command ) ;
186-
187- // Join all parts with && to ensure each step succeeds before continuing
188- let fullCommand = parts . join ( " && " ) ;
189-
190- // Wrap in bash for consistent shell behavior
191- fullCommand = `bash -c ${ shescape . quote ( fullCommand ) } ` ;
192-
193- // Optionally wrap with timeout
194- if ( options . timeout !== undefined ) {
195- const remoteTimeout = Math . ceil ( options . timeout ) + 1 ;
196- fullCommand = `timeout -s KILL ${ remoteTimeout } ${ fullCommand } ` ;
197- }
198172
199173 // Build docker exec args
200174 const dockerArgs : string [ ] = [ "exec" , "-i" ] ;
201175
202176 // Add environment variables directly to docker exec
177+ const envVars = { ...options . env } ;
203178 for ( const [ key , value ] of Object . entries ( envVars ) ) {
204179 dockerArgs . push ( "-e" , `${ key } =${ value } ` ) ;
205180 }
206181
207- dockerArgs . push ( containerName , "bash" , "-c" , fullCommand ) ;
208-
209- log . debug ( `Docker command: docker ${ dockerArgs . join ( " " ) } ` ) ;
182+ dockerArgs . push ( this . containerName , "bash" , "-c" , fullCommand ) ;
210183
211184 // Spawn docker exec command
212- const dockerProcess = spawn ( "docker" , dockerArgs , {
185+ const process = spawn ( "docker" , dockerArgs , {
213186 stdio : [ "pipe" , "pipe" , "pipe" ] ,
214187 windowsHide : true ,
215188 } ) ;
216189
217- // Wrap in DisposableProcess for automatic cleanup
218- const disposable = new DisposableProcess ( dockerProcess ) ;
219-
220- // Convert Node.js streams to Web Streams
221- const stdout = Readable . toWeb ( dockerProcess . stdout ) as unknown as ReadableStream < Uint8Array > ;
222- const stderr = Readable . toWeb ( dockerProcess . stderr ) as unknown as ReadableStream < Uint8Array > ;
223- const stdin = Writable . toWeb ( dockerProcess . stdin ) as unknown as WritableStream < Uint8Array > ;
224-
225- // Track if we killed the process due to timeout or abort
226- let timedOut = false ;
227- let aborted = false ;
228-
229- // Create promises for exit code and duration
230- const exitCode = new Promise < number > ( ( resolve , reject ) => {
231- dockerProcess . on ( "close" , ( code , signal ) => {
232- if ( aborted || options . abortSignal ?. aborted ) {
233- resolve ( EXIT_CODE_ABORTED ) ;
234- return ;
235- }
236- if ( timedOut ) {
237- resolve ( EXIT_CODE_TIMEOUT ) ;
238- return ;
239- }
240- resolve ( code ?? ( signal ? - 1 : 0 ) ) ;
241- } ) ;
242-
243- dockerProcess . on ( "error" , ( err ) => {
244- reject ( new RuntimeError ( `Failed to execute Docker command: ${ err . message } ` , "exec" , err ) ) ;
245- } ) ;
246- } ) ;
247-
248- const duration = exitCode . then ( ( ) => performance . now ( ) - startTime ) ;
249-
250- // Handle abort signal
251- if ( options . abortSignal ) {
252- options . abortSignal . addEventListener ( "abort" , ( ) => {
253- aborted = true ;
254- disposable [ Symbol . dispose ] ( ) ;
255- } ) ;
256- }
257-
258- // Handle timeout
259- if ( options . timeout !== undefined ) {
260- const timeoutHandle = setTimeout ( ( ) => {
261- timedOut = true ;
262- disposable [ Symbol . dispose ] ( ) ;
263- } , options . timeout * 1000 ) ;
264-
265- void exitCode . finally ( ( ) => clearTimeout ( timeoutHandle ) ) ;
266- }
267-
268- return Promise . resolve ( { stdout, stderr, stdin, exitCode, duration } ) ;
190+ return { process } ;
269191 }
270192
271- /**
272- * Read file contents from container as a stream
273- */
274- readFile ( filePath : string , abortSignal ?: AbortSignal ) : ReadableStream < Uint8Array > {
275- return new ReadableStream < Uint8Array > ( {
276- start : async ( controller : ReadableStreamDefaultController < Uint8Array > ) => {
277- try {
278- const stream = await this . exec ( `cat ${ shescape . quote ( filePath ) } ` , {
279- cwd : CONTAINER_SRC_DIR ,
280- timeout : 300 ,
281- abortSignal,
282- } ) ;
283-
284- const reader = stream . stdout . getReader ( ) ;
285- const exitCodePromise = stream . exitCode ;
286-
287- while ( true ) {
288- const { done, value } = await reader . read ( ) ;
289- if ( done ) break ;
290- controller . enqueue ( value ) ;
291- }
292-
293- const code = await exitCodePromise ;
294- if ( code !== 0 ) {
295- const stderr = await streamToString ( stream . stderr ) ;
296- throw new RuntimeError ( `Failed to read file ${ filePath } : ${ stderr } ` , "file_io" ) ;
297- }
298-
299- controller . close ( ) ;
300- } catch ( err ) {
301- if ( err instanceof RuntimeError ) {
302- controller . error ( err ) ;
303- } else {
304- controller . error (
305- new RuntimeError (
306- `Failed to read file ${ filePath } : ${ err instanceof Error ? err . message : String ( err ) } ` ,
307- "file_io" ,
308- err instanceof Error ? err : undefined
309- )
310- ) ;
311- }
312- }
313- } ,
314- } ) ;
315- }
316-
317- /**
318- * Write file contents to container atomically from a stream
319- */
320- writeFile ( filePath : string , abortSignal ?: AbortSignal ) : WritableStream < Uint8Array > {
321- const tempPath = `${ filePath } .tmp.${ Date . now ( ) } ` ;
322- const writeCommand = `mkdir -p $(dirname ${ shescape . quote ( filePath ) } ) && cat > ${ shescape . quote ( tempPath ) } && mv ${ shescape . quote ( tempPath ) } ${ shescape . quote ( filePath ) } ` ;
323-
324- let execPromise : Promise < ExecStream > | null = null ;
325-
326- const getExecStream = ( ) => {
327- execPromise ??= this . exec ( writeCommand , {
328- cwd : CONTAINER_SRC_DIR ,
329- timeout : 300 ,
330- abortSignal,
331- } ) ;
332- return execPromise ;
333- } ;
334-
335- return new WritableStream < Uint8Array > ( {
336- write : async ( chunk : Uint8Array ) => {
337- const stream = await getExecStream ( ) ;
338- const writer = stream . stdin . getWriter ( ) ;
339- try {
340- await writer . write ( chunk ) ;
341- } finally {
342- writer . releaseLock ( ) ;
343- }
344- } ,
345- close : async ( ) => {
346- const stream = await getExecStream ( ) ;
347- await stream . stdin . close ( ) ;
348- const exitCode = await stream . exitCode ;
349-
350- if ( exitCode !== 0 ) {
351- const stderr = await streamToString ( stream . stderr ) ;
352- throw new RuntimeError ( `Failed to write file ${ filePath } : ${ stderr } ` , "file_io" ) ;
353- }
354- } ,
355- abort : async ( reason ?: unknown ) => {
356- const stream = await getExecStream ( ) ;
357- await stream . stdin . abort ( ) ;
358- throw new RuntimeError ( `Failed to write file ${ filePath } : ${ String ( reason ) } ` , "file_io" ) ;
359- } ,
360- } ) ;
361- }
362-
363- /**
364- * Get file statistics from container
365- */
366- async stat ( filePath : string , abortSignal ?: AbortSignal ) : Promise < FileStat > {
367- const stream = await this . exec ( `stat -c '%s %Y %F' ${ shescape . quote ( filePath ) } ` , {
368- cwd : CONTAINER_SRC_DIR ,
369- timeout : 10 ,
370- abortSignal,
371- } ) ;
372-
373- const [ stdout , stderr , exitCode ] = await Promise . all ( [
374- streamToString ( stream . stdout ) ,
375- streamToString ( stream . stderr ) ,
376- stream . exitCode ,
377- ] ) ;
378-
379- if ( exitCode !== 0 ) {
380- throw new RuntimeError ( `Failed to stat ${ filePath } : ${ stderr } ` , "file_io" ) ;
381- }
382-
383- const parts = stdout . trim ( ) . split ( " " ) ;
384- if ( parts . length < 3 ) {
385- throw new RuntimeError ( `Failed to parse stat output for ${ filePath } : ${ stdout } ` , "file_io" ) ;
386- }
387-
388- const size = parseInt ( parts [ 0 ] , 10 ) ;
389- const mtime = parseInt ( parts [ 1 ] , 10 ) ;
390- const fileType = parts . slice ( 2 ) . join ( " " ) ;
391-
392- return {
393- size,
394- modifiedTime : new Date ( mtime * 1000 ) ,
395- isDirectory : fileType === "directory" ,
396- } ;
397- }
193+ // ===== Runtime interface implementations =====
398194
399195 resolvePath ( filePath : string ) : Promise < string > {
400196 // Inside container, paths are already absolute
@@ -404,25 +200,6 @@ export class DockerRuntime implements Runtime {
404200 ) ;
405201 }
406202
407- normalizePath ( targetPath : string , basePath : string ) : string {
408- const target = targetPath . trim ( ) ;
409- let base = basePath . trim ( ) ;
410-
411- if ( base . length > 1 && base . endsWith ( "/" ) ) {
412- base = base . slice ( 0 , - 1 ) ;
413- }
414-
415- if ( target === "." ) {
416- return base ;
417- }
418-
419- if ( target . startsWith ( "/" ) ) {
420- return target ;
421- }
422-
423- return base . endsWith ( "/" ) ? base + target : base + "/" + target ;
424- }
425-
426203 getWorkspacePath ( _projectPath : string , _workspaceName : string ) : string {
427204 // For Docker, workspace path is always /src inside the container
428205 return CONTAINER_SRC_DIR ;
@@ -808,8 +585,4 @@ export class DockerRuntime implements Runtime {
808585 error : "Forking Docker workspaces is not yet implemented. Create a new workspace instead." ,
809586 } ) ;
810587 }
811-
812- tempDir ( ) : Promise < string > {
813- return Promise . resolve ( "/tmp" ) ;
814- }
815588}
0 commit comments