@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.
- package/LICENSE +115 -0
- package/dist/browser-guard.d.ts +2 -0
- package/dist/browser-guard.d.ts.map +1 -0
- package/dist/browser-guard.js +18 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +54 -0
- package/dist/provider.d.ts +54 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +943 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/package.json +57 -0
package/dist/provider.js
ADDED
|
@@ -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
|
+
}
|