@molecule/api-code-sandbox-docker 1.0.1 → 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.
package/dist/provider.js CHANGED
@@ -6,10 +6,19 @@
6
6
  *
7
7
  * @module
8
8
  */
9
+ import { readFile, statfs } from 'fs/promises';
9
10
  import http from 'http';
11
+ import { freemem } from 'os';
12
+ import { Readable } from 'stream';
10
13
  import { getLogger } from '@molecule/api-bond';
11
14
  import { t } from '@molecule/api-i18n';
12
15
  const logger = getLogger();
16
+ import { exportContainerFiles, importContainerFiles } from './archive.js';
17
+ import { measureDockerCapacity } from './capacity.js';
18
+ import { verifyDockerEgress } from './egress.js';
19
+ import { hibernateContainer, resumeContainer } from './hibernate.js';
20
+ import { describeContainer, findContainers, listDockerVolumes, updateContainerResources, } from './inspect.js';
21
+ import * as templates from './templates.js';
13
22
  const DEFAULT_IMAGE = 'node:22-slim';
14
23
  /**
15
24
  * Shell-safe single-quote escaping for file paths. Prevents command injection
@@ -34,6 +43,17 @@ const LABEL_PREFIX = 'molecule-sandbox';
34
43
  const DEFAULT_SANDBOX_NETWORK = 'molecule-sandbox';
35
44
  /** Default Docker daemon TCP port for a plain (unencrypted) `tcp://` endpoint. */
36
45
  const DEFAULT_DOCKER_TCP_PORT = 2375;
46
+ /**
47
+ * Repository that holds sandbox templates, kept separate from the base image's
48
+ * repository so a template sweep can never reach `molecule-sandbox:latest`.
49
+ */
50
+ const DEFAULT_TEMPLATE_REPOSITORY = 'molecule-sandbox-template';
51
+ /** `X-Registry-Auth` for an anonymous or already-logged-in daemon. */
52
+ const EMPTY_REGISTRY_AUTH = Buffer.from('{}').toString('base64');
53
+ /** Ports published for every sandbox when the caller names none. */
54
+ const DEFAULT_PUBLISH_PORTS = [4000, 5173];
55
+ /** Maximum processes a sandbox may hold when the caller sets no `pidsLimit`. */
56
+ const DEFAULT_PIDS_LIMIT = 512;
37
57
  /**
38
58
  * Parse a `DOCKER_HOST`-style value into an endpoint, honoring the docker-client
39
59
  * conventions: `unix:///path/to/docker.sock` selects a socket; `tcp://host:port`
@@ -135,6 +155,63 @@ class DockerMuxParser {
135
155
  }
136
156
  }
137
157
  }
158
+ /**
159
+ * Validate the ports a caller asked to publish, falling back to this provider's
160
+ * historical API + Vite pair when it named none.
161
+ *
162
+ * Rejects rather than filters: a caller that asked for a port it cannot have
163
+ * needs to hear so, because the alternative is a dev server running inside a
164
+ * sandbox that nothing on the outside can ever reach — which looks like a broken
165
+ * application, not a bad argument.
166
+ *
167
+ * @param ports - Ports inside the sandbox the caller wants reachable.
168
+ * @returns The ports to expose and bind.
169
+ * @throws {Error} When a port is not a valid TCP port number.
170
+ */
171
+ export function resolvePublishPorts(ports) {
172
+ if (!ports || ports.length === 0)
173
+ return DEFAULT_PUBLISH_PORTS;
174
+ for (const port of ports) {
175
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
176
+ throw new Error(`Invalid publish port ${String(port)}: must be an integer in 1-65535`);
177
+ }
178
+ }
179
+ return [...new Set(ports)];
180
+ }
181
+ /**
182
+ * Available memory in bytes, or `null` when it cannot be read.
183
+ *
184
+ * Prefers Linux `MemAvailable`, which is the kernel's estimate of what a new
185
+ * workload can use WITHOUT swapping and counts reclaimable page cache.
186
+ * `os.freemem()` excludes that cache and badly understates free memory on any
187
+ * warm host, so it is only the off-Linux fallback — using it as the primary
188
+ * signal rejects boots the host could comfortably serve.
189
+ *
190
+ * @returns Available bytes, or `null` when unreadable.
191
+ */
192
+ async function availableMemoryBytes() {
193
+ try {
194
+ const match = /^MemAvailable:\s+(\d+)\s+kB/m.exec(await readFile('/proc/meminfo', 'utf-8'));
195
+ if (match) {
196
+ const kb = Number.parseInt(match[1], 10);
197
+ if (Number.isFinite(kb) && kb > 0)
198
+ return kb * 1024;
199
+ }
200
+ }
201
+ catch (_error) {
202
+ // /proc/meminfo is absent on non-Linux hosts and unreadable in some
203
+ // sandboxes — fall through to the portable estimate rather than failing.
204
+ }
205
+ try {
206
+ const bytes = freemem();
207
+ return Number.isFinite(bytes) && bytes > 0 ? bytes : null;
208
+ }
209
+ catch (_error) {
210
+ // No usable memory reading at all — report "unknown" so the caller does not
211
+ // mistake it for "exhausted".
212
+ return null;
213
+ }
214
+ }
138
215
  /**
139
216
  * Retry a Docker API operation that failed with a TRANSIENT fault — a request
140
217
  * timeout or a connection-level reset, i.e. the daemon was momentarily overwhelmed,
@@ -209,6 +286,20 @@ class DockerSandboxProvider {
209
286
  configNetwork;
210
287
  /** [C1-1] Memoize the one-time ICC-off network ensure so create() pays it only once. */
