@molecule/api-code-sandbox-docker 1.1.5 → 1.2.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/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-10-16T23:29:06.286Z
6
+ Generated: 2026-10-27T12:31:00.294Z
7
7
  -->
8
8
 
9
9
  # @molecule/api-code-sandbox-docker
@@ -81,6 +81,26 @@ interface DockerConfig {
81
81
  labelPrefix?: string
82
82
  /** Preview URL template. Use {port} placeholder. */
83
83
  previewUrlTemplate?: string
84
+ /**
85
+ * The interface published sandbox ports bind to on the daemon's host.
86
+ * Defaults to `127.0.0.1` (loopback — nothing outside the host reaches a
87
+ * sandbox port), or the `DOCKER_PUBLISH_HOST_IP` env var.
88
+ *
89
+ * The one knob that makes a REMOTE daemon (`host`/`DOCKER_HOST`) usable for
90
+ * hosting: the control plane's front proxies to the address `describe()`
91
+ * reports, and that address is this bind. A specific routable IP (the box's
92
+ * address on a network the front can reach) is the scoped choice; the
93
+ * wildcards `0.0.0.0`/`::` bind EVERY interface and expose every published
94
+ * port of every sandbox on that daemon to every network it touches, so in
95
+ * `NODE_ENV=production` they are refused unless `MOL_DOCKER_ALLOW_PUBLIC_BINDS=1`
96
+ * is set (honored with a loud warning). A wildcard bind on a remote daemon is
97
+ * reported as the daemon's own host.
98
+ *
99
+ * Exposure is the operator's contract either way: whatever address this
100
+ * names must be reachable by the front and FIREWALLED against everything
101
+ * else — binding a port is a deliberate exposure decision, not a default.
102
+ */
103
+ publishHostIp?: string
84
104
  /**
85
105
  * Docker repository holding sandbox TEMPLATES — one tag per template id.
86
106
  * Defaults to `molecule-sandbox-template` (or `SANDBOX_TEMPLATE_REPOSITORY`).
@@ -132,6 +152,8 @@ interface ProcessEnv {
132
152
  DOCKER_HOST?: string
133
153
  /** Docker network sandbox containers attach to (default `molecule-sandbox`, ICC-off). Overridden by `config.network`. [C1-1] */
134
154
  SANDBOX_DOCKER_NETWORK?: string
155
+ /** Interface published ports bind to on the daemon's host (default `127.0.0.1`, loopback-only). Overridden by `config.publishHostIp`. */
156
+ DOCKER_PUBLISH_HOST_IP?: string
135
157
  /** Docker repository holding sandbox templates (default `molecule-sandbox-template`). Overridden by `config.templateRepository`. */
136
158
  SANDBOX_TEMPLATE_REPOSITORY?: string
137
159
  /** Registry host for sharing templates across hosts. Overridden by `config.templateRegistry`. */
@@ -180,7 +202,7 @@ duplicate container. If `onRetry` returns non-null, that value is used and the
180
202
  operation is not re-issued.
181
203
 
182
204
  ```typescript
183
- function withTransientRetry(
205
+ function withTransientRetry<T>(
184
206
  op: () => Promise<T>,
185
207
  opts: {
186
208
  label: string
@@ -276,6 +298,9 @@ proxy. When the plain-TCP endpoint comes from the ambient `DOCKER_HOST` env
276
298
  (not explicit config) and `NODE_ENV=production`, the provider REFUSES to
277
299
  connect — exec payloads would cross the network unencrypted — unless
278
300
  `MOL_DOCKER_ALLOW_PLAIN_TCP=1` is set, which is honored with a loud warning.
301
+ Published ports bind loopback by default; a remote daemon used for hosting
302
+ names a routable interface with `config.publishHostIp` / `DOCKER_PUBLISH_HOST_IP`
303
+ (wildcard binds need `MOL_DOCKER_ALLOW_PUBLIC_BINDS=1` in production).
279
304
  Caller-supplied container ids are shape-validated (`[A-Za-z0-9][A-Za-z0-9_.:-]*`,
280
305
  so Docker hex ids, names, and fly-style `app:machine` ids pass) before they are
281
306
  interpolated into Docker API paths. The provider never pulls images, so `create()` fails with a no-such-image
package/dist/index.d.ts CHANGED
@@ -21,6 +21,9 @@
21
21
  * (not explicit config) and `NODE_ENV=production`, the provider REFUSES to
22
22
  * connect — exec payloads would cross the network unencrypted — unless
23
23
  * `MOL_DOCKER_ALLOW_PLAIN_TCP=1` is set, which is honored with a loud warning.
24
+ * Published ports bind loopback by default; a remote daemon used for hosting
25
+ * names a routable interface with `config.publishHostIp` / `DOCKER_PUBLISH_HOST_IP`
26
+ * (wildcard binds need `MOL_DOCKER_ALLOW_PUBLIC_BINDS=1` in production).
24
27
  * Caller-supplied container ids are shape-validated (`[A-Za-z0-9][A-Za-z0-9_.:-]*`,
25
28
  * so Docker hex ids, names, and fly-style `app:machine` ids pass) before they are
26
29
  * interpolated into Docker API paths. The provider never pulls images, so `create()` fails with a no-such-image
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2FG;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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8FG;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
@@ -21,6 +21,9 @@
21
21
  * (not explicit config) and `NODE_ENV=production`, the provider REFUSES to
22
22
  * connect — exec payloads would cross the network unencrypted — unless
23
23
  * `MOL_DOCKER_ALLOW_PLAIN_TCP=1` is set, which is honored with a loud warning.
24
+ * Published ports bind loopback by default; a remote daemon used for hosting
25
+ * names a routable interface with `config.publishHostIp` / `DOCKER_PUBLISH_HOST_IP`
26
+ * (wildcard binds need `MOL_DOCKER_ALLOW_PUBLIC_BINDS=1` in production).
24
27
  * Caller-supplied container ids are shape-validated (`[A-Za-z0-9][A-Za-z0-9_.:-]*`,
25
28
  * so Docker hex ids, names, and fly-style `app:machine` ids pass) before they are
26
29
  * interpolated into Docker API paths. The provider never pulls images, so `create()` fails with a no-such-image
package/dist/inspect.d.ts CHANGED
@@ -30,6 +30,13 @@ export interface InspectContext {
30
30
  labelPrefix: string;
31
31
  warn?: (message: string, meta?: Record<string, unknown>) => void;
32
32
  debug?: (message: string, meta?: Record<string, unknown>) => void;
33
+ /**
34
+ * Where a port bound to EVERY interface is actually reachable, when that is
35
+ * somewhere other than this process's loopback — the host of a remote
36
+ * (`tcp://`) daemon. Unset for a local daemon, whose wildcard binds answer
37
+ * on loopback and nowhere the caller can name.
38
+ */
39
+ advertisedHost?: string;
33
40
  }
