@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 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-08-05T12:53:11.363Z
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.1
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. The provider never pulls images, so `create()` fails with a no-such-image
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. The provider never pulls images, so `create()` fails with a no-such-image
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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0EG;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"}
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. The provider never pulls images, so `create()` fails with a no-such-image
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.
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,OAAO,KAAK,EAcV,eAAe,EAMhB,MAAM,4BAA4B,CAAA;AAoBnC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AA6J9C;;;;;;;;;;;;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;AAyxCD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG,eAAe,CAErE"}
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 API request.
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 { host: config.host ?? '127.0.0.1', port: config.port ?? DEFAULT_DOCKER_TCP_PORT };
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 { socketPath: process.env.DOCKER_SOCKET_PATH ?? '/var/run/docker.sock' };
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
- this.endpoint = resolveDockerEndpoint(config);
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: false,
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
- parser.onStdout(cb);
939
+ if (tty)
940
+ ttyListeners.push(cb);
941
+ else
942
+ parser.onStdout(cb);
871
943
  },
872
944
  onStderr(cb) {
873
- parser.onStderr(cb);
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: false });
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.0.2",
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.2",
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.10"
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.1",
40
+ "@molecule/api-code-sandbox": "^1.2.0",
41
41
  "@molecule/api-i18n": "^1.0.1"
42
42
  },
43
43
  "peerDependenciesMeta": {