@@ -131,6 +131,7 @@ function validateResourceName(value: string, label: string): void {
131131export class DockerSandboxProvider implements SandboxProvider {
132132 private readonly image : string ;
133133 private readonly containers = new Map < string , string > ( ) ; // sessionId -> containerId
134+ private readonly sessionConfigs = new Map < string , SandboxConfig > ( ) ; // sessionId -> config
134135
135136 constructor ( image : string = 'python:3.11-slim' ) {
136137 this . image = image ;
@@ -189,6 +190,7 @@ export class DockerSandboxProvider implements SandboxProvider {
189190 . trim ( ) ;
190191
191192 this . containers . set ( sessionId , containerId ) ;
193+ this . sessionConfigs . set ( sessionId , cfg ) ;
192194
193195 return {
194196 agentId,
@@ -213,17 +215,31 @@ export class DockerSandboxProvider implements SandboxProvider {
213215 throw new Error ( `No active session '${ sessionId } ' for agent '${ agentId } '` ) ;
214216 }
215217
218+ const cfg = this . sessionConfigs . get ( sessionId ) ?? defaultSandboxConfig ( ) ;
219+ // Validate timeoutSeconds: must be positive. Zero/negative would mean "no limit"
220+ // in coreutils timeout, but our outer guard would still enforce a limit.
221+ // Clamp to 1 second minimum to avoid confusion.
222+ const timeoutSeconds = Number . isFinite ( cfg . timeoutSeconds ) && cfg . timeoutSeconds >= 1
223+ ? Math . floor ( cfg . timeoutSeconds )
224+ : 1 ;
225+
216226 const executionId = randomUUID ( ) ;
217227 const startTime = Date . now ( ) ;
218228
219229 return new Promise < ExecutionHandle > ( ( resolve ) => {
220230 const encoded = Buffer . from ( code ) . toString ( 'base64' ) ;
231+ // Use 'timeout' command with SIGKILL and kill-after to enforce execution time limit.
232+ // The default signal (SIGTERM) can be caught/ignored by sandboxed code,
233+ // allowing it to bypass the timeout. SIGKILL cannot be caught.
234+ // --kill-after=5s sends SIGKILL 5 seconds after the initial signal if the process
235+ // hasn't exited, providing a hard backstop.
221236 const execArgs = [
222- 'exec' , containerId , 'python3' , '-c' ,
237+ 'exec' , containerId , 'timeout' , '--signal=SIGKILL' , '--kill-after=5s' , String ( timeoutSeconds ) ,
238+ 'python3' , '-c' ,
223239 `import base64; exec(base64.b64decode('${ encoded } ').decode())` ,
224240 ] ;
225241
226- execFile ( 'docker' , execArgs , { timeout : 60_000 } , ( error , stdout , stderr ) => {
242+ execFile ( 'docker' , execArgs , { timeout : ( timeoutSeconds + 5 ) * 1000 } , ( error , stdout , stderr ) => {
227243 const durationSeconds = ( Date . now ( ) - startTime ) / 1000.0 ;
228244 // Node's ExecException.code can be: a numeric exit code (child exited
229245 // non-zero), `null` (child killed by a signal — `error.signal` is set
@@ -239,14 +255,19 @@ export class DockerSandboxProvider implements SandboxProvider {
239255 : 0 ;
240256 const killed = error !== null && 'killed' in error && ( error as { killed : boolean } ) . killed ;
241257
258+ // timeout command exits with 124 when the command times out (SIGTERM sent).
259+ // With --signal=SIGKILL, the child gets SIGKILL and exits with 137 (128 + 9).
260+ // Treat both 124 and 137 as timeout.
261+ const timedOut = exitCode === 124 || exitCode === 137 ;
262+
242263 const result : SandboxResult = {
243264 success : exitCode === 0 ,
244265 exitCode,
245266 stdout : stdout ?? '' ,
246267 stderr : stderr ?? '' ,
247268 durationSeconds,
248- killed,
249- killReason : killed ? 'timeout' : '' ,
269+ killed : timedOut || killed ,
270+ killReason : timedOut ? 'timeout' : ( killed ? 'signal' : '' ) ,
250271 } ;
251272
252273 resolve ( {
@@ -275,6 +296,7 @@ export class DockerSandboxProvider implements SandboxProvider {
275296 } ) ;
276297 } finally {
277298 this . containers . delete ( sessionId ) ;
299+ this . sessionConfigs . delete ( sessionId ) ;
278300 }
279301 }
280302}
0 commit comments