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,216 @@ 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+ const abortSession = ( ) : void => {
179+ void deleteSession ( ) ;
180+ } ;
181+ options . signal ?. addEventListener ( "abort" , abortSession , { once : true } ) ;
182+
183+ try {
184+ if ( options . signal ?. aborted ) {
185+ return cancelledExecResult ( ) ;
186+ }
187+ await processApi . createSession ( sessionId ) ;
188+ if ( options . signal ?. aborted ) {
189+ return cancelledExecResult ( ) ;
190+ }
191+
192+ const response = await processApi . executeSessionCommand ( sessionId , {
193+ command,
194+ runAsync : true ,
195+ suppressInputEcho : true ,
196+ } ) ;
197+ if ( ! response . cmdId ) {
198+ throw new Error ( "Daytona session command did not return a command id" ) ;
199+ }
200+
201+ const startedAt = Date . now ( ) ;
202+ while ( ! options . signal ?. aborted ) {
203+ if ( Date . now ( ) - startedAt >= SESSION_COMMAND_TIMEOUT_MS ) {
204+ throw new Error ( "Daytona session command timed out" ) ;
205+ }
206+ const commandState = await processApi . getSessionCommand (
207+ sessionId ,
208+ response . cmdId ,
209+ ) ;
210+ if ( options . signal ?. aborted ) {
211+ return cancelledExecResult ( ) ;
212+ }
213+ if ( typeof commandState . exitCode === "number" ) {
214+ const logs = await processApi . getSessionCommandLogs (
215+ sessionId ,
216+ response . cmdId ,
217+ ) ;
218+ if ( options . signal ?. aborted ) {
219+ return cancelledExecResult ( ) ;
220+ }
221+ return {
222+ stdout : truncateOutput (
223+ logs . stdout ?? logs . output ?? "" ,
224+ options . maxBuffer ,
225+ ) ,
226+ stderr : truncateOutput ( logs . stderr ?? "" , options . maxBuffer ) ,
227+ exitCode : commandState . exitCode ,
228+ } ;
229+ }
230+ await sleep ( SESSION_POLL_MS ) ;
231+ }
232+
233+ return cancelledExecResult ( ) ;
234+ } finally {
235+ options . signal ?. removeEventListener ( "abort" , abortSession ) ;
236+ await deleteSession ( ) ;
237+ }
238+ }
239+
28240 /**
29241 * Create a new Daytona sandbox. This is async because it provisions
30242 * a remote sandbox environment.
@@ -49,28 +261,21 @@ export class DaytonaSandbox implements Sandbox {
49261 command : string ,
50262 cwd ?: string ,
51263 env ?: Record < string , string > ,
264+ signal ?: AbortSignal ,
52265 ) : Promise < ExecResult > {
53266 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 } ` ;
267+ const fullCommand = this . buildShellCommand ( command , cwd , env ) ;
268+ const processApi = this . handle . process as DaytonaProcessApi ;
269+ if ( signal ?. aborted ) {
270+ return cancelledExecResult ( ) ;
68271 }
69- if ( cwd ) {
70- const escapedCwd = cwd . replace ( / ' / g, "'\\''" ) ;
71- fullCommand = `cd '${ escapedCwd } ' && ${ fullCommand } ` ;
272+ if ( signal && this . hasSessionApi ( processApi ) ) {
273+ return await this . execWithSession ( fullCommand , {
274+ signal,
275+ maxBuffer : EXEC_OUTPUT_MAX_BUFFER ,
276+ } ) ;
72277 }
73- const result = await this . handle . process . executeCommand ( fullCommand ) ;
278+ const result = await processApi . executeCommand ( fullCommand ) ;
74279 return {
75280 stdout : result . result ,
76281 stderr : "" ,
@@ -85,6 +290,35 @@ export class DaytonaSandbox implements Sandbox {
85290 }
86291 }
87292
293+ async execWithArgs (
294+ command : string ,
295+ args : string [ ] = [ ] ,
296+ options : ExecWithArgsOptions = { } ,
297+ ) : Promise < ExecResult > {
298+ try {
299+ const fullCommand = this . buildShellCommand (
300+ [ command , ...args ] . map ( quoteShellArg ) . join ( " " ) ,
301+ options . cwd ,
302+ options . env ,
303+ ) ;
304+ if ( options . signal ) {
305+ return await this . execWithSession ( fullCommand , options ) ;
306+ }
307+ const result = await this . handle . process . executeCommand ( fullCommand ) ;
308+ return {
309+ stdout : truncateOutput ( result . result , options . maxBuffer ) ,
310+ stderr : "" ,
311+ exitCode : result . exitCode ,
312+ } ;
313+ } catch ( err ) {
314+ return {
315+ stdout : "" ,
316+ stderr : err instanceof Error ? err . message : String ( err ) ,
317+ exitCode : 1 ,
318+ } ;
319+ }
320+ }
321+
88322 async readFile ( path : string ) : Promise < string > {
89323 const content = await this . handle . fs . downloadFile ( path ) ;
90324 return typeof content === "string" ? content : content . toString ( "utf-8" ) ;
0 commit comments