@molecule/api-code-sandbox-docker 1.1.0 → 1.1.2

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,EAOhB,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;AA21CD;;;;;;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}';
@@ -569,6 +589,9 @@ class DockerSandboxProvider {
569
589
  */
570
590
  async get(id) {
571
591
  try {
592
+ // Validate before interpolation into `/containers/${id}/json` — an
593
+ // id carrying path structure could retarget the request.
594
+ this.assertContainerId(id);
572
595
  const info = (await this.dockerApi(`/containers/${id}/json`));
573
596
  const projectId = info.Config.Labels[`${this.labelPrefix}.projectId`] ?? '';
574
597
  // Derive the real status from Docker's container state rather than
@@ -601,6 +624,8 @@ class DockerSandboxProvider {
601
624
  * @param id - The Docker container ID to destroy.
602
625
  */
603
626
  async destroy(id) {
627
+ // Validate before interpolation into `/containers/${id}...` paths.
628
+ this.assertContainerId(id);
604
629
  // Read volume name from container labels before removing
605
630
  let volumeName;
606
631
  try {
@@ -641,6 +666,9 @@ class DockerSandboxProvider {
641
666
  * @returns A `Sandbox` with lifecycle and file system methods bound to the container.
642
667
  */
643
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);
644
672
  // eslint-disable-next-line @typescript-eslint/no-this-alias
645
673
  const provider = this;
646
674
  return {
@@ -1189,7 +1217,79 @@ class DockerSandboxProvider {
1189
1217
  * `X-Registry-Auth`, which the daemon requires even for anonymous pulls.
1190
1218
  * @returns The parsed JSON response, or raw text for non-JSON responses.
1191
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
+ */
1192
1291
  async dockerApi(path, method = 'GET', body, timeoutMs = 30_000, headers) {
1292
+ this.ensureEndpointSafety();
1193
1293
  return new Promise((resolve, reject) => {
1194
1294
  const opts = {
1195
1295
  ...this.endpoint,
@@ -1328,6 +1428,7 @@ class DockerSandboxProvider {
1328
1428
  * @returns The raw response buffer.
1329
1429
  */
1330
1430
  async dockerApiRaw(path, method = 'GET', body) {
1431
+ this.ensureEndpointSafety();
1331
1432
  return new Promise((resolve, reject) => {
1332
1433
  const opts = {
1333
1434
  ...this.endpoint,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-code-sandbox-docker",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Docker-based code sandbox container provider",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,11 +29,11 @@
29
29
  ],
30
30
  "license": "Apache-2.0",
31
31
  "devDependencies": {
32
- "@molecule/api-bond": "1.0.1",
33
- "@molecule/api-code-sandbox": "1.2.0",
32
+ "@molecule/api-bond": "1.0.2",
33
+ "@molecule/api-code-sandbox": "1.2.1",
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",
@@ -50,7 +50,7 @@
50
50
  "url": "https://github.com/molecule-dev/molecule.git",
51
51
  "directory": "packages/api/bonds/code-sandbox/docker"
52
52
  },
53
- "homepage": "https://github.com/molecule-dev/molecule/tree/main/packages/api/bonds/code-sandbox/docker",
53
+ "homepage": "https://www.molecule.dev/packages/api-code-sandbox-docker",
54
54
  "bugs": "https://github.com/molecule-dev/molecule/issues",
55
55
  "publishConfig": {
56
56
  "access": "public"