feat: add SandboxManager for per-session container lifecycle

This commit is contained in:
William Valentin
2026-02-06 15:58:34 -08:00
parent 1314ac0163
commit 0d5601fb13
2 changed files with 146 additions and 0 deletions
+91
View File
@@ -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();
});
});
});
+55
View File
@@ -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<string, DockerSandbox>();
private config: SandboxConfig;
constructor(config: SandboxConfig) {
this.config = config;
}
/** Get or create a sandbox for a session. */
async getOrCreate(sessionId: string): Promise<DockerSandbox> {
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<void> {
const sandbox = this.sandboxes.get(sessionId);
if (!sandbox) return;
await sandbox.destroy();
this.sandboxes.delete(sessionId);
}
/** Destroy all sandboxes (daemon shutdown). */
async destroyAll(): Promise<void> {
const entries = Array.from(this.sandboxes.entries());
await Promise.allSettled(
entries.map(async ([_id, sandbox]) => {
await sandbox.destroy();
}),
);
this.sandboxes.clear();
}
}