@molecule/api-code-sandbox-docker 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,943 @@
1
+ /**
2
+ * Docker implementation of SandboxProvider.
3
+ *
4
+ * Uses the Docker Engine API via HTTP to manage containers.
5
+ * Each sandbox runs as an isolated Docker container with resource limits.
6
+ *
7
+ * @module
8
+ */
9
+ import http from 'http';
10
+ import { getLogger } from '@molecule/api-bond';
11
+ import { t } from '@molecule/api-i18n';
12
+ const logger = getLogger();
13
+ const DEFAULT_IMAGE = 'node:22-slim';
14
+ /**
15
+ * Shell-safe single-quote escaping for file paths. Prevents command injection
16
+ * via `$()`, backticks, or other shell metacharacters inside double quotes.
17
+ * @param s - The string to escape for shell use.
18
+ * @returns A single-quoted shell-safe string.
19
+ */
20
+ function shellQuote(s) {
21
+ return "'" + s.replace(/'/g, "'\\''") + "'";
22
+ }
23
+ const LABEL_PREFIX = 'molecule-sandbox';
24
+ /**
25
+ * [C1-1] Default sandbox network. The shared docker `bridge` has inter-container communication
26
+ * ENABLED, so two tenants' sandboxes can reach each other's Vite/API dev-server ports by IP —
27
+ * a cross-tenant info-disclosure gap for any multi-tenant app that runs untrusted code in these
28
+ * sandboxes. We default to a dedicated user-defined network created with ICC DISABLED
29
+ * (`com.docker.network.bridge.enable_icc=false`) so each tenant is isolated at L2 out of the
30
+ * box. Operators can override with `config.network` or `SANDBOX_DOCKER_NETWORK`; setting it to
31
+ * `bridge` is refused in production (see {@link DockerSandboxProvider.ensureSandboxNetwork}).
32
+ * Host-layer default-deny egress filtering remains a separate, operator-provisioned control.
33
+ */
34
+ const DEFAULT_SANDBOX_NETWORK = 'molecule-sandbox';
35
+ /** Default Docker daemon TCP port for a plain (unencrypted) `tcp://` endpoint. */
36
+ const DEFAULT_DOCKER_TCP_PORT = 2375;
37
+ /**
38
+ * Parse a `DOCKER_HOST`-style value into an endpoint, honoring the docker-client
39
+ * conventions: `unix:///path/to/docker.sock` selects a socket; `tcp://host:port`
40
+ * (or bare `http://host:port`) selects a plain-TCP endpoint (port defaults to
41
+ * {@link DEFAULT_DOCKER_TCP_PORT}). Unknown schemes (e.g. Windows `npipe://`)
42
+ * return `null` so the caller falls back to the unix socket.
43
+ *
44
+ * TLS-protected daemons (`tcp://…:2376`, `DOCKER_TLS_VERIFY=1`) are NOT handled —
45
+ * the transport is plain HTTP; front such a daemon with a local socket proxy.
46
+ * @param value - the raw `DOCKER_HOST` value (or any endpoint string).
47
+ * @returns the parsed endpoint, or `null` if it does not name a supported endpoint.
48
+ */
49
+ function parseDockerHost(value) {
50
+ const raw = value?.trim();
51
+ if (!raw)
52
+ return null;
53
+ if (raw.startsWith('unix://')) {
54
+ return { socketPath: raw.slice('unix://'.length) || '/var/run/docker.sock' };
55
+ }
56
+ const tcp = /^(?:tcp|http):\/\/([^/:]+)(?::(\d+))?/.exec(raw);
57
+ if (tcp)
58
+ return { host: tcp[1], port: tcp[2] ? Number(tcp[2]) : DEFAULT_DOCKER_TCP_PORT };
59
+ return null;
60
+ }
61
+ /**
62
+ * Resolve which Docker daemon endpoint the provider connects to. Explicit config
63
+ * beats env; precedence:
64
+ * 1. `config.host`/`config.port` → plain-TCP endpoint (host defaults to
65
+ * `127.0.0.1`, port to {@link DEFAULT_DOCKER_TCP_PORT});
66
+ * 2. `config.socketPath` → that unix socket;
67
+ * 3. `DOCKER_HOST` env (`tcp://` or `unix://`, the docker-client convention);
68
+ * 4. `DOCKER_SOCKET_PATH` env, else `/var/run/docker.sock` (the default transport).
69
+ * @param config - the provider configuration.
70
+ * @returns the resolved endpoint to spread into every Docker API request.
71
+ */
72
+ function resolveDockerEndpoint(config) {
73
+ if (config.host !== undefined || config.port !== undefined) {
74
+ return { host: config.host ?? '127.0.0.1', port: config.port ?? DEFAULT_DOCKER_TCP_PORT };
75
+ }
76
+ if (config.socketPath !== undefined) {
77
+ return { socketPath: config.socketPath };
78
+ }
79
+ const fromEnv = parseDockerHost(process.env.DOCKER_HOST);
80
+ if (fromEnv)
81
+ return fromEnv;
82
+ return { socketPath: process.env.DOCKER_SOCKET_PATH ?? '/var/run/docker.sock' };
83
+ }
84
+ /**
85
+ * Parses Docker's multiplexed binary stream from a raw socket.
86
+ * Each frame: 8-byte header [streamType(1), 0, 0, 0, size(4 BE uint32)] + payload.
87
+ * Buffers partial frames across data events.
88
+ */
89
+ /** Maximum single frame size in Docker multiplexed stream (50 MB). */
90
+ const MAX_MUX_FRAME_SIZE = 50 * 1024 * 1024;
91
+ /** Parses Docker multiplexed binary stream frames into stdout/stderr callbacks. */
92
+ class DockerMuxParser {
93
+ buffer = Buffer.alloc(0);
94
+ stdoutCb = null;
95
+ stderrCb = null;
96
+ /**
97
+ * Registers a callback for stdout data events.
98
+ * @param cb - The callback invoked with each stdout data chunk.
99
+ */
100
+ onStdout(cb) {
101
+ this.stdoutCb = cb;
102
+ }
103
+ /**
104
+ * Registers a callback for stderr data events.
105
+ * @param cb - The callback invoked with each stderr data chunk.
106
+ */
107
+ onStderr(cb) {
108
+ this.stderrCb = cb;
109
+ }
110
+ /** Clear internal buffer (e.g. on connection close). */
111
+ clear() {
112
+ this.buffer = Buffer.alloc(0);
113
+ }
114
+ /**
115
+ * Feeds a buffer chunk into the parser, extracting complete frames.
116
+ * @param chunk - The raw data chunk from the Docker stream.
117
+ */
118
+ feed(chunk) {
119
+ this.buffer = Buffer.concat([this.buffer, chunk]);
120
+ while (this.buffer.length >= 8) {
121
+ const streamType = this.buffer[0];
122
+ const frameSize = this.buffer.readUInt32BE(4);
123
+ if (frameSize > MAX_MUX_FRAME_SIZE) {
124
+ this.buffer = Buffer.alloc(0);
125
+ throw new Error(`Docker stream frame size ${frameSize} exceeds ${MAX_MUX_FRAME_SIZE} byte limit`);
126
+ }
127
+ if (this.buffer.length < 8 + frameSize)
128
+ break;
129
+ const payload = this.buffer.subarray(8, 8 + frameSize).toString('utf-8');
130
+ this.buffer = this.buffer.subarray(8 + frameSize);
131
+ if (streamType === 1)
132
+ this.stdoutCb?.(payload);
133
+ else if (streamType === 2)
134
+ this.stderrCb?.(payload);
135
+ }
136
+ }
137
+ }
138
+ /**
139
+ * Retry a Docker API operation that failed with a TRANSIENT fault — a request
140
+ * timeout or a connection-level reset, i.e. the daemon was momentarily overwhelmed,
141
+ * not the request malformed. This is the fix for the observed "first cold boot of a
142
+ * concurrent batch dies on `Docker API timeout: POST /containers/create`" failure:
143
+ * a single 30 s create timeout under daemon load currently kills the whole boot.
144
+ *
145
+ * HTTP error responses (4xx/5xx) are deliberately NOT retried — those are real
146
+ * answers (no-such-image, name conflict), not transient network faults. Bounded
147
+ * attempts with linear backoff.
148
+ *
149
+ * `onRetry` is the idempotency guard: a create that TIMED OUT client-side may have
150
+ * still succeeded server-side, so before re-issuing it the caller can adopt the
151
+ * already-created resource (looked up by a unique label) instead of leaking a
152
+ * duplicate container. If `onRetry` returns non-null, that value is used and the
153
+ * operation is not re-issued.
154
+ *
155
+ * @param op - the operation to attempt.
156
+ * @param opts - tuning + the optional adopt-on-retry guard.
157
+ * @param opts.label - short operation name for log lines.
158
+ * @param opts.attempts - max attempts (default 3).
159
+ * @param opts.onRetry - adopt-an-existing-resource guard, run before each retry.
160
+ * @param opts.delayMs - backoff for attempt N (default `400 * N` ms).
161
+ * @param opts.log - optional logger for retry warnings.
162
+ * @returns the operation's result, or an adopted result from `onRetry`.
163
+ */
164
+ export async function withTransientRetry(op, opts) {
165
+ const attempts = opts.attempts ?? 3;
166
+ const backoff = opts.delayMs ?? ((n) => 400 * n);
167
+ let lastError;
168
+ for (let attempt = 1; attempt <= attempts; attempt++) {
169
+ try {
170
+ return await op();
171
+ }
172
+ catch (error) {
173
+ lastError = error;
174
+ const message = String(error?.message ?? error);
175
+ const transient = /Docker API timeout|ECONNRESET|EPIPE|socket hang ?up|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT/i.test(message);
176
+ if (!transient || attempt === attempts)
177
+ break;
178
+ if (opts.onRetry) {
179
+ const adopted = await opts.onRetry().catch(() => null);
180
+ if (adopted != null)
181
+ return adopted;
182
+ }
183
+ opts.log?.warn(`Docker API transient failure on ${opts.label} (attempt ${attempt}/${attempts}) — retrying`, { error: message });
184
+ await new Promise((resolve) => setTimeout(resolve, backoff(attempt)));
185
+ }
186
+ }
187
+ throw lastError;
188
+ }
189
+ /**
190
+ * Docker-based implementation of `SandboxProvider`. Each sandbox runs as an isolated
191
+ * Docker container with configurable CPU/memory limits. Communicates with the Docker
192
+ * Engine API over a Unix socket by default, or a plain-TCP endpoint when one is
193
+ * selected via `config.host`/`config.port` or a `DOCKER_HOST` env var.
194
+ */
195
+ class DockerSandboxProvider {
196
+ name = 'docker';
197
+ /** Resolved daemon endpoint (unix socket or TCP host:port); see {@link resolveDockerEndpoint}. */
198
+ endpoint;
199
+ baseImage;
200
+ labelPrefix;
201
+ previewUrlTemplate;
202
+ defaultCpu;
203
+ defaultMemoryMB;
204
+ /**
205
+ * Explicit network from `config.network`, if any. Takes precedence over the
206
+ * `SANDBOX_DOCKER_NETWORK` env var — which is still read at create time when
207
+ * this is unset, so operators can set it after construction. See {@link resolveNetwork}.
208
+ */
209
+ configNetwork;
210
+ /** [C1-1] Memoize the one-time ICC-off network ensure so create() pays it only once. */
211
+ networkEnsured = false;
212
+ constructor(config = {}) {
213
+ this.endpoint = resolveDockerEndpoint(config);
214
+ this.baseImage = config.baseImage ?? DEFAULT_IMAGE;
215
+ this.labelPrefix = config.labelPrefix ?? LABEL_PREFIX;
216
+ this.previewUrlTemplate = config.previewUrlTemplate ?? 'http://localhost:{port}';
217
+ this.defaultCpu = config.defaultCpu ?? 1;
218
+ this.defaultMemoryMB = config.defaultMemoryMB ?? 1024;
219
+ this.configNetwork = config.network;
220
+ }
221
+ /**
222
+ * Resolve the Docker network to attach containers to. Precedence:
223
+ * `config.network` (captured at construction) → `SANDBOX_DOCKER_NETWORK` env
224
+ * (read here, at create time) → {@link DEFAULT_SANDBOX_NETWORK}.
225
+ * @returns the network name for the container's `NetworkMode`.
226
+ */
227
+ resolveNetwork() {
228
+ return this.configNetwork ?? process.env.SANDBOX_DOCKER_NETWORK ?? DEFAULT_SANDBOX_NETWORK;
229
+ }
230
+ /**
231
+ * [C1-1] Idempotently ensure the sandbox network exists with inter-container communication
232
+ * DISABLED, so tenants can't reach each other's containers over a shared bridge. Resolves the
233
+ * network via {@link resolveNetwork} (`config.network` → `SANDBOX_DOCKER_NETWORK` →
234
+ * {@link DEFAULT_SANDBOX_NETWORK}). The shared
235
+ * `bridge` has ICC enabled and gives NO isolation, so it is refused in production and only
236
+ * warned about elsewhere. Memoized; tolerant of "already exists" (409); non-fatal on other
237
+ * errors (container create surfaces a clear error if the network is genuinely missing).
238
+ */
239
+ async ensureSandboxNetwork() {
240
+ if (this.networkEnsured)
241
+ return;
242
+ const network = this.resolveNetwork();
243
+ if (network === 'bridge') {
244
+ if (process.env.NODE_ENV === 'production') {
245
+ throw new Error('Sandbox network "bridge" is forbidden in production (via SANDBOX_DOCKER_NETWORK or ' +
246
+ 'config.network): the shared docker bridge has inter-container communication enabled, ' +
247
+ "so one tenant can reach another tenant's sandbox dev-server ports by IP (C1-1). Use " +
248
+ `the isolated default ("${DEFAULT_SANDBOX_NETWORK}") or a dedicated ICC-off network.`);
249
+ }
250
+ logger.warn('Sandbox network is the shared docker "bridge" — NO cross-tenant isolation (C1-1). ' +
251
+ 'Use the isolated default network (unset SANDBOX_DOCKER_NETWORK / config.network).');
252
+ this.networkEnsured = true;
253
+ return;
254
+ }
255
+ this.networkEnsured = true;
256
+ try {
257
+ await this.dockerApi('/networks/create', 'POST', {
258
+ Name: network,
259
+ Driver: 'bridge',
260
+ CheckDuplicate: true,
261
+ Options: { 'com.docker.network.bridge.enable_icc': 'false' },
262
+ });
263
+ logger.info('Sandbox network ready', { network });
264
+ }
265
+ catch (error) {
266
+ // dockerApi rejects on 409 (already exists) — that is success for our purposes.
267
+ const msg = error instanceof Error ? error.message : String(error);
268
+ if (msg.includes('409') || /already exists/i.test(msg)) {
269
+ logger.debug('Sandbox network already exists', { network });
270
+ }
271
+ else {
272
+ // Non-fatal: container create surfaces a clear error if the network is truly missing.
273
+ logger.warn('Failed to ensure sandbox network', { network, error });
274
+ }
275
+ }
276
+ }
277
+ /**
278
+ * Creates a new Docker container as a sandbox with the specified resource limits,
279
+ * environment variables, and exposed ports (4000 for API, 5173 for Vite preview).
280
+ * @param config - The sandbox configuration including project ID, image, env vars, and resource limits.
281
+ * @returns A `Sandbox` object wrapping the created container.
282
+ */
283
+ async create(config) {
284
+ // [C1-1] Ensure the ICC-off isolated network exists before placing the container on it.
285
+ await this.ensureSandboxNetwork();
286
+ const cpu = config.resources?.cpu ?? this.defaultCpu;
287
+ const memoryMB = config.resources?.memoryMB ?? this.defaultMemoryMB;
288
+ const image = config.image ?? this.baseImage;
289
+ const env = Object.entries(config.env ?? {}).map(([k, v]) => `${k}=${v}`);
290
+ const labels = {
291
+ [`${this.labelPrefix}.projectId`]: config.projectId,
292
+ [`${this.labelPrefix}.managed`]: 'true',
293
+ };
294
+ // Merge any caller-supplied labels FIRST so the provider's own managed
295
+ // labels above (set last via reassignment below) always win — a caller can
296
+ // never clobber `${prefix}.managed`/`projectId`. Additive + backward
297
+ // compatible: existing callers pass no `labels` and are unaffected.
298
+ if (config.labels && typeof config.labels === 'object' && !Array.isArray(config.labels)) {
299
+ for (const [k, v] of Object.entries(config.labels)) {
300
+ if (k === `${this.labelPrefix}.projectId` || k === `${this.labelPrefix}.managed`)
301
+ continue;
302
+ labels[k] = String(v);
303
+ }
304
+ }
305
+ const memoryBytes = memoryMB * 1024 * 1024;
306
+ const hostConfig = {
307
+ NanoCPUs: cpu * 1e9,
308
+ Memory: memoryBytes,
309
+ // Bound total memory+swap to 2× RAM (up to 1× RAM of swap) for transient spikes.
310
+ // `-1` (unlimited swap) let a tenant exhaust HOST swap — a cross-tenant DoS — since
311
+ // the Memory cap does not bound swap. [C1-2]
312
+ MemorySwap: memoryBytes * 2,
313
+ Init: true, // Use tini init to reap zombie processes
314
+ PidsLimit: 512,
315
+ SecurityOpt: ['no-new-privileges'],
316
+ CapDrop: ['ALL'],
317
+ // Minimal caps for the dev toolchain (file ownership + tini privilege drop).
318
+ // NET_ADMIN is deliberately NOT granted: it let a tenant flush in-container
319
+ // egress (SMTP) rules and enabled promiscuous-mode/ARP tricks on the shared
320
+ // bridge. Egress filtering must be enforced host-side, not in a tenant-rooted
321
+ // container.
322
+ CapAdd: ['CHOWN', 'SETGID', 'SETUID'],
323
+ // Bind ports to localhost only — prevents external access
324
+ PortBindings: {
325
+ '4000/tcp': [{ HostIp: '127.0.0.1', HostPort: '' }],
326
+ '5173/tcp': [{ HostIp: '127.0.0.1', HostPort: '' }],
327
+ },
328
+ Tmpfs: { '/tmp': 'rw,noexec,nosuid,size=256m' },
329
+ ReadonlyPaths: [
330
+ '/proc/asound',
331
+ '/proc/bus',
332
+ '/proc/fs',
333
+ '/proc/irq',
334
+ '/proc/sys',
335
+ '/proc/sysrq-trigger',
336
+ ],
337
+ MaskedPaths: [
338
+ '/proc/kcore',
339
+ '/proc/keys',
340
+ '/proc/latency_stats',
341
+ '/proc/sched_debug',
342
+ '/proc/timer_list',
343
+ '/proc/timer_stats',
344
+ '/proc/self/environ',
345
+ '/proc/1/environ',
346
+ ],
347
+ };
348
+ // [C1-1] Network: default to the dedicated ICC-OFF network (ensured above) so tenants are
349
+ // L2-isolated out of the box — NOT the shared `bridge`, whose inter-container communication
350
+ // lets one tenant reach another's dev-server ports by IP. Operators can override with
351
+ // `config.network` or SANDBOX_DOCKER_NETWORK (a `bridge` value is refused in production); pair
352
+ // it with host.docker.internal (below) + SANDBOX_DB_HOST=host.docker.internal so the sandbox
353
+ // still reaches its own DB. Resolved identically to ensureSandboxNetwork() above.
354
+ hostConfig.NetworkMode = this.resolveNetwork();
355
+ // Map host.docker.internal → host gateway on all networks (not added
356
+ // automatically on user-defined bridges). Harmless on the default bridge.
357
+ hostConfig.ExtraHosts = ['host.docker.internal:host-gateway'];
358
+ // Mount a named Docker volume at /workspace for persistent storage.
359
+ // Docker copies image contents (molecule/, node_modules/) into empty volumes.
360
+ if (config.volumeName) {
361
+ labels[`${this.labelPrefix}.volumeName`] = config.volumeName;
362
+ hostConfig.Binds = [`${config.volumeName}:/workspace`];
363
+ }
364
+ const body = {
365
+ Image: image,
366
+ Env: env,
367
+ Labels: labels,
368
+ HostConfig: hostConfig,
369
+ ExposedPorts: {
370
+ '4000/tcp': {},
371
+ '5173/tcp': {},
372
+ },
373
+ };
374
+ // Retry a transient create timeout (daemon momentarily overwhelmed under
375
+ // concurrent boots) instead of failing the whole sandbox boot. The onRetry
376
+ // guard adopts a container a timed-out create may have already made — keyed on
377
+ // the unique volumeName label — so a retry never leaks a duplicate.
378
+ const createRes = (await withTransientRetry(
379
+ // 120 s: creating a container with a fresh named volume copies the image's
380
+ // /workspace (molecule dist + node_modules, multi-GB) into the volume —
381
+ // measured at 33 s+ against the current base image, past the default 30 s.
382
+ () => this.dockerApi('/containers/create', 'POST', body, 120_000), {
383
+ label: 'containers/create',
384
+ log: logger,
385
+ onRetry: config.volumeName
386
+ ? async () => await this.findContainerIdByLabel(`${this.labelPrefix}.volumeName`, config.volumeName).then((id) => (id ? { Id: id } : null))
387
+ : undefined,
388
+ }));
389
+ return this.buildSandbox(createRes.Id, config.projectId);
390
+ }
391
+ /**
392
+ * Finds the id of a managed container by an exact `label=value` match. Used by
393
+ * the create-retry adoption guard to detect a container a timed-out create may
394
+ * have already produced (keyed on the unique volumeName label), so a retry adopts
395
+ * it rather than leaking a duplicate.
396
+ * @param key - the full label key (e.g. `molecule-sandbox.volumeName`).
397
+ * @param value - the exact label value to match.
398
+ * @returns the matching container id, or `null` if none exists.
399
+ */
400
+ async findContainerIdByLabel(key, value) {
401
+ const filters = JSON.stringify({ label: [`${key}=${value}`] });
402
+ const containers = (await this.dockerApi(`/containers/json?all=true&filters=${encodeURIComponent(filters)}`));
403
+ return Array.isArray(containers) && containers[0]?.Id ? containers[0].Id : null;
404
+ }
405
+ /**
406
+ * Retrieves an existing sandbox by its Docker container ID.
407
+ * @param id - The Docker container ID.
408
+ * @returns A `Sandbox` wrapping the container, or `null` if the container does not exist.
409
+ */
410
+ async get(id) {
411
+ try {
412
+ const info = (await this.dockerApi(`/containers/${id}/json`));
413
+ const projectId = info.Config.Labels[`${this.labelPrefix}.projectId`] ?? '';
414
+ // Derive the real status from Docker's container state rather than
415
+ // hardcoding 'stopped'. Otherwise GET /sandbox/status reports a live
416
+ // container as stopped, and the status handler syncs that bogus state
417
+ // back into the DB — corrupting sandboxStatus and stalling the build.
418
+ const status = info.State?.Running ? 'running' : 'stopped';
419
+ return this.buildSandbox(info.Id, projectId, status);
420
+ }
421
+ catch (error) {
422
+ logger.debug('Failed to get sandbox container', { id, error });
423
+ return null;
424
+ }
425
+ }
426
+ /**
427
+ * Lists all sandbox containers managed by this provider (filtered by the `managed=true` label).
428
+ * @param _userId - Reserved for future per-user filtering; currently returns all managed containers.
429
+ * @returns An array of `Sandbox` objects for each managed container.
430
+ */
431
+ async list(_userId) {
432
+ const filters = JSON.stringify({
433
+ label: [`${this.labelPrefix}.managed=true`],
434
+ });
435
+ const containers = (await this.dockerApi(`/containers/json?all=true&filters=${encodeURIComponent(filters)}`));
436
+ return containers.map((c) => this.buildSandbox(c.Id, c.Labels[`${this.labelPrefix}.projectId`] ?? '', c.State === 'running' ? 'running' : 'stopped'));
437
+ }
438
+ /**
439
+ * Force-removes a Docker container by ID and its associated volume (if any).
440
+ * Silently succeeds if already removed.
441
+ * @param id - The Docker container ID to destroy.
442
+ */
443
+ async destroy(id) {
444
+ // Read volume name from container labels before removing
445
+ let volumeName;
446
+ try {
447
+ const info = (await this.dockerApi(`/containers/${id}/json`));
448
+ volumeName = info.Config.Labels[`${this.labelPrefix}.volumeName`];
449
+ }
450
+ catch (_error) {
451
+ // Container may already be gone — reading labels is best-effort; destroy proceeds regardless
452
+ }
453
+ try {
454
+ await this.dockerApi(`/containers/${id}?force=true`, 'DELETE');
455
+ }
456
+ catch (error) {
457
+ logger.debug('Failed to destroy sandbox container (may already be removed)', { id, error });
458
+ }
459
+ // Clean up the associated volume
460
+ if (volumeName) {
461
+ try {
462
+ await this.dockerApi(`/volumes/${volumeName}`, 'DELETE');
463
+ logger.debug('Removed sandbox volume', { volumeName });
464
+ }
465
+ catch (error) {
466
+ logger.debug('Failed to remove sandbox volume (may already be removed)', {
467
+ volumeName,
468
+ error,
469
+ });
470
+ }
471
+ }
472
+ }
473
+ /**
474
+ * Builds a `Sandbox` object that wraps a Docker container with methods for
475
+ * start/stop/exec/file operations. All file operations are implemented via `docker exec`.
476
+ * @param containerId - The Docker container ID.
477
+ * @param _projectId - The project ID label (stored for metadata reference).
478
+ * @param initialStatus - The container's current status, derived from Docker
479
+ * state by the caller (`get`/`list`). Defaults to 'stopped' for callers that
480
+ * create a brand-new (not-yet-started) container.
481
+ * @returns A `Sandbox` with lifecycle and file system methods bound to the container.
482
+ */
483
+ buildSandbox(containerId, _projectId, initialStatus = 'stopped') {
484
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
485
+ const provider = this;
486
+ return {
487
+ id: containerId,
488
+ status: initialStatus,
489
+ previewUrl: provider.previewUrlTemplate.replace(/\{port\}/g, '5173'),
490
+ async start() {
491
+ // 120 s: on graphdriver storage the image→empty-volume copy happens at
492
+ // START (not create), so the first start pays the same multi-GB copy the
493
+ // create path does under the containerd snapshotter.
494
+ await provider.dockerApi(`/containers/${containerId}/start`, 'POST', undefined, 120_000);
495
+ this.status = 'running';
496
+ },
497
+ async stop() {
498
+ await provider.dockerApi(`/containers/${containerId}/stop`, 'POST');
499
+ this.status = 'stopped';
500
+ },
501
+ async sleep() {
502
+ await provider.dockerApi(`/containers/${containerId}/stop`, 'POST');
503
+ this.status = 'sleeping';
504
+ },
505
+ async wake() {
506
+ // Same 120 s rationale as start() — a first-ever start pays the volume copy.
507
+ await provider.dockerApi(`/containers/${containerId}/start`, 'POST', undefined, 120_000);
508
+ this.status = 'running';
509
+ },
510
+ async exec(command, opts) {
511
+ // Wrap the command in a timeout if specified to enforce time limits
512
+ const timeoutMs = opts?.timeout;
513
+ // Use single-quote shell escaping — JSON.stringify produces double quotes which
514
+ // still allow $(), backtick, and ! expansion inside sh -c.
515
+ const shellQuote = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
516
+ const wrappedCommand = timeoutMs
517
+ ? `timeout ${Math.ceil(timeoutMs / 1000)} sh -c ${shellQuote(command)}`
518
+ : command;
519
+ const attempt = async () => {
520
+ const execCreate = (await provider.dockerApi(`/containers/${containerId}/exec`, 'POST', {
521
+ Cmd: ['sh', '-c', wrappedCommand],
522
+ AttachStdout: true,
523
+ AttachStderr: true,
524
+ WorkingDir: opts?.cwd ?? '/workspace',
525
+ Env: opts?.env ? Object.entries(opts.env).map(([k, v]) => `${k}=${v}`) : undefined,
526
+ }));
527
+ const rawBuf = await provider.dockerApiRaw(`/exec/${execCreate.Id}/start`, 'POST', {
528
+ Detach: false,
529
+ Tty: false,
530
+ });
531
+ // Docker multiplexed stream: each frame has an 8-byte header
532
+ // [stream_type(1), 0, 0, 0, size(4 bytes big-endian)] + payload
533
+ const stdoutChunks = [];
534
+ const stderrChunks = [];
535
+ let offset = 0;
536
+ while (offset + 8 <= rawBuf.length) {
537
+ const streamType = rawBuf[offset];
538
+ const frameSize = rawBuf.readUInt32BE(offset + 4);
539
+ offset += 8;
540
+ const end = Math.min(offset + frameSize, rawBuf.length);
541
+ const payload = rawBuf.subarray(offset, end);
542
+ if (streamType === 2) {
543
+ stderrChunks.push(payload);
544
+ }
545
+ else {
546
+ stdoutChunks.push(payload);
547
+ }
548
+ offset = end;
549
+ }
550
+ // The attach stream ending does NOT mean the command finished: a
551
+ // daemon under I/O load can FIN the stream early, and Node resolves
552
+ // the partial body as a normal 'end'. Trusting it returned truncated
553
+ // stdout + a null ExitCode (Docker reports null while the process
554
+ // runs) that callers took as success — a scaffold `npm install` once
555
+ // "completed" in seconds while npm kept installing for 9 more
556
+ // minutes. Inspect is authoritative: if the process is still
557
+ // running, poll until it actually exits before reporting.
558
+ const inspectExec = async () => (await provider.dockerApi(`/exec/${execCreate.Id}/json`));
559
+ let inspectRes = await inspectExec();
560
+ if (inspectRes.Running) {
561
+ // The in-container `timeout` wrapper bounds the command itself when
562
+ // opts.timeout is set; +30s covers signal/reap latency. Without a
563
+ // caller timeout, cap the wait at the same 10 min as the stream.
564
+ const deadline = Date.now() + (timeoutMs ? timeoutMs + 30_000 : 600_000);
565
+ while (inspectRes.Running && Date.now() < deadline) {
566
+ await new Promise((resolve) => setTimeout(resolve, 2000));
567
+ inspectRes = await inspectExec();
568
+ }
569
+ }
570
+ return {
571
+ stdout: Buffer.concat(stdoutChunks).toString(),
572
+ stderr: Buffer.concat(stderrChunks).toString(),
573
+ // -1 = indeterminate: still running at deadline (or Docker gave no
574
+ // code). Distinct from any real shell exit status (0-255) so
575
+ // callers can tell "unfinished/unknown" from "failed".
576
+ exitCode: inspectRes.ExitCode ?? -1,
577
+ };
578
+ };
579
+ try {
580
+ return await attempt();
581
+ }
582
+ catch (error) {
583
+ // A stopped/hibernated container returns Docker 409 "is not running".
584
+ // This happens when the auto-hibernation loop sleeps a sandbox that is
585
+ // actually still in use (e.g. a long agentic turn, or a brief race at
586
+ // turn start). Surfacing it as a raw failure makes every file tool
587
+ // error out mid-build. Instead, transparently start the container and
588
+ // retry once so the exec succeeds. Any other error is rethrown.
589
+ const message = error instanceof Error ? error.message : String(error);
590
+ if (/is not running|\b409\b/.test(message)) {
591
+ try {
592
+ // Same 120 s rationale as start() — a first-ever start pays the volume copy.
593
+ await provider.dockerApi(`/containers/${containerId}/start`, 'POST', undefined, 120_000);
594
+ }
595
+ catch (_error) {
596
+ // 304 (already started) or a genuine start failure — let the retry
597
+ // below surface the real error if the container is truly gone.
598
+ }
599
+ return await attempt();
600
+ }
601
+ throw error;
602
+ }
603
+ },
604
+ async readFile(path) {
605
+ const result = await this.exec(`cat ${shellQuote(path)}`);
606
+ if (result.exitCode !== 0)
607
+ throw new Error(t('codeSandbox.docker.error.readFailed', { path, error: result.stderr }, { defaultValue: `Failed to read ${path}: ${result.stderr}` }));
608
+ return result.stdout;
609
+ },
610
+ async writeFile(path, content) {
611
+ const b64 = Buffer.from(content).toString('base64');
612
+ const qp = shellQuote(path);
613
+ const failIfError = (result) => {
614
+ if (result.exitCode !== 0)
615
+ throw new Error(t('codeSandbox.docker.error.writeFailed', { path, error: result.stderr }, { defaultValue: `Failed to write ${path}: ${result.stderr}` }));
616
+ };
617
+ // A single `echo '<b64>' | base64 -d` command is passed as ONE argument to
618
+ // `sh -c`, which Linux caps at MAX_ARG_STRLEN (128KB). Any file whose base64
619
+ // exceeds that — i.e. content over ~96KB (a large component, data file, or
620
+ // migration) — would E2BIG and the write would FAIL. Split the base64 into
621
+ // sub-limit chunks: the first (with mkdir) truncates, the rest append. Each
622
+ // chunk is a multiple of 4 base64 chars (= 3 decoded bytes) so it decodes
623
+ // cleanly on its own and the bytes concatenate exactly. Small files (base64
624
+ // ≤ one chunk, ~45KB raw) stay a single exec — no extra round-trips.
625
+ const CHUNK = 60_000;
626
+ failIfError(await this.exec(`mkdir -p "$(dirname ${qp})" && echo ${shellQuote(b64.slice(0, CHUNK))} | base64 -d > ${qp}`));
627
+ for (let i = CHUNK; i < b64.length; i += CHUNK) {
628
+ failIfError(await this.exec(`echo ${shellQuote(b64.slice(i, i + CHUNK))} | base64 -d >> ${qp}`));
629
+ }
630
+ },
631
+ async readDir(path) {
632
+ // Append trailing slash so ls follows symlinks (e.g. /workspace -> /sandbox/project)
633
+ const dirPath = path.endsWith('/') ? path : `${path}/`;
634
+ // NO pipeline here: `ls … | tail -n +2` reported TAIL's exit code (always 0), which
635
+ // silently defeated the missing-dir check below — verified live in the sandbox image
636
+ // (`sh -c "ls /nonexistent/ | tail -n +2"` exits 0; without the pipe, 2). The `total`
637
+ // header line tail used to drop is filtered in JS instead.
638
+ const result = await this.exec(`ls -la --time-style=+%s ${shellQuote(dirPath)}`);
639
+ // A nonexistent directory must THROW (like readFile), never return [] — an empty
640
+ // list reads as "the directory exists and is empty", and an AI executor building
641
+ // on that spent a whole turn theorizing about "virtual" files that were never there.
642
+ if (result.exitCode !== 0)
643
+ throw new Error(t('codeSandbox.docker.error.readDirFailed', { path, error: result.stderr }, { defaultValue: `Failed to list ${path}: ${result.stderr}` }));
644
+ return result.stdout
645
+ .trim()
646
+ .split('\n')
647
+ .filter(Boolean)
648
+ .filter((line) => !/^total\s/.test(line)) // ls's summary header (was `tail -n +2`)
649
+ .filter((line) => {
650
+ // Skip . and .. entries
651
+ const name = line.split(/\s+/).slice(6).join(' ');
652
+ return name !== '.' && name !== '..';
653
+ })
654
+ .map((line) => {
655
+ const parts = line.split(/\s+/);
656
+ const isDir = line.startsWith('d');
657
+ const isSymlink = line.startsWith('l');
658
+ const size = parseInt(parts[4] ?? '0', 10);
659
+ const rawName = parts.slice(6).join(' ');
660
+ // Symlinks show as "name -> target" in ls -la
661
+ const arrowIdx = rawName.indexOf(' -> ');
662
+ const name = isSymlink && arrowIdx !== -1 ? rawName.slice(0, arrowIdx) : rawName;
663
+ const symlinkTarget = isSymlink && arrowIdx !== -1 ? rawName.slice(arrowIdx + 4) : undefined;
664
+ return {
665
+ name,
666
+ type: isDir ? 'directory' : 'file',
667
+ size: isDir ? undefined : size,
668
+ ...(symlinkTarget ? { symlinkTarget } : {}),
669
+ };
670
+ });
671
+ },
672
+ async deleteFile(path) {
673
+ const result = await this.exec(`rm -rf ${shellQuote(path)}`);
674
+ if (result.exitCode !== 0)
675
+ throw new Error(t('codeSandbox.docker.error.deleteFailed', { path, error: result.stderr }, { defaultValue: `Failed to delete ${path}: ${result.stderr}` }));
676
+ },
677
+ getPreviewUrl(port) {
678
+ // Replace ALL {port} occurrences — the template may use it more than once
679
+ // (e.g. a per-sandbox subdomain `sb-{port}.preview.localhost:{port}`).
680
+ return provider.previewUrlTemplate.replace(/\{port\}/g, String(port ?? 5173));
681
+ },
682
+ async spawn(command, opts) {
683
+ const execCreate = (await provider.dockerApi(`/containers/${containerId}/exec`, 'POST', {
684
+ Cmd: ['sh', '-c', command],
685
+ AttachStdin: true,
686
+ AttachStdout: true,
687
+ AttachStderr: true,
688
+ Tty: false,
689
+ WorkingDir: opts?.cwd ?? '/workspace',
690
+ Env: opts?.env ? Object.entries(opts.env).map(([k, v]) => `${k}=${v}`) : undefined,
691
+ }));
692
+ const socket = await provider.dockerExecUpgrade(execCreate.Id);
693
+ const parser = new DockerMuxParser();
694
+ let closeCb = null;
695
+ // Kill idle spawn sockets after 10 minutes to prevent descriptor leaks
696
+ socket.setTimeout(600_000);
697
+ socket.on('timeout', () => {
698
+ socket.destroy();
699
+ });
700
+ socket.on('data', (chunk) => {
701
+ try {
702
+ parser.feed(chunk);
703
+ }
704
+ catch (err) {
705
+ logger.debug('Spawn parser error, destroying socket', {
706
+ error: err instanceof Error ? err.message : err,
707
+ });
708
+ socket.destroy();
709
+ }
710
+ });
711
+ socket.on('close', () => {
712
+ parser.clear();
713
+ closeCb?.();
714
+ });
715
+ socket.on('error', (err) => {
716
+ logger.debug('Spawn socket error', { error: err.message });
717
+ parser.clear();
718
+ closeCb?.();
719
+ });
720
+ return {
721
+ write(data) {
722
+ if (!socket.destroyed)
723
+ socket.write(data);
724
+ },
725
+ onStdout(cb) {
726
+ parser.onStdout(cb);
727
+ },
728
+ onStderr(cb) {
729
+ parser.onStderr(cb);
730
+ },
731
+ onClose(cb) {
732
+ closeCb = cb;
733
+ },
734
+ kill() {
735
+ if (!socket.destroyed)
736
+ socket.destroy();
737
+ },
738
+ };
739
+ },
740
+ onFileChange(_cb) {
741
+ let active = true;
742
+ const poll = async () => {
743
+ while (active) {
744
+ await new Promise((r) => setTimeout(r, 2000));
745
+ }
746
+ };
747
+ poll().catch(() => { });
748
+ return () => {
749
+ active = false;
750
+ };
751
+ },
752
+ };
753
+ }
754
+ // ---------------------------------------------------------------------------
755
+ // Volume management
756
+ // ---------------------------------------------------------------------------
757
+ /**
758
+ * Create a named Docker volume. No-op if it already exists.
759
+ * @param name - The Docker volume name to create.
760
+ */
761
+ async createVolume(name) {
762
+ try {
763
+ await this.dockerApi('/volumes/create', 'POST', { Name: name });
764
+ }
765
+ catch (error) {
766
+ // 409 Conflict means the volume already exists — idempotent success
767
+ if (error instanceof Error && error.message.includes('409'))
768
+ return;
769
+ throw error;
770
+ }
771
+ }
772
+ /**
773
+ * Remove a named Docker volume. Silently succeeds if already removed.
774
+ * @param name - The Docker volume name to remove.
775
+ */
776
+ async removeVolume(name) {
777
+ try {
778
+ await this.dockerApi(`/volumes/${name}`, 'DELETE');
779
+ }
780
+ catch (error) {
781
+ logger.debug('Failed to remove volume', { name, error });
782
+ }
783
+ }
784
+ /**
785
+ * Check if a named Docker volume exists.
786
+ * @param name - The Docker volume name to check.
787
+ * @returns `true` if the volume exists, `false` otherwise.
788
+ */
789
+ async volumeExists(name) {
790
+ try {
791
+ await this.dockerApi(`/volumes/${name}`);
792
+ return true;
793
+ }
794
+ catch (_error) {
795
+ // 404 means the volume does not exist — that is the expected "false" result
796
+ return false;
797
+ }
798
+ }
799
+ /**
800
+ * Makes an HTTP request to the Docker Engine API via the Unix socket.
801
+ * @param path - The API endpoint path (e.g. `/containers/create`).
802
+ * @param method - The HTTP method (defaults to `'GET'`).
803
+ * @param body - Optional JSON request body.
804
+ * @param timeoutMs - Request timeout (default 30 s). Container create/start
805
+ * pass a longer one: the daemon populates a fresh named volume from the
806
+ * image's contents during those calls, which scales with image size — a
807
+ * multi-GB `/workspace` reliably exceeds 30 s, and a client-side timeout on
808
+ * a create that succeeds server-side leaks a duplicate container.
809
+ * @returns The parsed JSON response, or raw text for non-JSON responses.
810
+ */
811
+ async dockerApi(path, method = 'GET', body, timeoutMs = 30_000) {
812
+ return new Promise((resolve, reject) => {
813
+ const opts = {
814
+ ...this.endpoint,
815
+ path: `/v1.44${path}`,
816
+ method,
817
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
818
+ };
819
+ const req = http.request(opts, (res) => {
820
+ const chunks = [];
821
+ res.on('data', (chunk) => chunks.push(chunk));
822
+ res.on('end', () => {
823
+ const text = Buffer.concat(chunks).toString();
824
+ if (res.statusCode && res.statusCode >= 400) {
825
+ reject(new Error(t('codeSandbox.docker.error.apiError', { method, path, status: String(res.statusCode), error: text }, { defaultValue: `Docker API ${method} ${path}: ${res.statusCode} ${text}` })));
826
+ return;
827
+ }
828
+ try {
829
+ resolve(JSON.parse(text));
830
+ }
831
+ catch (_error) {
832
+ // Non-JSON response (e.g. plain-text or empty body) — return raw text
833
+ resolve(text);
834
+ }
835
+ });
836
+ res.on('error', reject);
837
+ });
838
+ // Timeout to prevent hanging on unresponsive Docker daemon
839
+ req.setTimeout(timeoutMs, () => {
840
+ req.destroy(new Error(`Docker API timeout: ${method} ${path}`));
841
+ });
842
+ req.on('error', reject);
843
+ if (body)
844
+ req.write(JSON.stringify(body));
845
+ req.end();
846
+ });
847
+ }
848
+ /**
849
+ * Like `dockerApi` but returns the raw response Buffer without parsing.
850
+ * Used for exec start responses which return a multiplexed binary stream.
851
+ * @param path - The API endpoint path.
852
+ * @param method - The HTTP method (defaults to `'GET'`).
853
+ * @param body - Optional JSON request body.
854
+ * @returns The raw response buffer.
855
+ */
856
+ async dockerApiRaw(path, method = 'GET', body) {
857
+ return new Promise((resolve, reject) => {
858
+ const opts = {
859
+ ...this.endpoint,
860
+ path: `/v1.44${path}`,
861
+ method,
862
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
863
+ };
864
+ const MAX_RAW_RESPONSE = 50 * 1024 * 1024; // 50 MB cap on exec output
865
+ let totalSize = 0;
866
+ const req = http.request(opts, (res) => {
867
+ const chunks = [];
868
+ res.on('data', (chunk) => {
869
+ totalSize += chunk.length;
870
+ if (totalSize > MAX_RAW_RESPONSE) {
871
+ req.destroy(new Error(`Docker exec output exceeded ${MAX_RAW_RESPONSE / (1024 * 1024)}MB limit`));
872
+ return;
873
+ }
874
+ chunks.push(chunk);
875
+ });
876
+ res.on('end', () => {
877
+ const buf = Buffer.concat(chunks);
878
+ if (res.statusCode && res.statusCode >= 400) {
879
+ reject(new Error(t('codeSandbox.docker.error.apiError', { method, path, status: String(res.statusCode), error: buf.toString() }, {
880
+ defaultValue: `Docker API ${method} ${path}: ${res.statusCode} ${buf.toString()}`,
881
+ })));
882
+ return;
883
+ }
884
+ resolve(buf);
885
+ });
886
+ res.on('error', reject);
887
+ });
888
+ // Longer timeout for exec responses (commands can take minutes)
889
+ req.setTimeout(600_000, () => {
890
+ req.destroy(new Error(`Docker exec timeout: ${method} ${path}`));
891
+ });
892
+ req.on('error', reject);
893
+ if (body)
894
+ req.write(JSON.stringify(body));
895
+ req.end();
896
+ });
897
+ }
898
+ /**
899
+ * Starts a Docker exec instance and upgrades the HTTP connection to a raw
900
+ * bidirectional socket. Docker hijacks the connection (101 Switching Protocols)
901
+ * when stdin is attached, giving us a raw TCP socket for streaming I/O.
902
+ * @param execId - The exec instance ID from the create step.
903
+ * @returns The raw socket for stdin writes and multiplexed stdout/stderr reads.
904
+ */
905
+ dockerExecUpgrade(execId) {
906
+ return new Promise((resolve, reject) => {
907
+ const body = JSON.stringify({ Detach: false, Tty: false });
908
+ const req = http.request({
909
+ ...this.endpoint,
910
+ path: `/v1.44/exec/${execId}/start`,
911
+ method: 'POST',
912
+ headers: {
913
+ 'Content-Type': 'application/json',
914
+ Connection: 'Upgrade',
915
+ Upgrade: 'tcp',
916
+ },
917
+ }, (res) => {
918
+ // Fallback: if Docker doesn't upgrade (e.g. old version), reject
919
+ reject(new Error(`Expected 101 upgrade, got ${res.statusCode}`));
920
+ });
921
+ req.on('upgrade', (_res, socket, head) => {
922
+ // Process any initial data that arrived with the upgrade response
923
+ if (head.length > 0) {
924
+ socket.unshift(head);
925
+ }
926
+ resolve(socket);
927
+ });
928
+ req.on('error', reject);
929
+ req.write(body);
930
+ req.end();
931
+ });
932
+ }
933
+ }
934
+ /**
935
+ * Creates a new `DockerSandboxProvider` instance with the given configuration.
936
+ * @param config - Optional Docker-specific configuration: daemon endpoint
937
+ * (`socketPath`, or `host`/`port` for a TCP daemon; `DOCKER_HOST` is also
938
+ * honored), `baseImage`, CPU/memory defaults, and the sandbox `network`.
939
+ * @returns A `SandboxProvider` that manages Docker containers as sandboxes.
940
+ */
941
+ export function createProvider(config) {
942
+ return new DockerSandboxProvider(config);
943
+ }