34
41
  /** Shape of the fields this module reads from `GET /containers/{id}/json`. */
35
42
  interface ContainerInspect {
@@ -1 +1 @@
1
- {"version":3,"file":"inspect.d.ts","sourceRoot":"","sources":["../src/inspect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EACV,kBAAkB,EAElB,iBAAiB,EACjB,YAAY,EACZ,gBAAgB,EAChB,UAAU,EACX,MAAM,4BAA4B,CAAA;AAEnC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAEjD,+DAA+D;AAC/D,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,aAAa,CAAA;IACtB,uEAAuE;IACvE,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;IAChE,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;CAClE;AASD,8EAA8E;AAC9E,UAAU,gBAAgB;IACxB,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IACjD,MAAM,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAC5D,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAChD,eAAe,CAAC,EAAE;QAChB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;YAAE,MAAM,CAAC,EAAE,MAAM,CAAC;YAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG,IAAI,CAAC,GAAG,IAAI,CAAA;KACpF,CAAA;CACF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,gBAAgB,GAAG,iBAAiB,CA2B3F;AAED;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,cAAc,EACnB,EAAE,EAAE,MAAM,GACT,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAYnC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,cAAc,CAClC,GAAG,EAAE,cAAc,EACnB,KAAK,CAAC,EAAE,YAAY,GACnB,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAwB9B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,cAAc,EACnB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,UAAU,EAAE,CAAC,CAkCvB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,wBAAwB,CAC5C,GAAG,EAAE,cAAc,EACnB,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,OAAO,CAAC,gBAAgB,CAAC,GACnC,OAAO,CAAC,IAAI,CAAC,CAqBf"}
1
+ {"version":3,"file":"inspect.d.ts","sourceRoot":"","sources":["../src/inspect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EACV,kBAAkB,EAElB,iBAAiB,EACjB,YAAY,EACZ,gBAAgB,EAChB,UAAU,EACX,MAAM,4BAA4B,CAAA;AAEnC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAEjD,+DAA+D;AAC/D,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,aAAa,CAAA;IACtB,uEAAuE;IACvE,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;IAChE,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;IACjE;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AASD,8EAA8E;AAC9E,UAAU,gBAAgB;IACxB,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IACjD,MAAM,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAC5D,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAChD,eAAe,CAAC,EAAE;QAChB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;YAAE,MAAM,CAAC,EAAE,MAAM,CAAC;YAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG,IAAI,CAAC,GAAG,IAAI,CAAA;KACpF,CAAA;CACF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,gBAAgB,GAAG,iBAAiB,CAoC3F;AAED;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,cAAc,EACnB,EAAE,EAAE,MAAM,GACT,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAYnC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,cAAc,CAClC,GAAG,EAAE,cAAc,EACnB,KAAK,CAAC,EAAE,YAAY,GACnB,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAwB9B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,cAAc,EACnB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,UAAU,EAAE,CAAC,CAkCvB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,wBAAwB,CAC5C,GAAG,EAAE,cAAc,EACnB,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,OAAO,CAAC,gBAAgB,CAAC,GACnC,OAAO,CAAC,IAAI,CAAC,CAqBf"}
package/dist/inspect.js CHANGED
@@ -53,7 +53,16 @@ export function toDescriptor(ctx, info) {
53
53
  const port = Number.parseInt(binding.HostPort ?? '', 10);
54
54
  if (!Number.isInteger(port) || port <= 0)
55
55
  continue;
56
- ports.push({ sandboxPort, host: binding.HostIp || '127.0.0.1', port });
56
+ // A bind on every interface has no single address of its own: it is
57
+ // reachable wherever the daemon's host is reachable, which the context
58
+ // names for a remote daemon and which is loopback for a local one.
59
+ const hostIp = binding.HostIp;
60
+ const bindAll = !hostIp || hostIp === '0.0.0.0' || hostIp === '::';
61
+ ports.push({
62
+ sandboxPort,
63
+ host: bindAll ? (ctx.advertisedHost ?? '127.0.0.1') : hostIp,
64
+ port,
65
+ });
57
66
  }
58
67
  }
59
68
  return {
@@ -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;AA4K9C;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAQ9D;AAyED;;;;;;;;;;;;;;;;;;;;;;;;;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;AAg9CD;;;;;;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;AASH,OAAO,KAAK,EAeV,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;AAyED;;;;;;;;;;;;;;;;;;;;;;;;;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;AA0hDD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG,eAAe,CAErE"}
package/dist/provider.js CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { readFile, statfs } from 'fs/promises';
10
10
  import http from 'http';
11
+ import net from 'net';
11
12
  import { freemem } from 'os';
12
13
  import { Readable } from 'stream';
13
14
  import { getLogger } from '@molecule/api-bond';
@@ -318,6 +319,20 @@ export async function withTransientRetry(op, opts) {
318
319
  */
319
320
  class DockerSandboxProvider {
320
321
  name = 'docker';
322
+ /**
323
+ * Containers this provider runs live until they are destroyed (optionally
324
+ * surviving crashes via `SandboxConfig.restartPolicy`) and every published
325
+ * port is host-mapped at an address `describe()` reports — the two things a
326
+ * long-lived hosting role needs, which is why this provider is selectable
327
+ * for it. A LOCAL daemon's default loopback bind is routable only from the
328
+ * control plane's own host, which is exactly what a co-located front needs;
329
+ * a remote daemon needs `publishHostIp` to name an address the front can
330
+ * reach.
331
+ */
332
+ hostingCapabilities = {
333
+ longLived: true,
334
+ routable: true,
335
+ };
321
336
  /** Resolved daemon endpoint (unix socket or TCP host:port); see {@link resolveDockerEndpoint}. */
322
337
  endpoint;
323
338
  baseImage;
@@ -353,6 +368,10 @@ class DockerSandboxProvider {
353
368
  endpointSafetyChecked = false;
354
369
  /** Whether the plain-TCP override warning has fired (once per provider). */
355
370
  plainTcpOverrideWarned = false;
371
+ /** Explicit publish bind from `config.publishHostIp`, if any; env is consulted at create time. */
372
+ configPublishHostIp;
373
+ /** Whether the wildcard-bind warning has fired (once per provider). */
374
+ publicBindOverrideWarned = false;
356
375
  constructor(config = {}) {
357
376
  const resolved = resolveDockerEndpoint(config);
358
377
  this.endpoint = resolved.endpoint;
@@ -363,6 +382,7 @@ class DockerSandboxProvider {
363
382
  this.defaultCpu = config.defaultCpu ?? 1;
364
383
  this.defaultMemoryMB = config.defaultMemoryMB ?? 1024;
365
384
  this.configNetwork = config.network;
385
+ this.configPublishHostIp = config.publishHostIp;
366
386
  this.configTemplateRepository = config.templateRepository;
367
387
  this.configTemplateRegistry = config.templateRegistry;
368
388
  this.configTemplateRegistryAuth = config.templateRegistryAuth;
@@ -404,8 +424,50 @@ class DockerSandboxProvider {
404
424
  labelPrefix: this.labelPrefix,
405
425
  warn: (message, meta) => logger.warn(message, meta),
406
426
  debug: (message, meta) => logger.debug(message, meta),
427
+ // A wildcard port bind on a REMOTE daemon is reachable at the daemon's
428
+ // own host, not at the control plane's loopback — that is the address a
429
+ // front needs. A local daemon leaves this unset, so a wildcard there
430
+ // still reports loopback.
431
+ advertisedHost: 'host' in this.endpoint ? this.endpoint.host : undefined,
407
432
  };
408
433
  }
434
+ /**
435
+ * Resolve the interface published ports bind to on the daemon's host.
436
+ * Precedence: `config.publishHostIp` (captured at construction) →
437
+ * `DOCKER_PUBLISH_HOST_IP` env (read here, at create time) → loopback.
438
+ *
439
+ * @returns the bind address for `HostConfig.PortBindings`.
440
+ * @throws {Error} when the value is not an IP address, or is a wildcard
441
+ * (`0.0.0.0`/`::`) while `NODE_ENV=production` without
442
+ * `MOL_DOCKER_ALLOW_PUBLIC_BINDS=1` — a wildcard publishes every port of
443
+ * every sandbox on that daemon to every network it touches, and that is an
444
+ * exposure someone must accept by name, never inherit by default.
445
+ */
446
+ resolvePublishHostIp() {
447
+ const raw = this.configPublishHostIp ?? process.env.DOCKER_PUBLISH_HOST_IP ?? '127.0.0.1';
448
+ const value = raw.trim();
449
+ if (net.isIP(value) === 0) {
450
+ throw new Error(`DOCKER_PUBLISH_HOST_IP/publishHostIp must be an IP address (got ${JSON.stringify(raw)}); ` +
451
+ 'a hostname would need Docker to resolve it at bind time, when it may answer differently.');
452
+ }
453
+ const wildcard = value === '0.0.0.0' || value === '::';
454
+ if (wildcard && process.env.NODE_ENV === 'production') {
455
+ const override = process.env.MOL_DOCKER_ALLOW_PUBLIC_BINDS;
456
+ if (override !== '1' && override !== 'true') {
457
+ throw new Error('Publishing sandbox ports on every interface (0.0.0.0/::) while NODE_ENV=production — ' +
458
+ 'refusing. Bind a specific IP the front can reach, or set ' +
459
+ 'MOL_DOCKER_ALLOW_PUBLIC_BINDS=1 to explicitly accept exposing every published port ' +
460
+ 'of every sandbox on this daemon.');
461
+ }
462
+ if (!this.publicBindOverrideWarned) {
463
+ this.publicBindOverrideWarned = true;
464
+ logger.warn('Sandbox ports bind EVERY interface (MOL_DOCKER_ALLOW_PUBLIC_BINDS accepted) — ' +
465
+ 'every published port of every sandbox on this daemon is reachable from every network ' +
466
+ 'it touches. Firewall the host so only the front can dial them.');
467
+ }
468
+ }
469
+ return value;
470
+ }
409
471
  /**
410
472
  * Resolve the Docker network to attach containers to. Precedence:
411
473
  * `config.network` (captured at construction) → `SANDBOX_DOCKER_NETWORK` env
@@ -489,6 +551,7 @@ class DockerSandboxProvider {
489
551
  image = template.ref;
490
552
  }
491
553
  const publishPorts = resolvePublishPorts(config.publishPorts);
554
+ const publishHostIp = this.resolvePublishHostIp();
492
555
  const env = Object.entries(config.env ?? {}).map(([k, v]) => `${k}=${v}`);
493
556
  const labels = {
494
557
  [`${this.labelPrefix}.projectId`]: config.projectId,
@@ -523,10 +586,13 @@ class DockerSandboxProvider {
523
586
  // bridge. Egress filtering must be enforced host-side, not in a tenant-rooted
524
587
  // container.
525
588
  CapAdd: ['CHOWN', 'SETGID', 'SETUID'],
526
- // Bind ports to localhost only — prevents external access. Which ports is
527
- // the caller's business (`config.publishPorts`); the historical
528
- // API + Vite pair is the default so existing callers are unaffected.
529
- PortBindings: Object.fromEntries(publishPorts.map((port) => [`${port}/tcp`, [{ HostIp: '127.0.0.1', HostPort: '' }]])),
589
+ // Bind published ports to a chosen interface — loopback by default, so
590
+ // nothing outside the host reaches a sandbox port. Which ports is the
591
+ // caller's business (`config.publishPorts`); the historical API + Vite
592
+ // pair is the default so existing callers are unaffected. The interface
593
+ // is `resolvePublishHostIp()`'s: loopback until an operator names an
594
+ // address their front can reach (a remote-hosting daemon).
595
+ PortBindings: Object.fromEntries(publishPorts.map((port) => [`${port}/tcp`, [{ HostIp: publishHostIp, HostPort: '' }]])),
530
596
  Tmpfs: { '/tmp': 'rw,noexec,nosuid,size=256m' },
531
597
  ReadonlyPaths: [
532
598
  '/proc/asound',
package/dist/types.d.ts CHANGED
@@ -42,6 +42,26 @@ export interface DockerConfig {
42
42
  labelPrefix?: string;
43
43
  /** Preview URL template. Use {port} placeholder. */
44
44
  previewUrlTemplate?: string;
45
+ /**
46
+ * The interface published sandbox ports bind to on the daemon's host.
47
+ * Defaults to `127.0.0.1` (loopback — nothing outside the host reaches a
48
+ * sandbox port), or the `DOCKER_PUBLISH_HOST_IP` env var.
49
+ *
50
+ * The one knob that makes a REMOTE daemon (`host`/`DOCKER_HOST`) usable for
51
+ * hosting: the control plane's front proxies to the address `describe()`
52
+ * reports, and that address is this bind. A specific routable IP (the box's
53
+ * address on a network the front can reach) is the scoped choice; the
54
+ * wildcards `0.0.0.0`/`::` bind EVERY interface and expose every published
55
+ * port of every sandbox on that daemon to every network it touches, so in
56
+ * `NODE_ENV=production` they are refused unless `MOL_DOCKER_ALLOW_PUBLIC_BINDS=1`
57
+ * is set (honored with a loud warning). A wildcard bind on a remote daemon is
58
+ * reported as the daemon's own host.
59
+ *
60
+ * Exposure is the operator's contract either way: whatever address this
61
+ * names must be reachable by the front and FIREWALLED against everything
62
+ * else — binding a port is a deliberate exposure decision, not a default.
63
+ */
64
+ publishHostIp?: string;
45
65
  /**
46
66
  * Docker repository holding sandbox TEMPLATES — one tag per template id.
47
67
  * Defaults to `molecule-sandbox-template` (or `SANDBOX_TEMPLATE_REPOSITORY`).
@@ -88,6 +108,8 @@ export interface ProcessEnv {
88
108
  DOCKER_HOST?: string;
89
109
  /** Docker network sandbox containers attach to (default `molecule-sandbox`, ICC-off). Overridden by `config.network`. [C1-1] */
90
110
  SANDBOX_DOCKER_NETWORK?: string;
111
+ /** Interface published ports bind to on the daemon's host (default `127.0.0.1`, loopback-only). Overridden by `config.publishHostIp`. */
112
+ DOCKER_PUBLISH_HOST_IP?: string;
91
113
  /** Docker repository holding sandbox templates (default `molecule-sandbox-template`). Overridden by `config.templateRepository`. */
92
114
  SANDBOX_TEMPLATE_REPOSITORY?: string;
93
115
  /** Registry host for sharing templates across hosts. Overridden by `config.templateRegistry`. */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,sHAAsH;IACtH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,0IAA0I;IAC1I,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sCAAsC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,sCAAsC;IACtC,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,iDAAiD;IACjD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,oDAAoD;IACpD,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B;;;;;;;;OAQG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAA;CAC9B;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAClD;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,kHAAkH;IAClH,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,wJAAwJ;IACxJ,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gIAAgI;IAChI,sBAAsB,CAAC,EAAE,MAAM,CAAA;IAC/B,oIAAoI;IACpI,2BAA2B,CAAC,EAAE,MAAM,CAAA;IACpC,iGAAiG;IACjG,yBAAyB,CAAC,EAAE,MAAM,CAAA;IAClC,mHAAmH;IACnH,8BAA8B,CAAC,EAAE,MAAM,CAAA;CACxC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,sHAAsH;IACtH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,0IAA0I;IAC1I,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sCAAsC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,sCAAsC;IACtC,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,iDAAiD;IACjD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,oDAAoD;IACpD,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;;;;;OAQG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAA;CAC9B;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAClD;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,kHAAkH;IAClH,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,wJAAwJ;IACxJ,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gIAAgI;IAChI,sBAAsB,CAAC,EAAE,MAAM,CAAA;IAC/B,yIAAyI;IACzI,sBAAsB,CAAC,EAAE,MAAM,CAAA;IAC/B,oIAAoI;IACpI,2BAA2B,CAAC,EAAE,MAAM,CAAA;IACpC,iGAAiG;IACjG,yBAAyB,CAAC,EAAE,MAAM,CAAA;IAClC,mHAAmH;IACnH,8BAA8B,CAAC,EAAE,MAAM,CAAA;CACxC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-code-sandbox-docker",
3
- "version": "1.1.5",
3
+ "version": "1.2.0",
4
4
  "description": "Docker-based code sandbox container provider",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,8 +29,8 @@
29
29
  ],
30
30
  "license": "Apache-2.0",
31
31
  "devDependencies": {
32
- "@molecule/api-bond": "1.0.3",
33
- "@molecule/api-code-sandbox": "1.4.1",
32
+ "@molecule/api-bond": "1.0.4",
33
+ "@molecule/api-code-sandbox": "1.5.0",
34
34
  "@types/node": "26.6.5",
35
35
  "typescript": "6.0.3",
36
36
  "vitest": "4.1.12"