From 7cb5287ed3489adad9dcf554d16b8738f178f034 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Fri, 6 Feb 2026 15:53:50 -0800 Subject: [PATCH] feat: add DockerSandbox class for container lifecycle --- src/sandbox/docker.test.ts | 162 +++++++++++++++++++++++++++++++++++++ src/sandbox/docker.ts | 123 ++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 src/sandbox/docker.test.ts create mode 100644 src/sandbox/docker.ts diff --git a/src/sandbox/docker.test.ts b/src/sandbox/docker.test.ts new file mode 100644 index 0000000..95764ea --- /dev/null +++ b/src/sandbox/docker.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DockerSandbox, type DockerSandboxConfig } from './docker.js'; +import * as childProcess from 'child_process'; + +// Mock child_process.execFile +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})); + +const mockedExecFile = vi.mocked(childProcess.execFile); + +function mockExecFileSuccess(stdout = '', stderr = '') { + mockedExecFile.mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, callback: unknown) => { + (callback as (err: null, stdout: string, stderr: string) => void)(null, stdout, stderr); + return {} as ReturnType; + }, + ); +} + +function mockExecFileError(message: string) { + mockedExecFile.mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, callback: unknown) => { + (callback as (err: Error) => void)(new Error(message)); + return {} as ReturnType; + }, + ); +} + +describe('DockerSandbox', () => { + const defaultConfig: DockerSandboxConfig = { + sessionId: 'test-session', + image: 'node:22-slim', + workspaceDir: '/workspace', + network: 'none', + memoryLimit: '512m', + cpuLimit: '1.0', + timeoutSeconds: 300, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('create()', () => { + it('creates a docker container with correct args', async () => { + mockExecFileSuccess('container-abc123'); + const sandbox = new DockerSandbox(defaultConfig); + await sandbox.create(); + + expect(mockedExecFile).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining([ + 'create', + '--name', expect.stringContaining('flynn-test-session'), + '--memory', '512m', + '--cpus', '1.0', + '--network', 'none', + '-v', expect.stringContaining(':/workspace'), + 'node:22-slim', + 'sleep', 'infinity', + ]), + expect.any(Object), + expect.any(Function), + ); + expect(sandbox.containerId).toBe('container-abc123'); + }); + + it('starts the container after creating', async () => { + mockExecFileSuccess('container-abc123'); + const sandbox = new DockerSandbox(defaultConfig); + await sandbox.create(); + + // Second call should be docker start + expect(mockedExecFile).toHaveBeenCalledTimes(2); + expect(mockedExecFile).toHaveBeenNthCalledWith( + 2, 'docker', ['start', 'container-abc123'], + expect.any(Object), expect.any(Function), + ); + }); + + it('throws if docker create fails', async () => { + mockExecFileError('docker not found'); + const sandbox = new DockerSandbox(defaultConfig); + await expect(sandbox.create()).rejects.toThrow('docker not found'); + }); + }); + + describe('exec()', () => { + it('runs command inside container', async () => { + const sandbox = new DockerSandbox(defaultConfig); + // Manually set container ID to skip create + (sandbox as unknown as { _containerId: string })._containerId = 'container-abc'; + + mockExecFileSuccess('hello world\n'); + const result = await sandbox.exec('echo hello world'); + + expect(mockedExecFile).toHaveBeenCalledWith( + 'docker', + ['exec', 'container-abc', 'bash', '-c', 'echo hello world'], + expect.objectContaining({ timeout: expect.any(Number) }), + expect.any(Function), + ); + expect(result).toEqual({ stdout: 'hello world\n', stderr: '' }); + }); + + it('passes cwd as workdir option', async () => { + const sandbox = new DockerSandbox(defaultConfig); + (sandbox as unknown as { _containerId: string })._containerId = 'container-abc'; + + mockExecFileSuccess(''); + await sandbox.exec('ls', { cwd: '/workspace/project' }); + + expect(mockedExecFile).toHaveBeenCalledWith( + 'docker', + ['exec', '-w', '/workspace/project', 'container-abc', 'bash', '-c', 'ls'], + expect.any(Object), + expect.any(Function), + ); + }); + + it('throws if no container created', async () => { + const sandbox = new DockerSandbox(defaultConfig); + await expect(sandbox.exec('echo hi')).rejects.toThrow('not created'); + }); + }); + + describe('destroy()', () => { + it('force-removes the container', async () => { + const sandbox = new DockerSandbox(defaultConfig); + (sandbox as unknown as { _containerId: string })._containerId = 'container-abc'; + + mockExecFileSuccess(); + await sandbox.destroy(); + + expect(mockedExecFile).toHaveBeenCalledWith( + 'docker', ['rm', '-f', 'container-abc'], + expect.any(Object), expect.any(Function), + ); + }); + + it('does nothing if no container', async () => { + const sandbox = new DockerSandbox(defaultConfig); + await sandbox.destroy(); // should not throw + expect(mockedExecFile).not.toHaveBeenCalled(); + }); + }); + + describe('isAvailable()', () => { + it('returns true when docker is installed', async () => { + mockExecFileSuccess('Docker version 27.0.0'); + const result = await DockerSandbox.isAvailable(); + expect(result).toBe(true); + }); + + it('returns false when docker is not installed', async () => { + mockExecFileError('command not found'); + const result = await DockerSandbox.isAvailable(); + expect(result).toBe(false); + }); + }); +}); diff --git a/src/sandbox/docker.ts b/src/sandbox/docker.ts new file mode 100644 index 0000000..03367b8 --- /dev/null +++ b/src/sandbox/docker.ts @@ -0,0 +1,123 @@ +import { execFile } from 'child_process'; + +export interface DockerSandboxConfig { + sessionId: string; + image: string; + workspaceDir: string; + network: 'none' | 'bridge' | 'host'; + memoryLimit: string; + cpuLimit: string; + timeoutSeconds: number; +} + +export interface ExecOptions { + cwd?: string; + timeout?: number; +} + +export interface ExecResult { + stdout: string; + stderr: string; +} + +/** + * Manages a single Docker container for sandboxed tool execution. + * Uses the Docker CLI directly (no SDK dependency). + */ +export class DockerSandbox { + private config: DockerSandboxConfig; + private _containerId: string | null = null; + private _hostWorkdir: string; + + constructor(config: DockerSandboxConfig) { + this.config = config; + const sanitizedId = config.sessionId.replace(/[^a-zA-Z0-9_-]/g, '_'); + this._hostWorkdir = `/tmp/flynn-sandbox-${sanitizedId}`; + } + + get containerId(): string | null { + return this._containerId; + } + + get containerName(): string { + const sanitizedId = this.config.sessionId.replace(/[^a-zA-Z0-9_-]/g, '_'); + return `flynn-${sanitizedId}`; + } + + /** Create and start the sandbox container. */ + async create(): Promise { + const args = [ + 'create', + '--name', this.containerName, + '--memory', this.config.memoryLimit, + '--cpus', this.config.cpuLimit, + '--network', this.config.network, + '-v', `${this._hostWorkdir}:${this.config.workspaceDir}`, + this.config.image, + 'sleep', 'infinity', + ]; + + const createResult = await this.dockerCmd(args); + this._containerId = createResult.stdout.trim(); + + await this.dockerCmd(['start', this._containerId]); + } + + /** Execute a command inside the container. */ + async exec(command: string, opts?: ExecOptions): Promise { + if (!this._containerId) { + throw new Error('Sandbox container not created. Call create() first.'); + } + + const args = ['exec']; + if (opts?.cwd) { + args.push('-w', opts.cwd); + } + args.push(this._containerId, 'bash', '-c', command); + + const timeout = opts?.timeout ?? this.config.timeoutSeconds * 1000; + return this.dockerCmd(args, timeout); + } + + /** Force-remove the container. */ + async destroy(): Promise { + if (!this._containerId) return; + + try { + await this.dockerCmd(['rm', '-f', this._containerId]); + } catch { + // Ignore errors during cleanup + } + this._containerId = null; + } + + /** Check if Docker is available on this host. */ + static async isAvailable(): Promise { + try { + await new Promise((resolve, reject) => { + execFile('docker', ['version', '--format', '{{.Server.Version}}'], { + timeout: 5000, + }, (error, stdout) => { + if (error) reject(error); + else resolve(stdout); + }); + }); + return true; + } catch { + return false; + } + } + + /** Run a docker CLI command. */ + private dockerCmd(args: string[], timeout = 30_000): Promise { + return new Promise((resolve, reject) => { + execFile('docker', args, { timeout, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + resolve({ stdout, stderr }); + }); + }); + } +}