211
288
  networkEnsured = false;
289
+ /** Explicit template repository from config; env is consulted at call time. */
290
+ configTemplateRepository;
291
+ /** Explicit template registry from config; env is consulted at call time. */
292
+ configTemplateRegistry;
293
+ /** Explicit registry credential from config; env is consulted at call time. */
294
+ configTemplateRegistryAuth;
295
+ /**
296
+ * Whether this host still looks capable of CRIU checkpoints. Shared by
297
+ * `hibernate` and `resume` for every sandbox this provider hands out, so a host
298
+ * without CRIU pays one rejected request per process instead of one per
299
+ * hibernation. Held on the instance rather than at module scope so two
300
+ * providers (and two tests) do not inherit each other's verdict.
301
+ */
302
+ checkpoints = { supported: true };
212
303
  constructor(config = {}) {
213
304
  this.endpoint = resolveDockerEndpoint(config);
214
305
  this.baseImage = config.baseImage ?? DEFAULT_IMAGE;
@@ -217,6 +308,48 @@ class DockerSandboxProvider {
217
308
  this.defaultCpu = config.defaultCpu ?? 1;
218
309
  this.defaultMemoryMB = config.defaultMemoryMB ?? 1024;
219
310
  this.configNetwork = config.network;
311
+ this.configTemplateRepository = config.templateRepository;
312
+ this.configTemplateRegistry = config.templateRegistry;
313
+ this.configTemplateRegistryAuth = config.templateRegistryAuth;
314
+ }
315
+ /**
316
+ * Assemble the context every template operation runs against. Env is read here
317
+ * rather than in the constructor, matching {@link resolveNetwork}: this
318
+ * provider is commonly constructed before the process has loaded its
319
+ * environment.
320
+ *
321
+ * @returns The template context.
322
+ */
323
+ templateContext() {
324
+ return {
325
+ request: (path, method, body, timeoutMs, headers) => this.dockerApi(path, method, body, timeoutMs, headers),
326
+ download: (path, timeoutMs) => this.dockerDownload(path, timeoutMs),
327
+ upload: (path, body, timeoutMs) => this.dockerUpload(path, body, timeoutMs),
328
+ baseImage: this.baseImage,
329
+ repository: this.configTemplateRepository ??
330
+ process.env.SANDBOX_TEMPLATE_REPOSITORY ??
331
+ DEFAULT_TEMPLATE_REPOSITORY,
332
+ registry: this.configTemplateRegistry ?? process.env.SANDBOX_TEMPLATE_REGISTRY ?? '',
333
+ registryAuth: this.configTemplateRegistryAuth ??
334
+ process.env.SANDBOX_TEMPLATE_REGISTRY_AUTH ??
335
+ EMPTY_REGISTRY_AUTH,
336
+ labelPrefix: this.labelPrefix,
337
+ warn: (message, meta) => logger.warn(message, meta),
338
+ debug: (message, meta) => logger.debug(message, meta),
339
+ };
340
+ }
341
+ /**
342
+ * The context shared by the inspection capabilities.
343
+ *
344
+ * @returns The inspection context.
345
+ */
346
+ inspectContext() {
347
+ return {
348
+ request: (path, method, body, timeoutMs, headers) => this.dockerApi(path, method, body, timeoutMs, headers),
349
+ labelPrefix: this.labelPrefix,
350
+ warn: (message, meta) => logger.warn(message, meta),
351
+ debug: (message, meta) => logger.debug(message, meta),
352
+ };
220
353
  }
221
354
  /**
222
355
  * Resolve the Docker network to attach containers to. Precedence:
@@ -285,7 +418,22 @@ class DockerSandboxProvider {
285
418
  await this.ensureSandboxNetwork();
286
419
  const cpu = config.resources?.cpu ?? this.defaultCpu;
287
420
  const memoryMB = config.resources?.memoryMB ?? this.defaultMemoryMB;
288
- const image = config.image ?? this.baseImage;
421
+ let image = config.image ?? this.baseImage;
422
+ // Booting from a template is the caller naming a filesystem it captured. If
423
+ // it is gone, FAIL — falling back to the base image would hand back a
424
+ // healthy-looking sandbox whose contents are not the ones that were asked
425
+ // for, and nothing downstream would notice.
426
+ if (config.templateId) {
427
+ const template = await templates.getTemplate(this.templateContext(), config.templateId);
428
+ if (!template) {
429
+ throw new Error(t('codeSandbox.docker.error.templateMissing', { templateId: config.templateId }, {
430
+ defaultValue: `Cannot create a sandbox from template "${config.templateId}": it does not exist. ` +
431
+ 'Commit it first, or fetch it from the shared template store.',
432
+ }));
433
+ }
434
+ image = template.ref;
435
+ }
436
+ const publishPorts = resolvePublishPorts(config.publishPorts);
289
437
  const env = Object.entries(config.env ?? {}).map(([k, v]) => `${k}=${v}`);
290
438
  const labels = {
291
439
  [`${this.labelPrefix}.projectId`]: config.projectId,
@@ -311,7 +459,7 @@ class DockerSandboxProvider {
311
459
  // the Memory cap does not bound swap. [C1-2]
312
460
  MemorySwap: memoryBytes * 2,
313
461
  Init: true, // Use tini init to reap zombie processes
314
- PidsLimit: 512,
462
+ PidsLimit: config.resources?.pidsLimit ?? DEFAULT_PIDS_LIMIT,
315
463
  SecurityOpt: ['no-new-privileges'],
316
464
  CapDrop: ['ALL'],
317
465
  // Minimal caps for the dev toolchain (file ownership + tini privilege drop).
@@ -320,11 +468,10 @@ class DockerSandboxProvider {
320
468
  // bridge. Egress filtering must be enforced host-side, not in a tenant-rooted
321
469
  // container.
322
470
  CapAdd: ['CHOWN', 'SETGID', 'SETUID'],
323
- // Bind ports to localhost only — prevents external access
324
- PortBindings: {
325
- '4000/tcp': [{ HostIp: '127.0.0.1', HostPort: '' }],
326
- '5173/tcp': [{ HostIp: '127.0.0.1', HostPort: '' }],
327
- },
471
+ // Bind ports to localhost only — prevents external access. Which ports is
472
+ // the caller's business (`config.publishPorts`); the historical
473
+ // API + Vite pair is the default so existing callers are unaffected.
474
+ PortBindings: Object.fromEntries(publishPorts.map((port) => [`${port}/tcp`, [{ HostIp: '127.0.0.1', HostPort: '' }]])),
328
475
  Tmpfs: { '/tmp': 'rw,noexec,nosuid,size=256m' },
329
476
  ReadonlyPaths: [
330
477
  '/proc/asound',
@@ -361,16 +508,29 @@ class DockerSandboxProvider {
361
508
  labels[`${this.labelPrefix}.volumeName`] = config.volumeName;
362
509
  hostConfig.Binds = [`${config.volumeName}:/workspace`];
363
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
+ }
364
520
  const body = {
365
521
  Image: image,
366
522
  Env: env,
367
523
  Labels: labels,
368
524
  HostConfig: hostConfig,
369
- ExposedPorts: {
370
- '4000/tcp': {},
371
- '5173/tcp': {},
372
- },
525
+ ExposedPorts: Object.fromEntries(publishPorts.map((port) => [`${port}/tcp`, {}])),
373
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];
374
534
  // Retry a transient create timeout (daemon momentarily overwhelmed under
375
535
  // concurrent boots) instead of failing the whole sandbox boot. The onRetry
376
536
  // guard adopts a container a timed-out create may have already made — keyed on
@@ -679,17 +839,32 @@ class DockerSandboxProvider {
679
839
  // (e.g. a per-sandbox subdomain `sb-{port}.preview.localhost:{port}`).
680
840
  return provider.previewUrlTemplate.replace(/\{port\}/g, String(port ?? 5173));
681
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
+ */
682
855
  async spawn(command, opts) {
856
+ const tty = Boolean(opts?.pty);
683
857
  const execCreate = (await provider.dockerApi(`/containers/${containerId}/exec`, 'POST', {
684
858
  Cmd: ['sh', '-c', command],
685
859
  AttachStdin: true,
686
860
  AttachStdout: true,
687
861
  AttachStderr: true,
688
- Tty: false,
862
+ Tty: tty,
863
+ ...(opts?.pty ? { ConsoleSize: [opts.pty.rows, opts.pty.cols] } : {}),
689
864
  WorkingDir: opts?.cwd ?? '/workspace',
690
865
  Env: opts?.env ? Object.entries(opts.env).map(([k, v]) => `${k}=${v}`) : undefined,
691
866
  }));
692
- const socket = await provider.dockerExecUpgrade(execCreate.Id);
867
+ const socket = await provider.dockerExecUpgrade(execCreate.Id, tty);
693
868
  const parser = new DockerMuxParser();
694
869
  let closeCb = null;
695
870
  // Kill idle spawn sockets after 10 minutes to prevent descriptor leaks
@@ -697,7 +872,17 @@ class DockerSandboxProvider {
697
872
  socket.on('timeout', () => {
698
873
  socket.destroy();
699
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 = [];
700
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
+ }
701
886
  try {
702
887
  parser.feed(chunk);
703
888
  }
@@ -723,10 +908,16 @@ class DockerSandboxProvider {
723
908
  socket.write(data);
724
909
  },
725
910
  onStdout(cb) {
726
- parser.onStdout(cb);
911
+ if (tty)
912
+ ttyListeners.push(cb);
913
+ else
914
+ parser.onStdout(cb);
727
915
  },
728
916
  onStderr(cb) {
729
- 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);
730
921
  },
731
922
  onClose(cb) {
732
923
  closeCb = cb;
@@ -735,8 +926,64 @@ class DockerSandboxProvider {
735
926
  if (!socket.destroyed)
736
927
  socket.destroy();
737
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
+ : {}),
738
945
  };
739
946
  },
947
+ async hibernate() {
948
+ const outcome = await hibernateContainer({
949
+ request: (path, method, body, timeoutMs, headers) => provider.dockerApi(path, method, body, timeoutMs, headers),
950
+ checkpoints: provider.checkpoints,
951
+ warn: (message, meta) => logger.warn(message, meta),
952
+ info: (message, meta) => logger.info(message, meta),
953
+ }, containerId);
954
+ this.status = 'sleeping';
955
+ return outcome;
956
+ },
957
+ async resume() {
958
+ const outcome = await resumeContainer({
959
+ request: (path, method, body, timeoutMs, headers) => provider.dockerApi(path, method, body, timeoutMs, headers),
960
+ checkpoints: provider.checkpoints,
961
+ warn: (message, meta) => logger.warn(message, meta),
962
+ info: (message, meta) => logger.info(message, meta),
963
+ }, containerId);
964
+ this.status = 'running';
965
+ return outcome;
966
+ },
967
+ async setResources(resources) {
968
+ await updateContainerResources({
969
+ request: (path, method, body, timeoutMs, headers) => provider.dockerApi(path, method, body, timeoutMs, headers),
970
+ labelPrefix: provider.labelPrefix,
971
+ warn: (message, meta) => logger.warn(message, meta),
972
+ debug: (message, meta) => logger.debug(message, meta),
973
+ }, containerId, resources);
974
+ },
975
+ async exportFiles(path) {
976
+ return exportContainerFiles({
977
+ download: (p, timeoutMs) => provider.dockerDownload(p, timeoutMs),
978
+ upload: (p, body, timeoutMs) => provider.dockerUpload(p, body, timeoutMs),
979
+ }, containerId, path);
980
+ },
981
+ async importFiles(path, archive) {
982
+ await importContainerFiles({
983
+ download: (p, timeoutMs) => provider.dockerDownload(p, timeoutMs),
984
+ upload: (p, body, timeoutMs) => provider.dockerUpload(p, body, timeoutMs),
985
+ }, containerId, path, archive);
986
+ },
740
987
  onFileChange(_cb) {
741
988
  let active = true;
742
989
  const poll = async () => {
@@ -796,6 +1043,138 @@ class DockerSandboxProvider {
796
1043
  return false;
797
1044
  }
798
1045
  }
1046
+ /**
1047
+ * Prove whether the host actually denies sandbox egress.
1048
+ *
1049
+ * Delegates to {@link verifyDockerEgress}, which runs BOTH legs — outbound
1050
+ * (DOCKER-USER) and host-bound (INPUT). See that module for why one leg is not
1051
+ * enough: host-gateway traffic never traverses DOCKER-USER, so an outbound-only
1052
+ * check reports "filtered" on a host whose SSH, Redis, monitoring and admin
1053
+ * consoles are reachable from every sandbox.
1054
+ *
1055
+ * @returns What was observed about egress filtering.
1056
+ */
1057
+ async verifyEgress() {
1058
+ return verifyDockerEgress({
1059
+ request: (path, method, body, timeoutMs) => this.dockerApi(path, method, body, timeoutMs),
1060
+ network: this.resolveNetwork(),
1061
+ baseImage: this.baseImage,
1062
+ labelPrefix: this.labelPrefix,
1063
+ warn: (message, meta) => logger.warn(message, meta),
1064
+ });
1065
+ }
1066
+ // ---------------------------------------------------------------------------
1067
+ // Templates
1068
+ // ---------------------------------------------------------------------------
1069
+ /**
1070
+ * Capture a sandbox's filesystem as a reusable template.
1071
+ *
1072
+ * See `templates.ts` for why this is not a plain `commit`: a sandbox keeps the
1073
+ * project on a volume, and a volume is not part of a container's writable
1074
+ * layer — so committing the sandbox itself produces an image that boots
1075
+ * successfully with an empty workspace.
1076
+ *
1077
+ * @param options - What to capture and what to call it.
1078
+ * @returns The template as it now exists.
1079
+ */
1080
+ async commitTemplate(options) {
1081
+ return templates.commitTemplate(this.templateContext(), options);
1082
+ }
1083
+ /**
1084
+ * Read one template by the caller's identifier.
1085
+ *
1086
+ * @param templateId - The caller's identifier.
1087
+ * @returns The template, or `null` when no image carries that tag.
1088
+ */
1089
+ async getTemplate(templateId) {
1090
+ return templates.getTemplate(this.templateContext(), templateId);
1091
+ }
1092
+ /**
1093
+ * Enumerate templates in this provider's template repository.
1094
+ *
1095
+ * @param options - Narrowing by id prefix.
1096
+ * @returns Every matching template.
1097
+ */
1098
+ async listTemplates(options) {
1099
+ return templates.listTemplates(this.templateContext(), options);
1100
+ }
1101
+ /**
1102
+ * Delete a template, refusing while a container is still backed by it.
1103
+ *
1104
+ * @param templateId - The caller's identifier.
1105
+ */
1106
+ async removeTemplate(templateId) {
1107
+ return templates.removeTemplate(this.templateContext(), templateId);
1108
+ }
1109
+ /**
1110
+ * Push a template to the configured registry so other hosts can boot from it.
1111
+ *
1112
+ * @param templateId - The caller's identifier.
1113
+ */
1114
+ async publishTemplate(templateId) {
1115
+ return templates.publishTemplate(this.templateContext(), templateId);
1116
+ }
1117
+ /**
1118
+ * Pull a template from the configured registry onto this host.
1119
+ *
1120
+ * @param templateId - The caller's identifier.
1121
+ * @returns The now-local template, or `null` when the registry does not have it.
1122
+ */
1123
+ async fetchTemplate(templateId) {
1124
+ return templates.fetchTemplate(this.templateContext(), templateId);
1125
+ }
1126
+ // ---------------------------------------------------------------------------
1127
+ // Inspection
1128
+ // ---------------------------------------------------------------------------
1129
+ /**
1130
+ * Describe one sandbox without building a handle for it.
1131
+ *
1132
+ * @param id - The container id.
1133
+ * @returns The descriptor, or `null` when no such container exists.
1134
+ */
1135
+ async describe(id) {
1136
+ return describeContainer(this.inspectContext(), id);
1137
+ }
1138
+ /**
1139
+ * Find sandboxes by the labels the caller put on them.
1140
+ *
1141
+ * @param query - Narrowing by label, status and project.
1142
+ * @returns Descriptors for every match.
1143
+ */
1144
+ async find(query) {
1145
+ return findContainers(this.inspectContext(), query);
1146
+ }
1147
+ /**
1148
+ * Enumerate named volumes and whether anything still has them attached.
1149
+ *
1150
+ * @param options - Narrowing by name prefix and attachment.
1151
+ * @returns Every matching volume.
1152
+ */
1153
+ async listVolumes(options) {
1154
+ return listDockerVolumes(this.inspectContext(), options);
1155
+ }
1156
+ /**
1157
+ * Measure what the host backing this daemon can still fit.
1158
+ *
1159
+ * @returns Measured headroom and an explanation of anything unmeasurable.
1160
+ */
1161
+ async capacity() {
1162
+ return measureDockerCapacity({
1163
+ request: (path, method, body, timeoutMs, headers) => this.dockerApi(path, method, body, timeoutMs, headers),
1164
+ // Only a unix-socket daemon writes to storage this process can measure.
1165
+ daemonIsLocal: 'socketPath' in this.endpoint,
1166
+ statfs: async (path) => {
1167
+ const stats = await statfs(path);
1168
+ return {
1169
+ bsize: Number(stats.bsize),
1170
+ bavail: Number(stats.bavail),
1171
+ ffree: Number(stats.ffree),
1172
+ };
1173
+ },
1174
+ availableMemoryBytes,
1175
+ warn: (message, meta) => logger.warn(message, meta),
1176
+ });
1177
+ }
799
1178
  /**
800
1179
  * Makes an HTTP request to the Docker Engine API via the Unix socket.
801
1180
  * @param path - The API endpoint path (e.g. `/containers/create`).
@@ -806,15 +1185,20 @@ class DockerSandboxProvider {
806
1185
  * image's contents during those calls, which scales with image size — a
807
1186
  * multi-GB `/workspace` reliably exceeds 30 s, and a client-side timeout on
808
1187
  * a create that succeeds server-side leaks a duplicate container.
1188
+ * @param headers - Extra request headers. Registry operations need
1189
+ * `X-Registry-Auth`, which the daemon requires even for anonymous pulls.
809
1190
  * @returns The parsed JSON response, or raw text for non-JSON responses.
810
1191
  */
811
- async dockerApi(path, method = 'GET', body, timeoutMs = 30_000) {
1192
+ async dockerApi(path, method = 'GET', body, timeoutMs = 30_000, headers) {
812
1193
  return new Promise((resolve, reject) => {
813
1194
  const opts = {
814
1195
  ...this.endpoint,
815
1196
  path: `/v1.44${path}`,
816
1197
  method,
817
- headers: body ? { 'Content-Type': 'application/json' } : undefined,
1198
+ headers: {
1199
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
1200
+ ...headers,
1201
+ },
818
1202
  };
819
1203
  const req = http.request(opts, (res) => {
820
1204
  const chunks = [];
@@ -845,6 +1229,96 @@ class DockerSandboxProvider {
845
1229
  req.end();
846
1230
  });
847
1231
  }
1232
+ /**
1233
+ * Issue a Docker Engine API GET and hand back the response body as a byte
1234
+ * stream.
1235
+ *
1236
+ * Unbuffered on purpose: this carries whole project trees, and materializing
1237
+ * one in this process to hand it to the next call trades a slow transfer for
1238
+ * an out-of-memory API. The error path DOES buffer, because an error body is
1239
+ * a sentence and the caller needs to read it.
1240
+ *
1241
+ * @param path - The API endpoint path.
1242
+ * @param timeoutMs - Request timeout (default 10 min).
1243
+ * @returns The response body as an async byte stream.
1244
+ */
1245
+ dockerDownload(path, timeoutMs = 600_000) {
1246
+ return new Promise((resolve, reject) => {
1247
+ const req = http.request({ ...this.endpoint, path: `/v1.44${path}`, method: 'GET' }, (res) => {
1248
+ if (res.statusCode && res.statusCode >= 400) {
1249
+ const chunks = [];
1250
+ res.on('data', (chunk) => chunks.push(chunk));
1251
+ res.on('end', () => {
1252
+ reject(new Error(t('codeSandbox.docker.error.apiError', {
1253
+ method: 'GET',
1254
+ path,
1255
+ status: String(res.statusCode),
1256
+ error: Buffer.concat(chunks).toString(),
1257
+ }, {
1258
+ defaultValue: `Docker API GET ${path}: ${res.statusCode} ${Buffer.concat(chunks).toString()}`,
1259
+ })));
1260
+ });
1261
+ res.on('error', reject);
1262
+ return;
1263
+ }
1264
+ resolve(res);
1265
+ });
1266
+ req.setTimeout(timeoutMs, () => {
1267
+ req.destroy(new Error(`Docker API timeout: GET ${path}`));
1268
+ });
1269
+ req.on('error', reject);
1270
+ req.end();
1271
+ });
1272
+ }
1273
+ /**
1274
+ * Issue a Docker Engine API PUT whose request body is a byte stream.
1275
+ *
1276
+ * Piped rather than written in a loop so Node applies backpressure — without
1277
+ * it, a fast source fills this process's memory with everything the socket has
1278
+ * not drained yet, which for a project tree is the whole tree.
1279
+ *
1280
+ * @param path - The API endpoint path.
1281
+ * @param body - The request body as an async byte stream.
1282
+ * @param timeoutMs - Request timeout (default 10 min).
1283
+ */
1284
+ dockerUpload(path, body, timeoutMs = 600_000) {
1285
+ return new Promise((resolve, reject) => {
1286
+ const req = http.request({
1287
+ ...this.endpoint,
1288
+ path: `/v1.44${path}`,
1289
+ method: 'PUT',
1290
+ headers: { 'Content-Type': 'application/x-tar' },
1291
+ }, (res) => {
1292
+ const chunks = [];
1293
+ res.on('data', (chunk) => chunks.push(chunk));
1294
+ res.on('end', () => {
1295
+ if (res.statusCode && res.statusCode >= 400) {
1296
+ reject(new Error(t('codeSandbox.docker.error.apiError', {
1297
+ method: 'PUT',
1298
+ path,
1299
+ status: String(res.statusCode),
1300
+ error: Buffer.concat(chunks).toString(),
1301
+ }, {
1302
+ defaultValue: `Docker API PUT ${path}: ${res.statusCode} ${Buffer.concat(chunks).toString()}`,
1303
+ })));
1304
+ return;
1305
+ }
1306
+ resolve();
1307
+ });
1308
+ res.on('error', reject);
1309
+ });
1310
+ req.setTimeout(timeoutMs, () => {
1311
+ req.destroy(new Error(`Docker API timeout: PUT ${path}`));
1312
+ });
1313
+ req.on('error', reject);
1314
+ const source = Readable.from(body);
1315
+ source.on('error', (error) => {
1316
+ req.destroy(error);
1317
+ reject(error);
1318
+ });
1319
+ source.pipe(req);
1320
+ });
1321
+ }
848
1322
  /**
849
1323
  * Like `dockerApi` but returns the raw response Buffer without parsing.
850
1324
  * Used for exec start responses which return a multiplexed binary stream.
@@ -900,11 +1374,14 @@ class DockerSandboxProvider {
900
1374
  * bidirectional socket. Docker hijacks the connection (101 Switching Protocols)
901
1375
  * when stdin is attached, giving us a raw TCP socket for streaming I/O.
902
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.
903
1380
  * @returns The raw socket for stdin writes and multiplexed stdout/stderr reads.
904
1381
  */
905
- dockerExecUpgrade(execId) {
1382
+ dockerExecUpgrade(execId, tty = false) {
906
1383
  return new Promise((resolve, reject) => {
907
- const body = JSON.stringify({ Detach: false, Tty: false });
1384
+ const body = JSON.stringify({ Detach: false, Tty: tty });
908
1385
  const req = http.request({
909
1386
  ...this.endpoint,
910
1387
  path: `/v1.44/exec/${execId}/start`,