@molecule/api-code-sandbox-docker 1.0.2 → 1.1.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.
@@ -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;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"}
package/dist/provider.js CHANGED
@@ -508,6 +508,15 @@ class DockerSandboxProvider {
508
508
  labels[`${this.labelPrefix}.volumeName`] = config.volumeName;
509
509
  hostConfig.Binds = [`${config.volumeName}:/workspace`];
510
510
  }
511
+ // What the container does when its main process exits. Docker's default is
512
+ // `no`, matching this provider's historical behaviour: a sandbox whose
513
+ // process exits stays stopped for the caller to inspect. A caller running
514
+ // something long-lived (a production instance) asks for `always`/`on-failure`
515
+ // so the workload comes back after a crash or a daemon restart without a
516
+ // control plane in the loop.
517
+ if (config.restartPolicy && config.restartPolicy !== 'no') {
518
+ hostConfig.RestartPolicy = { Name: config.restartPolicy };
519
+ }
511
520
  const body = {
512
521
  Image: image,
513
522
  Env: env,
@@ -515,6 +524,13 @@ class DockerSandboxProvider {
515
524
  HostConfig: hostConfig,
516
525
  ExposedPorts: Object.fromEntries(publishPorts.map((port) => [`${port}/tcp`, {}])),
517
526
  };
527
+ // The caller's own main process, replacing the image's CMD. Without it a
528
+ // container that Docker restarts comes back running the image's idle command
529
+ // and serving nothing, while `status` still reports it as running — which it
530
+ // is, and empty. `Cmd` (not `Entrypoint`) so the image's entrypoint — tini,
531
+ // enabled above via `Init` — still wraps it and reaps its children.
532
+ if (config.command?.length)
533
+ body.Cmd = [...config.command];
518
534
  // Retry a transient create timeout (daemon momentarily overwhelmed under
519
535
  // concurrent boots) instead of failing the whole sandbox boot. The onRetry
520
536
  // guard adopts a container a timed-out create may have already made — keyed on
@@ -823,17 +839,32 @@ class DockerSandboxProvider {
823
839
  // (e.g. a per-sandbox subdomain `sb-{port}.preview.localhost:{port}`).
824
840
  return provider.previewUrlTemplate.replace(/\{port\}/g, String(port ?? 5173));
825
841
  },
842
+ /**
843
+ * Spawn a long-running process with streaming I/O, optionally on a PTY.
844
+ *
845
+ * `opts.pty` asks Docker for `Tty: true`, which gives the process a real
846
+ * controlling terminal: Ctrl-C (`0x03`) becomes SIGINT for the foreground
847
+ * job and the width can be renegotiated through `handle.resize()`. A TTY
848
+ * exec stream is NOT multiplexed, so the frame parser is bypassed and
849
+ * everything (stderr included, as on a real terminal) arrives on stdout.
850
+ *
851
+ * @param command - The command to run.
852
+ * @param opts - Working directory, env, and optional PTY size.
853
+ * @returns A handle with streaming I/O, stdin, kill, and (on a PTY) resize.
854
+ */
826
855
  async spawn(command, opts) {
856
+ const tty = Boolean(opts?.pty);
827
857
  const execCreate = (await provider.dockerApi(`/containers/${containerId}/exec`, 'POST', {
828
858
  Cmd: ['sh', '-c', command],
829
859
  AttachStdin: true,
830
860
  AttachStdout: true,
831
861
  AttachStderr: true,
832
- Tty: false,
862
+ Tty: tty,
863
+ ...(opts?.pty ? { ConsoleSize: [opts.pty.rows, opts.pty.cols] } : {}),
833
864
  WorkingDir: opts?.cwd ?? '/workspace',
834
865
  Env: opts?.env ? Object.entries(opts.env).map(([k, v]) => `${k}=${v}`) : undefined,
835
866
  }));
836
- const socket = await provider.dockerExecUpgrade(execCreate.Id);
867
+ const socket = await provider.dockerExecUpgrade(execCreate.Id, tty);
837
868
  const parser = new DockerMuxParser();
838
869
  let closeCb = null;
839
870
  // Kill idle spawn sockets after 10 minutes to prevent descriptor leaks
@@ -841,7 +872,17 @@ class DockerSandboxProvider {
841
872
  socket.on('timeout', () => {
842
873
  socket.destroy();
843
874
  });
875
+ // A TTY exec stream carries raw terminal bytes — there are no 8-byte
876
+ // frame headers to strip, and feeding them to the mux parser would read
877
+ // the first byte of the shell's output as a stream descriptor.
878
+ const ttyListeners = [];
844
879
  socket.on('data', (chunk) => {
880
+ if (tty) {
881
+ const text = chunk.toString('utf8');
882
+ for (const cb of ttyListeners)
883
+ cb(text);
884
+ return;
885
+ }
845
886
  try {
846
887
  parser.feed(chunk);
847
888
  }
@@ -867,10 +908,16 @@ class DockerSandboxProvider {
867
908
  socket.write(data);
868
909
  },
869
910
  onStdout(cb) {
870
- parser.onStdout(cb);
911
+ if (tty)
912
+ ttyListeners.push(cb);
913
+ else
914
+ parser.onStdout(cb);
871
915
  },
872
916
  onStderr(cb) {
873
- parser.onStderr(cb);
917
+ // On a TTY both streams share one descriptor, so nothing arrives
918
+ // here — registering is harmless and keeps the handle uniform.
919
+ if (!tty)
920
+ parser.onStderr(cb);
874
921
  },
875
922
  onClose(cb) {
876
923
  closeCb = cb;
@@ -879,6 +926,22 @@ class DockerSandboxProvider {
879
926
  if (!socket.destroyed)
880
927
  socket.destroy();
881
928
  },
929
+ ...(tty
930
+ ? {
931
+ resize(size) {
932
+ void provider
933
+ .dockerApi(`/exec/${execCreate.Id}/resize?h=${Math.max(1, Math.floor(size.rows))}&w=${Math.max(1, Math.floor(size.cols))}`, 'POST')
934
+ .catch((error) => {
935
+ // Best-effort: a resize races the exec exiting, and a
936
+ // failed one only leaves the remote width stale — never a
937
+ // reason to tear down a live session.
938
+ logger.debug('Spawn resize failed', {
939
+ error: error instanceof Error ? error.message : error,
940
+ });
941
+ });
942
+ },
943
+ }
944
+ : {}),
882
945
  };
883
946
  },
884
947
  async hibernate() {
@@ -1311,11 +1374,14 @@ class DockerSandboxProvider {
1311
1374
  * bidirectional socket. Docker hijacks the connection (101 Switching Protocols)
1312
1375
  * when stdin is attached, giving us a raw TCP socket for streaming I/O.
1313
1376
  * @param execId - The exec instance ID from the create step.
1377
+ * @param tty - Whether the exec was created with a TTY; the start call must
1378
+ * agree, or Docker streams multiplexed frames to a terminal expecting raw
1379
+ * bytes.
1314
1380
  * @returns The raw socket for stdin writes and multiplexed stdout/stderr reads.
1315
1381
  */
1316
- dockerExecUpgrade(execId) {
1382
+ dockerExecUpgrade(execId, tty = false) {
1317
1383
  return new Promise((resolve, reject) => {
1318
- const body = JSON.stringify({ Detach: false, Tty: false });
1384
+ const body = JSON.stringify({ Detach: false, Tty: tty });
1319
1385
  const req = http.request({
1320
1386
  ...this.endpoint,
1321
1387
  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.0",
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
36
  "vitest": "4.1.10"
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": {