88 * Caches the sandbox handle to avoid redundant API calls per operation.
99 */
1010
11+ import { randomUUID } from "node:crypto" ;
1112import { Daytona } from "@daytonaio/sdk" ;
12- import type { ExecResult , Sandbox } from "../../../../src/sandbox/types.js" ;
13+ import type {
14+ ExecResult ,
15+ ExecWithArgsOptions ,
16+ Sandbox ,
17+ } from "../../../../src/sandbox/types.js" ;
1318
1419export interface DaytonaSandboxConfig {
1520 apiKey : string ;
@@ -22,9 +27,236 @@ type SandboxHandle = Awaited<
2227 ReturnType < InstanceType < typeof Daytona > [ "create" ] >
2328> ;
2429
30+ type DaytonaSessionCommand = {
31+ cmdId ?: string ;
32+ exitCode ?: number ;
33+ } ;
34+
35+ type DaytonaSessionLogs = {
36+ output ?: string ;
37+ stdout ?: string ;
38+ stderr ?: string ;
39+ } ;
40+
41+ type DaytonaProcessApi = SandboxHandle [ "process" ] & {
42+ createSession ?: ( sessionId : string ) => Promise < void > ;
43+ deleteSession ?: ( sessionId : string ) => Promise < void > ;
44+ executeSessionCommand ?: (
45+ sessionId : string ,
46+ req : {
47+ command : string ;
48+ runAsync ?: boolean ;
49+ suppressInputEcho ?: boolean ;
50+ } ,
51+ timeout ?: number ,
52+ ) => Promise < DaytonaSessionCommand > ;
53+ getSessionCommand ?: (
54+ sessionId : string ,
55+ commandId : string ,
56+ ) => Promise < DaytonaSessionCommand > ;
57+ getSessionCommandLogs ?: (
58+ sessionId : string ,
59+ commandId : string ,
60+ ) => Promise < DaytonaSessionLogs > ;
61+ } ;
62+
63+ const SESSION_POLL_MS = 100 ;
64+ const SESSION_COMMAND_TIMEOUT_MS = 90_000 ;
65+ const EXEC_OUTPUT_MAX_BUFFER = 40 * 1024 ;
66+
67+ function cancelledExecResult ( ) : ExecResult {
68+ return { stdout : "" , stderr : "" , exitCode : 1 } ;
69+ }
70+
71+ function quoteShellArg ( value : string ) : string {
72+ if ( / ^ [ A - Z a - z 0 - 9 _ . / : = @ % + , - ] + $ / u. test ( value ) ) {
73+ return value ;
74+ }
75+ return `'${ value . replace ( / ' / g, `'\\''` ) } '` ;
76+ }
77+
78+ function truncateOutput ( value : string , maxBuffer ?: number ) : string {
79+ if ( maxBuffer === undefined ) {
80+ return value ;
81+ }
82+ const bytes = Buffer . from ( value ) ;
83+ if ( bytes . length <= maxBuffer ) {
84+ return value ;
85+ }
86+ return bytes . subarray ( 0 , maxBuffer ) . toString ( "utf-8" ) ;
87+ }
88+
89+ function sleep ( ms : number ) : Promise < void > {
90+ return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ;
91+ }
92+
2593export class DaytonaSandbox implements Sandbox {
2694 private constructor ( private handle : SandboxHandle ) { }
2795
96+ private hasSessionApi ( processApi : DaytonaProcessApi ) : boolean {
97+ return ! ! (
98+ processApi . createSession &&
99+ processApi . deleteSession &&
100+ processApi . executeSessionCommand &&
101+ processApi . getSessionCommand &&
102+ processApi . getSessionCommandLogs
103+ ) ;
104+ }
105+
106+ private buildShellCommand (
107+ command : string ,
108+ cwd ?: string ,
109+ env ?: Record < string , string > ,
110+ ) : string {
111+ let fullCommand = command ;
112+ if ( env && Object . keys ( env ) . length > 0 ) {
113+ const envPrefix = Object . entries ( env )
114+ . map ( ( [ k , v ] ) => {
115+ if ( ! / ^ [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ ] * $ / . test ( k ) ) {
116+ throw new Error ( `Invalid environment variable name: ${ k } ` ) ;
117+ }
118+ const escaped = v . replace ( / ' / g, "'\\''" ) ;
119+ return `${ k } ='${ escaped } '` ;
120+ } )
121+ . join ( " " ) ;
122+ fullCommand = `${ envPrefix } ${ fullCommand } ` ;
123+ }
124+ if ( cwd ) {
125+ const escapedCwd = cwd . replace ( / ' / g, "'\\''" ) ;
126+ fullCommand = `cd '${ escapedCwd } ' && ${ fullCommand } ` ;
127+ }
128+ return fullCommand ;
129+ }
130+
131+ private async execWithSession (
132+ command : string ,
133+ options : ExecWithArgsOptions = { } ,
134+ ) : Promise < ExecResult > {
135+ const processApi = this . handle . process as DaytonaProcessApi ;
136+ if ( ! this . hasSessionApi ( processApi ) ) {
137+ if ( options . signal ?. aborted ) {
138+ return cancelledExecResult ( ) ;
139+ }
140+ if ( options . signal ) {
141+ throw new Error (
142+ "Daytona abortable execution requires session API support" ,
143+ ) ;
144+ }
145+ const result = await processApi . executeCommand ( command ) ;
146+ return {
147+ stdout : truncateOutput ( result . result , options . maxBuffer ) ,
148+ stderr : "" ,
149+ exitCode : result . exitCode ,
150+ } ;
151+ }
152+
153+ const sessionId = `maestro-exec-${ randomUUID ( ) } ` ;
154+ let sessionDeleted = false ;
155+ let sessionDeletePromise : Promise < void > | undefined ;
156+ const deleteSession = async ( ) : Promise < void > => {
157+ if ( sessionDeleted ) {
158+ return ;
159+ }
160+ if ( sessionDeletePromise ) {
161+ await sessionDeletePromise ;
162+ if ( sessionDeleted ) {
163+ return ;
164+ }
165+ }
166+ sessionDeletePromise = ( async ( ) => {
167+ try {
168+ await processApi . deleteSession ! ( sessionId ) ;
169+ sessionDeleted = true ;
170+ } catch {
171+ // The session may not exist yet during setup cancellation.
172+ } finally {
173+ sessionDeletePromise = undefined ;
174+ }
175+ } ) ( ) ;
176+ await sessionDeletePromise ;
177+ } ;
178+ // Tracks whether the async session command was started but never
179+ // observed to complete. We use this to warn loudly if the caller
180+ // aborts mid-execution: Daytona's `deleteSession` is documented to
181+ // terminate the associated process (see
182+ // `deleteSessionDeprecated`: "Delete a PTY session and terminate the
183+ // associated process"), but the SDK exposes no direct
184+ // command-cancellation endpoint, so the in-flight remote process
185+ // outliving the session would be invisible to us without this log.
186+ let inflightCmdId : string | null = null ;
187+ const abortSession = ( ) : void => {
188+ void deleteSession ( ) ;
189+ } ;
190+ options . signal ?. addEventListener ( "abort" , abortSession , { once : true } ) ;
191+
192+ try {
193+ if ( options . signal ?. aborted ) {
194+ return cancelledExecResult ( ) ;
195+ }
196+ await processApi . createSession ( sessionId ) ;
197+ if ( options . signal ?. aborted ) {
198+ return cancelledExecResult ( ) ;
199+ }
200+
201+ const response = await processApi . executeSessionCommand ( sessionId , {
202+ command,
203+ runAsync : true ,
204+ suppressInputEcho : true ,
205+ } ) ;
206+ if ( ! response . cmdId ) {
207+ throw new Error ( "Daytona session command did not return a command id" ) ;
208+ }
209+ inflightCmdId = response . cmdId ;
210+
211+ const startedAt = Date . now ( ) ;
212+ while ( ! options . signal ?. aborted ) {
213+ if ( Date . now ( ) - startedAt >= SESSION_COMMAND_TIMEOUT_MS ) {
214+ throw new Error ( "Daytona session command timed out" ) ;
215+ }
216+ const commandState = await processApi . getSessionCommand (
217+ sessionId ,
218+ response . cmdId ,
219+ ) ;
220+ if ( options . signal ?. aborted ) {
221+ return cancelledExecResult ( ) ;
222+ }
223+ if ( typeof commandState . exitCode === "number" ) {
224+ inflightCmdId = null ;
225+ const logs = await processApi . getSessionCommandLogs (
226+ sessionId ,
227+ response . cmdId ,
228+ ) ;
229+ if ( options . signal ?. aborted ) {
230+ return cancelledExecResult ( ) ;
231+ }
232+ return {
233+ stdout : truncateOutput (
234+ logs . stdout ?? logs . output ?? "" ,
235+ options . maxBuffer ,
236+ ) ,
237+ stderr : truncateOutput ( logs . stderr ?? "" , options . maxBuffer ) ,
238+ exitCode : commandState . exitCode ,
239+ } ;
240+ }
241+ await sleep ( SESSION_POLL_MS ) ;
242+ }
243+
244+ return cancelledExecResult ( ) ;
245+ } finally {
246+ options . signal ?. removeEventListener ( "abort" , abortSession ) ;
247+ await deleteSession ( ) ;
248+ if ( options . signal ?. aborted && inflightCmdId ) {
249+ // Surface the residual-process risk so a stuck/long-lived
250+ // remote command after an aborted session is at least
251+ // observable. The Daytona session API does not currently
252+ // expose a way for us to verify termination ourselves.
253+ console . warn (
254+ `[daytona] Session ${ sessionId } aborted with command ${ inflightCmdId } still in flight; relying on Daytona's documented deleteSession-terminates-process contract.` ,
255+ ) ;
256+ }
257+ }
258+ }
259+
28260 /**
29261 * Create a new Daytona sandbox. This is async because it provisions
30262 * a remote sandbox environment.
@@ -49,28 +281,21 @@ export class DaytonaSandbox implements Sandbox {
49281 command : string ,
50282 cwd ?: string ,
51283 env ?: Record < string , string > ,
284+ signal ?: AbortSignal ,
52285 ) : Promise < ExecResult > {
53286 try {
54- // Build command with env vars and cwd if provided
55- let fullCommand = command ;
56- if ( env && Object . keys ( env ) . length > 0 ) {
57- const envPrefix = Object . entries ( env )
58- . map ( ( [ k , v ] ) => {
59- if ( ! / ^ [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ ] * $ / . test ( k ) ) {
60- throw new Error ( `Invalid environment variable name: ${ k } ` ) ;
61- }
62- // Use single quotes to prevent shell interpretation
63- const escaped = v . replace ( / ' / g, "'\\''" ) ;
64- return `${ k } ='${ escaped } '` ;
65- } )
66- . join ( " " ) ;
67- fullCommand = `${ envPrefix } ${ fullCommand } ` ;
287+ const fullCommand = this . buildShellCommand ( command , cwd , env ) ;
288+ const processApi = this . handle . process as DaytonaProcessApi ;
289+ if ( signal ?. aborted ) {
290+ return cancelledExecResult ( ) ;
68291 }
69- if ( cwd ) {
70- const escapedCwd = cwd . replace ( / ' / g, "'\\''" ) ;
71- fullCommand = `cd '${ escapedCwd } ' && ${ fullCommand } ` ;
292+ if ( signal && this . hasSessionApi ( processApi ) ) {
293+ return await this . execWithSession ( fullCommand , {
294+ signal,
295+ maxBuffer : EXEC_OUTPUT_MAX_BUFFER ,
296+ } ) ;
72297 }
73- const result = await this . handle . process . executeCommand ( fullCommand ) ;
298+ const result = await processApi . executeCommand ( fullCommand ) ;
74299 return {
75300 stdout : result . result ,
76301 stderr : "" ,
@@ -85,6 +310,35 @@ export class DaytonaSandbox implements Sandbox {
85310 }
86311 }
87312
313+ async execWithArgs (
314+ command : string ,
315+ args : string [ ] = [ ] ,
316+ options : ExecWithArgsOptions = { } ,
317+ ) : Promise < ExecResult > {
318+ try {
319+ const fullCommand = this . buildShellCommand (
320+ [ command , ...args ] . map ( quoteShellArg ) . join ( " " ) ,
321+ options . cwd ,
322+ options . env ,
323+ ) ;
324+ if ( options . signal ) {
325+ return await this . execWithSession ( fullCommand , options ) ;
326+ }
327+ const result = await this . handle . process . executeCommand ( fullCommand ) ;
328+ return {
329+ stdout : truncateOutput ( result . result , options . maxBuffer ) ,
330+ stderr : "" ,
331+ exitCode : result . exitCode ,
332+ } ;
333+ } catch ( err ) {
334+ return {
335+ stdout : "" ,
336+ stderr : err instanceof Error ? err . message : String ( err ) ,
337+ exitCode : 1 ,
338+ } ;
339+ }
340+ }
341+
88342 async readFile ( path : string ) : Promise < string > {
89343 const content = await this . handle . fs . downloadFile ( path ) ;
90344 return typeof content === "string" ? content : content . toString ( "utf-8" ) ;
0 commit comments