From 0d5601fb13d442c95031fea0f1c1b4995df5cc73 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Fri, 6 Feb 2026 15:58:34 -0800 Subject: [PATCH] feat: add SandboxManager for per-session container lifecycle --- src/sandbox/manager.test.ts | 91 +++++++++++++++++++++++++++++++++++++ src/sandbox/manager.ts | 55 ++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 src/sandbox/manager.test.ts create mode 100644 src/sandbox/manager.ts diff --git a/src/sandbox/manager.test.ts b/src/sandbox/manager.test.ts new file mode 100644 index 0000000..be89356 --- /dev/null +++ b/src/sandbox/manager.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SandboxManager } from './manager.js'; +import { DockerSandbox } from './docker.js'; +import type { SandboxConfig } from '../config/schema.js'; + +// Mock DockerSandbox +vi.mock('./docker.js', () => ({ + DockerSandbox: vi.fn().mockImplementation(() => ({ + create: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + containerId: 'mock-container', + })), +})); + +describe('SandboxManager', () => { + const defaultConfig: SandboxConfig = { + enabled: true, + image: 'node:22-slim', + workspace_dir: '/workspace', + network: 'none', + memory_limit: '512m', + cpu_limit: '1.0', + timeout_seconds: 300, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getOrCreate()', () => { + it('creates a new sandbox for unknown session', async () => { + const manager = new SandboxManager(defaultConfig); + const sandbox = await manager.getOrCreate('session-1'); + + expect(DockerSandbox).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: 'session-1', + image: 'node:22-slim', + })); + expect(sandbox.create).toHaveBeenCalled(); + }); + + it('reuses existing sandbox for same session', async () => { + const manager = new SandboxManager(defaultConfig); + const first = await manager.getOrCreate('session-1'); + const second = await manager.getOrCreate('session-1'); + + expect(first).toBe(second); + expect(DockerSandbox).toHaveBeenCalledTimes(1); + }); + + it('creates separate sandboxes for different sessions', async () => { + const manager = new SandboxManager(defaultConfig); + await manager.getOrCreate('session-1'); + await manager.getOrCreate('session-2'); + + expect(DockerSandbox).toHaveBeenCalledTimes(2); + }); + }); + + describe('destroy()', () => { + it('destroys sandbox and removes from cache', async () => { + const manager = new SandboxManager(defaultConfig); + const sandbox = await manager.getOrCreate('session-1'); + + await manager.destroy('session-1'); + expect(sandbox.destroy).toHaveBeenCalled(); + + // Should create a new one now + await manager.getOrCreate('session-1'); + expect(DockerSandbox).toHaveBeenCalledTimes(2); + }); + + it('does nothing for unknown session', async () => { + const manager = new SandboxManager(defaultConfig); + await manager.destroy('nonexistent'); // should not throw + }); + }); + + describe('destroyAll()', () => { + it('destroys all sandboxes', async () => { + const manager = new SandboxManager(defaultConfig); + const s1 = await manager.getOrCreate('session-1'); + const s2 = await manager.getOrCreate('session-2'); + + await manager.destroyAll(); + expect(s1.destroy).toHaveBeenCalled(); + expect(s2.destroy).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts new file mode 100644 index 0000000..9af9b01 --- /dev/null +++ b/src/sandbox/manager.ts @@ -0,0 +1,55 @@ +import { DockerSandbox } from './docker.js'; +import type { SandboxConfig } from '../config/schema.js'; + +/** + * Manages per-session Docker sandboxes. + * Creates containers lazily on first access, destroys on session cleanup. + */ +export class SandboxManager { + private sandboxes = new Map(); + private config: SandboxConfig; + + constructor(config: SandboxConfig) { + this.config = config; + } + + /** Get or create a sandbox for a session. */ + async getOrCreate(sessionId: string): Promise { + let sandbox = this.sandboxes.get(sessionId); + if (sandbox) return sandbox; + + sandbox = new DockerSandbox({ + sessionId, + image: this.config.image, + workspaceDir: this.config.workspace_dir, + network: this.config.network, + memoryLimit: this.config.memory_limit, + cpuLimit: this.config.cpu_limit, + timeoutSeconds: this.config.timeout_seconds, + }); + + await sandbox.create(); + this.sandboxes.set(sessionId, sandbox); + return sandbox; + } + + /** Destroy a specific session's sandbox. */ + async destroy(sessionId: string): Promise { + const sandbox = this.sandboxes.get(sessionId); + if (!sandbox) return; + + await sandbox.destroy(); + this.sandboxes.delete(sessionId); + } + + /** Destroy all sandboxes (daemon shutdown). */ + async destroyAll(): Promise { + const entries = Array.from(this.sandboxes.entries()); + await Promise.allSettled( + entries.map(async ([_id, sandbox]) => { + await sandbox.destroy(); + }), + ); + this.sandboxes.clear(); + } +}