@molecule/api-code-sandbox-docker 1.0.2 → 1.1.1
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/README.md +9 -3
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +179 -12
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
|
|
|
3
3
|
Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
|
|
4
4
|
Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
|
|
5
5
|
To change this document, edit the module-level JSDoc in src/index.ts.
|
|
6
|
-
Generated: 2026-
|
|
6
|
+
Generated: 2026-09-18T06:33:40.632Z
|
|
7
7
|
-->
|
|
8
8
|
|
|
9
9
|
# @molecule/api-code-sandbox-docker
|
|
@@ -236,7 +236,7 @@ export function setupCodeSandboxDocker(): void {
|
|
|
236
236
|
Peer dependencies:
|
|
237
237
|
|
|
238
238
|
- `@molecule/api-bond` ^1.0.1
|
|
239
|
-
- `@molecule/api-code-sandbox` ^1.0
|
|
239
|
+
- `@molecule/api-code-sandbox` ^1.2.0
|
|
240
240
|
- `@molecule/api-i18n` ^1.0.1
|
|
241
241
|
|
|
242
242
|
### Runtime Dependencies
|
|
@@ -260,7 +260,13 @@ on the host. The daemon is reached over a unix socket by default
|
|
|
260
260
|
remote or rootless daemon can be selected with `config.host`/`config.port` or a
|
|
261
261
|
`DOCKER_HOST` (`tcp://host:port` or `unix:///path`). Only PLAIN (unencrypted)
|
|
262
262
|
TCP is supported — front a TLS-protected daemon (2376) with a local socket
|
|
263
|
-
proxy.
|
|
263
|
+
proxy. When the plain-TCP endpoint comes from the ambient `DOCKER_HOST` env
|
|
264
|
+
(not explicit config) and `NODE_ENV=production`, the provider REFUSES to
|
|
265
|
+
connect — exec payloads would cross the network unencrypted — unless
|
|
266
|
+
`MOL_DOCKER_ALLOW_PLAIN_TCP=1` is set, which is honored with a loud warning.
|
|
267
|
+
Caller-supplied container ids are shape-validated (`[A-Za-z0-9][A-Za-z0-9_.:-]*`,
|
|
268
|
+
so Docker hex ids, names, and fly-style `app:machine` ids pass) before they are
|
|
269
|
+
interpolated into Docker API paths. The provider never pulls images, so `create()` fails with a no-such-image
|
|
264
270
|
error if the base image (default `node:22-slim`, or `config.baseImage`) is
|
|
265
271
|
absent. The isolated sandbox network is auto-created on first use; it is NOT a
|
|
266
272
|
prerequisite.
|
package/dist/index.d.ts
CHANGED
|
@@ -17,7 +17,13 @@
|
|
|
17
17
|
* remote or rootless daemon can be selected with `config.host`/`config.port` or a
|
|
18
18
|
* `DOCKER_HOST` (`tcp://host:port` or `unix:///path`). Only PLAIN (unencrypted)
|
|
19
19
|
* TCP is supported — front a TLS-protected daemon (2376) with a local socket
|
|
20
|
-
* proxy.
|
|
20
|
+
* proxy. When the plain-TCP endpoint comes from the ambient `DOCKER_HOST` env
|
|
21
|
+
* (not explicit config) and `NODE_ENV=production`, the provider REFUSES to
|
|
22
|
+
* connect — exec payloads would cross the network unencrypted — unless
|
|
23
|
+
* `MOL_DOCKER_ALLOW_PLAIN_TCP=1` is set, which is honored with a loud warning.
|
|
24
|
+
* Caller-supplied container ids are shape-validated (`[A-Za-z0-9][A-Za-z0-9_.:-]*`,
|
|
25
|
+
* so Docker hex ids, names, and fly-style `app:machine` ids pass) before they are
|
|
26
|
+
* interpolated into Docker API paths. The provider never pulls images, so `create()` fails with a no-such-image
|
|
21
27
|
* error if the base image (default `node:22-slim`, or `config.baseImage`) is
|
|
22
28
|
* absent. The isolated sandbox network is auto-created on first use; it is NOT a
|
|
23
29
|
* prerequisite.
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgFG;AAEH,cAAc,oBAAoB,CAAA;AAClC,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA;AAE1B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAMjE;;GAEG;AACH,eAAO,MAAM,QAAQ,EAAE,eAUrB,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -17,7 +17,13 @@
|
|
|
17
17
|
* remote or rootless daemon can be selected with `config.host`/`config.port` or a
|
|
18
18
|
* `DOCKER_HOST` (`tcp://host:port` or `unix:///path`). Only PLAIN (unencrypted)
|
|
19
19
|
* TCP is supported — front a TLS-protected daemon (2376) with a local socket
|
|
20
|
-
* proxy.
|
|
20
|
+
* proxy. When the plain-TCP endpoint comes from the ambient `DOCKER_HOST` env
|
|
21
|
+
* (not explicit config) and `NODE_ENV=production`, the provider REFUSES to
|
|
22
|
+
* connect — exec payloads would cross the network unencrypted — unless
|
|
23
|
+
* `MOL_DOCKER_ALLOW_PLAIN_TCP=1` is set, which is honored with a loud warning.
|
|
24
|
+
* Caller-supplied container ids are shape-validated (`[A-Za-z0-9][A-Za-z0-9_.:-]*`,
|
|
25
|
+
* so Docker hex ids, names, and fly-style `app:machine` ids pass) before they are
|
|
26
|
+
* interpolated into Docker API paths. The provider never pulls images, so `create()` fails with a no-such-image
|
|
21
27
|
* error if the base image (default `node:22-slim`, or `config.baseImage`) is
|
|
22
28
|
* absent. The isolated sandbox network is auto-created on first use; it is NOT a
|
|
23
29
|
* prerequisite.
|
package/dist/provider.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,OAAO,KAAK,EAcV,eAAe,
|
|
1
|
+
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,OAAO,KAAK,EAcV,eAAe,EAOhB,MAAM,4BAA4B,CAAA;AAoBnC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AA4K9C;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAQ9D;AAkCD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,kBAAkB,CAAC,CAAC,EACxC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,IAAI,EAAE;IACJ,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;IACjC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAA;IACrC,GAAG,CAAC,EAAE;QAAE,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;KAAE,CAAA;CAC1D,GACA,OAAO,CAAC,CAAC,CAAC,CA2BZ;AA07CD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG,eAAe,CAErE"}
|
package/dist/provider.js
CHANGED
|
@@ -86,20 +86,32 @@ function parseDockerHost(value) {
|
|
|
86
86
|
* 2. `config.socketPath` → that unix socket;
|
|
87
87
|
* 3. `DOCKER_HOST` env (`tcp://` or `unix://`, the docker-client convention);
|
|
88
88
|
* 4. `DOCKER_SOCKET_PATH` env, else `/var/run/docker.sock` (the default transport).
|
|
89
|
+
*
|
|
90
|
+
* The result also reports whether the endpoint came from the ambient
|
|
91
|
+
* `DOCKER_HOST` env (vs. explicit code config) — the production plain-TCP
|
|
92
|
+
* guard treats those differently; see {@link DockerSandboxProvider.ensureEndpointSafety}.
|
|
93
|
+
*
|
|
89
94
|
* @param config - the provider configuration.
|
|
90
|
-
* @returns the resolved endpoint to spread into every Docker
|
|
95
|
+
* @returns the resolved endpoint plus its source, to spread into every Docker
|
|
96
|
+
* API request.
|
|
91
97
|
*/
|
|
92
98
|
function resolveDockerEndpoint(config) {
|
|
93
99
|
if (config.host !== undefined || config.port !== undefined) {
|
|
94
|
-
return {
|
|
100
|
+
return {
|
|
101
|
+
endpoint: { host: config.host ?? '127.0.0.1', port: config.port ?? DEFAULT_DOCKER_TCP_PORT },
|
|
102
|
+
fromDockerHostEnv: false,
|
|
103
|
+
};
|
|
95
104
|
}
|
|
96
105
|
if (config.socketPath !== undefined) {
|
|
97
|
-
return { socketPath: config.socketPath };
|
|
106
|
+
return { endpoint: { socketPath: config.socketPath }, fromDockerHostEnv: false };
|
|
98
107
|
}
|
|
99
108
|
const fromEnv = parseDockerHost(process.env.DOCKER_HOST);
|
|
100
109
|
if (fromEnv)
|
|
101
|
-
return fromEnv;
|
|
102
|
-
return {
|
|
110
|
+
return { endpoint: fromEnv, fromDockerHostEnv: true };
|
|
111
|
+
return {
|
|
112
|
+
endpoint: { socketPath: process.env.DOCKER_SOCKET_PATH ?? '/var/run/docker.sock' },
|
|
113
|
+
fromDockerHostEnv: false,
|
|
114
|
+
};
|
|
103
115
|
}
|
|
104
116
|
/**
|
|
105
117
|
* Parses Docker's multiplexed binary stream from a raw socket.
|
|
@@ -300,8 +312,16 @@ class DockerSandboxProvider {
|
|
|
300
312
|
* providers (and two tests) do not inherit each other's verdict.
|
|
301
313
|
*/
|
|
302
314
|
checkpoints = { supported: true };
|
|
315
|
+
/** Resolved daemon endpoint source: ambient `DOCKER_HOST` env vs explicit config. */
|
|
316
|
+
endpointFromDockerHostEnv;
|
|
317
|
+
/** {@link ensureEndpointSafety} runs once per provider instance (successful checks only). */
|
|
318
|
+
endpointSafetyChecked = false;
|
|
319
|
+
/** Whether the plain-TCP override warning has fired (once per provider). */
|
|
320
|
+
plainTcpOverrideWarned = false;
|
|
303
321
|
constructor(config = {}) {
|
|
304
|
-
|
|
322
|
+
const resolved = resolveDockerEndpoint(config);
|
|
323
|
+
this.endpoint = resolved.endpoint;
|
|
324
|
+
this.endpointFromDockerHostEnv = resolved.fromDockerHostEnv;
|
|
305
325
|
this.baseImage = config.baseImage ?? DEFAULT_IMAGE;
|
|
306
326
|
this.labelPrefix = config.labelPrefix ?? LABEL_PREFIX;
|
|
307
327
|
this.previewUrlTemplate = config.previewUrlTemplate ?? 'http://localhost:{port}';
|
|
@@ -508,6 +528,15 @@ class DockerSandboxProvider {
|
|
|
508
528
|
labels[`${this.labelPrefix}.volumeName`] = config.volumeName;
|
|
509
529
|
hostConfig.Binds = [`${config.volumeName}:/workspace`];
|
|
510
530
|
}
|
|
531
|
+
// What the container does when its main process exits. Docker's default is
|
|
532
|
+
// `no`, matching this provider's historical behaviour: a sandbox whose
|
|
533
|
+
// process exits stays stopped for the caller to inspect. A caller running
|
|
534
|
+
// something long-lived (a production instance) asks for `always`/`on-failure`
|
|
535
|
+
// so the workload comes back after a crash or a daemon restart without a
|
|
536
|
+
// control plane in the loop.
|
|
537
|
+
if (config.restartPolicy && config.restartPolicy !== 'no') {
|
|
538
|
+
hostConfig.RestartPolicy = { Name: config.restartPolicy };
|
|
539
|
+
}
|
|
511
540
|
const body = {
|
|
512
541
|
Image: image,
|
|
513
542
|
Env: env,
|
|
@@ -515,6 +544,13 @@ class DockerSandboxProvider {
|
|
|
515
544
|
HostConfig: hostConfig,
|
|
516
545
|
ExposedPorts: Object.fromEntries(publishPorts.map((port) => [`${port}/tcp`, {}])),
|
|
517
546
|
};
|
|
547
|
+
// The caller's own main process, replacing the image's CMD. Without it a
|
|
548
|
+
// container that Docker restarts comes back running the image's idle command
|
|
549
|
+
// and serving nothing, while `status` still reports it as running — which it
|
|
550
|
+
// is, and empty. `Cmd` (not `Entrypoint`) so the image's entrypoint — tini,
|
|
551
|
+
// enabled above via `Init` — still wraps it and reaps its children.
|
|
552
|
+
if (config.command?.length)
|
|
553
|
+
body.Cmd = [...config.command];
|
|
518
554
|
// Retry a transient create timeout (daemon momentarily overwhelmed under
|
|
519
555
|
// concurrent boots) instead of failing the whole sandbox boot. The onRetry
|
|
520
556
|
// guard adopts a container a timed-out create may have already made — keyed on
|
|
@@ -553,6 +589,9 @@ class DockerSandboxProvider {
|
|
|
553
589
|
*/
|
|
554
590
|
async get(id) {
|
|
555
591
|
try {
|
|
592
|
+
// Validate before interpolation into `/containers/${id}/json` — an
|
|
593
|
+
// id carrying path structure could retarget the request.
|
|
594
|
+
this.assertContainerId(id);
|
|
556
595
|
const info = (await this.dockerApi(`/containers/${id}/json`));
|
|
557
596
|
const projectId = info.Config.Labels[`${this.labelPrefix}.projectId`] ?? '';
|
|
558
597
|
// Derive the real status from Docker's container state rather than
|
|
@@ -585,6 +624,8 @@ class DockerSandboxProvider {
|
|
|
585
624
|
* @param id - The Docker container ID to destroy.
|
|
586
625
|
*/
|
|
587
626
|
async destroy(id) {
|
|
627
|
+
// Validate before interpolation into `/containers/${id}...` paths.
|
|
628
|
+
this.assertContainerId(id);
|
|
588
629
|
// Read volume name from container labels before removing
|
|
589
630
|
let volumeName;
|
|
590
631
|
try {
|
|
@@ -625,6 +666,9 @@ class DockerSandboxProvider {
|
|
|
625
666
|
* @returns A `Sandbox` with lifecycle and file system methods bound to the container.
|
|
626
667
|
*/
|
|
627
668
|
buildSandbox(containerId, _projectId, initialStatus = 'stopped') {
|
|
669
|
+
// Every method on the returned Sandbox interpolates containerId into
|
|
670
|
+
// Docker API paths (start/stop/exec) — validate once, here.
|
|
671
|
+
this.assertContainerId(containerId);
|
|
628
672
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
629
673
|
const provider = this;
|
|
630
674
|
return {
|
|
@@ -823,17 +867,32 @@ class DockerSandboxProvider {
|
|
|
823
867
|
// (e.g. a per-sandbox subdomain `sb-{port}.preview.localhost:{port}`).
|
|
824
868
|
return provider.previewUrlTemplate.replace(/\{port\}/g, String(port ?? 5173));
|
|
825
869
|
},
|
|
870
|
+
/**
|
|
871
|
+
* Spawn a long-running process with streaming I/O, optionally on a PTY.
|
|
872
|
+
*
|
|
873
|
+
* `opts.pty` asks Docker for `Tty: true`, which gives the process a real
|
|
874
|
+
* controlling terminal: Ctrl-C (`0x03`) becomes SIGINT for the foreground
|
|
875
|
+
* job and the width can be renegotiated through `handle.resize()`. A TTY
|
|
876
|
+
* exec stream is NOT multiplexed, so the frame parser is bypassed and
|
|
877
|
+
* everything (stderr included, as on a real terminal) arrives on stdout.
|
|
878
|
+
*
|
|
879
|
+
* @param command - The command to run.
|
|
880
|
+
* @param opts - Working directory, env, and optional PTY size.
|
|
881
|
+
* @returns A handle with streaming I/O, stdin, kill, and (on a PTY) resize.
|
|
882
|
+
*/
|
|
826
883
|
async spawn(command, opts) {
|
|
884
|
+
const tty = Boolean(opts?.pty);
|
|
827
885
|
const execCreate = (await provider.dockerApi(`/containers/${containerId}/exec`, 'POST', {
|
|
828
886
|
Cmd: ['sh', '-c', command],
|
|
829
887
|
AttachStdin: true,
|
|
830
888
|
AttachStdout: true,
|
|
831
889
|
AttachStderr: true,
|
|
832
|
-
Tty:
|
|
890
|
+
Tty: tty,
|
|
891
|
+
...(opts?.pty ? { ConsoleSize: [opts.pty.rows, opts.pty.cols] } : {}),
|
|
833
892
|
WorkingDir: opts?.cwd ?? '/workspace',
|
|
834
893
|
Env: opts?.env ? Object.entries(opts.env).map(([k, v]) => `${k}=${v}`) : undefined,
|
|
835
894
|
}));
|
|
836
|
-
const socket = await provider.dockerExecUpgrade(execCreate.Id);
|
|
895
|
+
const socket = await provider.dockerExecUpgrade(execCreate.Id, tty);
|
|
837
896
|
const parser = new DockerMuxParser();
|
|
838
897
|
let closeCb = null;
|
|
839
898
|
// Kill idle spawn sockets after 10 minutes to prevent descriptor leaks
|
|
@@ -841,7 +900,17 @@ class DockerSandboxProvider {
|
|
|
841
900
|
socket.on('timeout', () => {
|
|
842
901
|
socket.destroy();
|
|
843
902
|
});
|
|
903
|
+
// A TTY exec stream carries raw terminal bytes — there are no 8-byte
|
|
904
|
+
// frame headers to strip, and feeding them to the mux parser would read
|
|
905
|
+
// the first byte of the shell's output as a stream descriptor.
|
|
906
|
+
const ttyListeners = [];
|
|
844
907
|
socket.on('data', (chunk) => {
|
|
908
|
+
if (tty) {
|
|
909
|
+
const text = chunk.toString('utf8');
|
|
910
|
+
for (const cb of ttyListeners)
|
|
911
|
+
cb(text);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
845
914
|
try {
|
|
846
915
|
parser.feed(chunk);
|
|
847
916
|
}
|
|
@@ -867,10 +936,16 @@ class DockerSandboxProvider {
|
|
|
867
936
|
socket.write(data);
|
|
868
937
|
},
|
|
869
938
|
onStdout(cb) {
|
|
870
|
-
|
|
939
|
+
if (tty)
|
|
940
|
+
ttyListeners.push(cb);
|
|
941
|
+
else
|
|
942
|
+
parser.onStdout(cb);
|
|
871
943
|
},
|
|
872
944
|
onStderr(cb) {
|
|
873
|
-
|
|
945
|
+
// On a TTY both streams share one descriptor, so nothing arrives
|
|
946
|
+
// here — registering is harmless and keeps the handle uniform.
|
|
947
|
+
if (!tty)
|
|
948
|
+
parser.onStderr(cb);
|
|
874
949
|
},
|
|
875
950
|
onClose(cb) {
|
|
876
951
|
closeCb = cb;
|
|
@@ -879,6 +954,22 @@ class DockerSandboxProvider {
|
|
|
879
954
|
if (!socket.destroyed)
|
|
880
955
|
socket.destroy();
|
|
881
956
|
},
|
|
957
|
+
...(tty
|
|
958
|
+
? {
|
|
959
|
+
resize(size) {
|
|
960
|
+
void provider
|
|
961
|
+
.dockerApi(`/exec/${execCreate.Id}/resize?h=${Math.max(1, Math.floor(size.rows))}&w=${Math.max(1, Math.floor(size.cols))}`, 'POST')
|
|
962
|
+
.catch((error) => {
|
|
963
|
+
// Best-effort: a resize races the exec exiting, and a
|
|
964
|
+
// failed one only leaves the remote width stale — never a
|
|
965
|
+
// reason to tear down a live session.
|
|
966
|
+
logger.debug('Spawn resize failed', {
|
|
967
|
+
error: error instanceof Error ? error.message : error,
|
|
968
|
+
});
|
|
969
|
+
});
|
|
970
|
+
},
|
|
971
|
+
}
|
|
972
|
+
: {}),
|
|
882
973
|
};
|
|
883
974
|
},
|
|
884
975
|
async hibernate() {
|
|
@@ -1126,7 +1217,79 @@ class DockerSandboxProvider {
|
|
|
1126
1217
|
* `X-Registry-Auth`, which the daemon requires even for anonymous pulls.
|
|
1127
1218
|
* @returns The parsed JSON response, or raw text for non-JSON responses.
|
|
1128
1219
|
*/
|
|
1220
|
+
/**
|
|
1221
|
+
* Production guard for ambient plain-TCP daemon endpoints (DOCKER_HOST).
|
|
1222
|
+
*
|
|
1223
|
+
* A `tcp://` Docker endpoint speaks PLAIN HTTP: every API request —
|
|
1224
|
+
* including exec payloads (arbitrary commands + file contents) and image
|
|
1225
|
+
* operations — crosses the network unencrypted and unauthenticated.
|
|
1226
|
+
* Reaching such a daemon because an ambient `DOCKER_HOST` env happens to
|
|
1227
|
+
* be set is not a choice this library gets to make silently in production:
|
|
1228
|
+
* it refuses unless the operator explicitly accepts the risk via
|
|
1229
|
+
* `MOL_DOCKER_ALLOW_PLAIN_TCP=1` (which is honored with a loud warning).
|
|
1230
|
+
*
|
|
1231
|
+
* Explicit `config.host`/`config.port` is deliberately NOT guarded — that
|
|
1232
|
+
* is a code-level decision by the embedding application, and dev/CI setups
|
|
1233
|
+
* rely on it. Runs lazily (first API call) rather than in the constructor,
|
|
1234
|
+
* matching the provider's env-reads-at-call-time convention.
|
|
1235
|
+
*/
|
|
1236
|
+
ensureEndpointSafety() {
|
|
1237
|
+
if (this.endpointSafetyChecked)
|
|
1238
|
+
return;
|
|
1239
|
+
if (!this.endpointFromDockerHostEnv || !('host' in this.endpoint)) {
|
|
1240
|
+
this.endpointSafetyChecked = true;
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
1244
|
+
this.endpointSafetyChecked = true;
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
const override = process.env.MOL_DOCKER_ALLOW_PLAIN_TCP;
|
|
1248
|
+
if (override === '1' || override === 'true') {
|
|
1249
|
+
if (!this.plainTcpOverrideWarned) {
|
|
1250
|
+
this.plainTcpOverrideWarned = true;
|
|
1251
|
+
logger.warn('DOCKER_HOST selects a PLAIN-TCP Docker endpoint while NODE_ENV=production — ' +
|
|
1252
|
+
'container control traffic (incl. exec payloads) crosses the network unencrypted. ' +
|
|
1253
|
+
'Accepted only because MOL_DOCKER_ALLOW_PLAIN_TCP is set; prefer a unix socket ' +
|
|
1254
|
+
'or a TLS-terminating proxy.');
|
|
1255
|
+
}
|
|
1256
|
+
this.endpointSafetyChecked = true;
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
// Refuse WITHOUT memoizing: an earlier refusal can land inside a
|
|
1260
|
+
// best-effort try/catch (e.g. network ensure) and be swallowed — every
|
|
1261
|
+
// subsequent call must keep refusing until the configuration changes.
|
|
1262
|
+
throw new Error('DOCKER_HOST selects a plain-TCP (unencrypted, unauthenticated) Docker endpoint and ' +
|
|
1263
|
+
'NODE_ENV=production — refusing. Set MOL_DOCKER_ALLOW_PLAIN_TCP=1 to explicitly accept ' +
|
|
1264
|
+
'the risk, or point the provider at a unix socket / TLS-terminating proxy.');
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Shape a caller-supplied container id must have before it is interpolated
|
|
1268
|
+
* into a Docker API path. Docker's own ids are 64-char hex and container
|
|
1269
|
+
* names match `[a-zA-Z0-9][a-zA-Z0-9_.-]*`; the E2B-style provider uses
|
|
1270
|
+
* `app:machine` ids, so `:` is allowed too. Anything carrying URL/path
|
|
1271
|
+
* structure (`/`, `?`, `#`), percent-encoding, or whitespace is rejected —
|
|
1272
|
+
* interpolated raw, such a value could retarget the request path (e.g.
|
|
1273
|
+
* `../exec` or `?force=true` on a different resource).
|
|
1274
|
+
*/
|
|
1275
|
+
static CONTAINER_ID_SHAPE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
|
|
1276
|
+
/**
|
|
1277
|
+
* Validates a container id before any Docker API path interpolation.
|
|
1278
|
+
* @param id - the caller-supplied container id.
|
|
1279
|
+
* @returns the validated id, unchanged.
|
|
1280
|
+
* @throws {Error} when the id does not match {@link DockerSandboxProvider.CONTAINER_ID_SHAPE}.
|
|
1281
|
+
*/
|
|
1282
|
+
assertContainerId(id) {
|
|
1283
|
+
if (!DockerSandboxProvider.CONTAINER_ID_SHAPE.test(id)) {
|
|
1284
|
+
throw new Error(`Invalid sandbox container id: ${JSON.stringify(id)}`);
|
|
1285
|
+
}
|
|
1286
|
+
return id;
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Performs a JSON Docker Engine API request against the resolved endpoint.
|
|
1290
|
+
*/
|
|
1129
1291
|
async dockerApi(path, method = 'GET', body, timeoutMs = 30_000, headers) {
|
|
1292
|
+
this.ensureEndpointSafety();
|
|
1130
1293
|
return new Promise((resolve, reject) => {
|
|
1131
1294
|
const opts = {
|
|
1132
1295
|
...this.endpoint,
|
|
@@ -1265,6 +1428,7 @@ class DockerSandboxProvider {
|
|
|
1265
1428
|
* @returns The raw response buffer.
|
|
1266
1429
|
*/
|
|
1267
1430
|
async dockerApiRaw(path, method = 'GET', body) {
|
|
1431
|
+
this.ensureEndpointSafety();
|
|
1268
1432
|
return new Promise((resolve, reject) => {
|
|
1269
1433
|
const opts = {
|
|
1270
1434
|
...this.endpoint,
|
|
@@ -1311,11 +1475,14 @@ class DockerSandboxProvider {
|
|
|
1311
1475
|
* bidirectional socket. Docker hijacks the connection (101 Switching Protocols)
|
|
1312
1476
|
* when stdin is attached, giving us a raw TCP socket for streaming I/O.
|
|
1313
1477
|
* @param execId - The exec instance ID from the create step.
|
|
1478
|
+
* @param tty - Whether the exec was created with a TTY; the start call must
|
|
1479
|
+
* agree, or Docker streams multiplexed frames to a terminal expecting raw
|
|
1480
|
+
* bytes.
|
|
1314
1481
|
* @returns The raw socket for stdin writes and multiplexed stdout/stderr reads.
|
|
1315
1482
|
*/
|
|
1316
|
-
dockerExecUpgrade(execId) {
|
|
1483
|
+
dockerExecUpgrade(execId, tty = false) {
|
|
1317
1484
|
return new Promise((resolve, reject) => {
|
|
1318
|
-
const body = JSON.stringify({ Detach: false, Tty:
|
|
1485
|
+
const body = JSON.stringify({ Detach: false, Tty: tty });
|
|
1319
1486
|
const req = http.request({
|
|
1320
1487
|
...this.endpoint,
|
|
1321
1488
|
path: `/v1.44/exec/${execId}/start`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@molecule/api-code-sandbox-docker",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Docker-based code sandbox container provider",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -30,14 +30,14 @@
|
|
|
30
30
|
"license": "Apache-2.0",
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@molecule/api-bond": "1.0.1",
|
|
33
|
-
"@molecule/api-code-sandbox": "1.0
|
|
33
|
+
"@molecule/api-code-sandbox": "1.2.0",
|
|
34
34
|
"@types/node": "26.1.2",
|
|
35
35
|
"typescript": "6.0.3",
|
|
36
|
-
"vitest": "4.1.
|
|
36
|
+
"vitest": "4.1.11"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@molecule/api-bond": "^1.0.1",
|
|
40
|
-
"@molecule/api-code-sandbox": "^1.0
|
|
40
|
+
"@molecule/api-code-sandbox": "^1.2.0",
|
|
41
41
|
"@molecule/api-i18n": "^1.0.1"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|