@claxedo/sandbox-manager 0.10.1 → 0.10.3

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.
Files changed (42) hide show
  1. package/README.md +100 -30
  2. package/dist/checkpoint-manager.mjs +19 -0
  3. package/dist/contract.d.ts +40 -17
  4. package/dist/driver-billing.d.ts +23 -0
  5. package/dist/driver-catalog.mjs +57 -3
  6. package/dist/driver-ensure-input.d.ts +22 -0
  7. package/dist/drivers/agent37-client.d.ts +87 -0
  8. package/dist/drivers/agent37.d.ts +25 -0
  9. package/dist/drivers/agent37.mjs +888 -0
  10. package/dist/drivers/boat-client.d.ts +4 -4
  11. package/dist/drivers/boat-machine.d.ts +80 -0
  12. package/dist/drivers/boat-preparation.test-support.d.ts +2 -0
  13. package/dist/drivers/boat-template.mjs +196 -92
  14. package/dist/drivers/boat.d.ts +0 -17
  15. package/dist/drivers/boat.mjs +200 -108
  16. package/dist/drivers/cloudflare.d.ts +5 -9
  17. package/dist/drivers/cloudflare.mjs +71 -7
  18. package/dist/drivers/docker.mjs +57 -2
  19. package/dist/drivers/local-brokering.d.ts +12 -0
  20. package/dist/drivers/local-brokering.mjs +99 -24
  21. package/dist/drivers/modal.mjs +57 -2
  22. package/dist/drivers/transient-error.d.ts +0 -1
  23. package/dist/drivers/vercel.mjs +61 -3
  24. package/dist/egress-policy.d.ts +33 -32
  25. package/dist/egress-warnings.d.ts +4 -6
  26. package/dist/hosted-network-policy.d.ts +10 -64
  27. package/dist/hosted-network-policy.mjs +1 -1
  28. package/dist/image-name.d.ts +0 -1
  29. package/dist/image-name.mjs +5 -0
  30. package/dist/image.d.ts +1 -0
  31. package/dist/image.mjs +6 -0
  32. package/dist/index.mjs +180 -112
  33. package/dist/json.d.ts +1 -10
  34. package/dist/lease-failure.d.ts +20 -1
  35. package/dist/lease-types.d.ts +1 -10
  36. package/dist/runtime-env.d.ts +6 -0
  37. package/dist/runtime-env.mjs +5 -0
  38. package/dist/runtime-version.d.ts +25 -7
  39. package/dist/runtime-version.mjs +13 -2
  40. package/dist/start-admission.d.ts +32 -0
  41. package/dist/start-telemetry.d.ts +5 -3
  42. package/package.json +12 -4
@@ -18,7 +18,7 @@ export type BoatFetch = (input: string, init?: RequestInit) => Promise<Response>
18
18
  /**
19
19
  * What Boat's 429 `rate_limited` says about the account's start limit: the
20
20
  * window that is used up, its limit, and the starts used of each window. It
21
- * names no time the window reopens (2026-10-15), so none is kept.
21
+ * names no time the window reopens, so none is kept.
22
22
  */
23
23
  export type BoatStartLimit = {
24
24
  window: SandboxStartWindow;
@@ -33,10 +33,10 @@ export declare class BoatApiError extends Error {
33
33
  readonly status?: number | undefined;
34
34
  /** Boat answers 401 or 403 to the API key itself, never to a sandbox it was asked about. */
35
35
  readonly providerKeyRefused: boolean;
36
- /** Create, fork and resume each count as one start (Boat's own refusal, 2026-10-15). */
36
+ /** Create, fork and resume each count as one start (Boat's own refusal). */
37
37
  readonly providerRateLimited?: SandboxStartLimit;
38
38
  readonly startLimit?: BoatStartLimit;
39
- /** The state Boat's refusal gives the sandbox it refused, e.g. `archived` on a 400 `stop_failed` (2026-10-17). */
39
+ /** The state Boat's refusal gives the sandbox it refused, e.g. `archived` on a 400 `stop_failed`. */
40
40
  readonly sandboxState?: BoatSandboxState;
41
41
  constructor(message: string, code: string, status?: number | undefined, refusal?: {
42
42
  startLimit?: BoatStartLimit;
@@ -75,7 +75,7 @@ export declare function createBoatClient(options: BoatClientOptions): {
75
75
  }): Promise<void>;
76
76
  /**
77
77
  * `ttlSeconds` sets the auto-stop deadline to now plus that many seconds; `null` removes it.
78
- * Boat's reference example answers `sandbox.info`; the API answers `sandbox.updated` (2026-10-13).
78
+ * Boat's reference example answers `sandbox.info`; the API answers `sandbox.updated`.
79
79
  */
80
80
  update(id: string, input: {
81
81
  ttlSeconds: number | null;
@@ -0,0 +1,80 @@
1
+ export declare const IMAGE_PREPARATION_TIMEOUT_SECONDS = 600;
2
+ export declare const CONTAINER_START_TIMEOUT_SECONDS = 120;
3
+ export declare const DEFAULT_CONTAINER_NAME = "claxedo-runtime";
4
+ export declare const RUNTIME_ENV_PATH = ".claxedo-runtime-env";
5
+ export declare const REGISTRY_PASSWORD_PATH = ".claxedo-registry-password";
6
+ /**
7
+ * Turns a booted machine into one any workspace can start from: it keeps what
8
+ * every machine of the image shares (the image's trees, stub archive and env,
9
+ * and Boat's ~/.ascii/playbook.json) and removes what this machine's boot made
10
+ * (the runtime container and Boat's record and image archive of it, the
11
+ * workspace, the runtime's state, the boot's env). Nothing the runtime wrote
12
+ * into the image's trees was found there (the trees held only the image's own
13
+ * entries after two boots); what else differs on the machine
14
+ * is Boat's own, and a machine created from the template gets its own
15
+ * machine-id, SSH host keys, hostname and no hosted port.
16
+ */
17
+ export declare function boatTemplateResetCommand(): string;
18
+ export type StagedFiles = {
19
+ env: string;
20
+ password?: string;
21
+ };
22
+ export declare function holdingBootLock(commandTimeoutSeconds: number, script: string): string;
23
+ /**
24
+ * The image a boot names and the one it may keep serving instead. A resume of
25
+ * a machine whose trees hold only an older image keeps that image when its
26
+ * runtime is no older than the oldest the control plane serves, so a wake
27
+ * after a deploy does not wait 60–100 s on the new image's pull and seed
28
+ * (staging); the workspace then reads as outdated, and the
29
+ * person's Restart to update asks for the new image with `keepPreviousImage`
30
+ * off. A cold boot has no previous image to keep.
31
+ */
32
+ export type MachineBoot = {
33
+ image: string;
34
+ keepPreviousImage: boolean;
35
+ workspaceId: string;
36
+ directory: string;
37
+ port: number;
38
+ runtimeCommand: string;
39
+ containerName: string;
40
+ registry?: {
41
+ server: string;
42
+ username: string;
43
+ };
44
+ staged: StagedFiles;
45
+ staleStagedMinutes: number;
46
+ };
47
+ /** Where a boot's preparation leaves the image it booted for its container start to read. */
48
+ export declare function bootedImageFile(staged: StagedFiles): string;
49
+ /** The image a preparation booted: the last line it prints, since `docker login` prints before it. */
50
+ export declare function bootedImage(prepareStdout: string): string | undefined;
51
+ /** The last line a preparation prints when its pull gave up on a registry link that kept breaking. */
52
+ export declare const IMAGE_DOWNLOAD_INTERRUPTED = "image_download_interrupted";
53
+ /** Leads the line a boot that could keep the machine's last image prints when it takes the named one instead, with what it found. */
54
+ export declare const KEPT_IMAGE_SKIPPED = "kept_image_skipped:";
55
+ /**
56
+ * The command carries only paths: env arrives through the staged file
57
+ * (mounted read-only, sourced by the entrypoint at every container start so
58
+ * multi-line values like PEMs survive — `docker run --env-file` cannot
59
+ * express them), and the registry password is piped to `docker login` from a
60
+ * file that is removed whatever the outcome; a failed login aborts the chain.
61
+ *
62
+ * Two commands. `prepare` changes nothing a running container serves from:
63
+ * it waits for the daemon (a resume reboots the VM, and Docker comes up as a
64
+ * systemd service after Boat reports the sandbox ready), logs in, and settles
65
+ * which image boots: the one named when its trees, stub and env are there;
66
+ * the one the machine last booted when the boot may keep it and its trees
67
+ * hold a runtime still served; otherwise the one named, pulled and seeded
68
+ * where anything is missing. It loads that image's stub when Docker does not
69
+ * hold it, removes every other image's trees, writes the image for `start`
70
+ * and prints it. A failure there leaves a runtime of the previous image
71
+ * serving. `start` then replaces another image's container with one of the
72
+ * booted image, or starts that image's container that is already there: the
73
+ * one the stop kept, which Boat's restore creates again stopped, or one Boat
74
+ * recreated after a stop that was not this driver's; then it removes the
75
+ * image if a seed left it. A failure there leaves nothing serving.
76
+ */
77
+ export declare function machineBootScripts(boot: MachineBoot): {
78
+ prepare: string;
79
+ start: string;
80
+ };
@@ -0,0 +1,2 @@
1
+ /** What a Boat machine's preparation prints when the image its command names boots: that image, as the real script's last line. */
2
+ export declare function preparedImageOutput(command: string): string;
@@ -63,6 +63,7 @@ var sandboxDriverCredentialFields = {
63
63
  { key: "worker_url", label: "Worker URL" }
64
64
  ],
65
65
  boat: [{ key: "api_key", label: "API Key", secret: true }],
66
+ agent37: [{ key: "api_key", label: "API Key", secret: true }],
66
67
  docker: [{ key: "image", label: "Image" }]
67
68
  };
68
69
  var sandboxDriverLabels = {
@@ -70,6 +71,7 @@ var sandboxDriverLabels = {
70
71
  vercel: "Vercel",
71
72
  cloudflare: "Cloudflare",
72
73
  boat: "Boat",
74
+ agent37: "Agent37",
73
75
  docker: "Docker"
74
76
  };
75
77
 
@@ -81,6 +83,13 @@ var SandboxRuntimeBootError = class extends Error {
81
83
  this.name = "SandboxRuntimeBootError";
82
84
  }
83
85
  };
86
+ var SandboxImageDownloadError = class extends Error {
87
+ imageDownloadFailed = true;
88
+ constructor(reason) {
89
+ super(reason);
90
+ this.name = "SandboxImageDownloadError";
91
+ }
92
+ };
84
93
  var SandboxHostGoneError = class extends Error {
85
94
  hostGone = true;
86
95
  constructor(reason) {
@@ -136,6 +145,10 @@ var WORKSPACE_RUNTIME_PUBLIC_ENV_KEYS = /* @__PURE__ */ new Set([
136
145
  function workspaceRuntimeBootSecrets(env) {
137
146
  return Object.entries(env).flatMap(([key, value]) => WORKSPACE_RUNTIME_PUBLIC_ENV_KEYS.has(key) ? [] : [value]);
138
147
  }
148
+ var REDACTED_VALUE_MIN_LENGTH = 8;
149
+ function redactWorkspaceRuntimeBootSecrets(text, secrets) {
150
+ return secrets.filter((secret) => secret.length >= REDACTED_VALUE_MIN_LENGTH).reduce((output, secret) => output.split(secret).join("[redacted]"), text);
151
+ }
139
152
  function workspaceRuntimeBootEnv(input) {
140
153
  assertWorkspaceRuntimeIdentityEnv(input.env);
141
154
  const env = {
@@ -181,6 +194,17 @@ function envFile(input) {
181
194
  }).join("\n") + "\n";
182
195
  }
183
196
 
197
+ // src/runtime-version.ts
198
+ var SERVED_WORKSPACE_RUNTIME_FLOOR = {
199
+ version: "0.10.1",
200
+ image: "ghcr.io/kyashrathore/claxedo-sandbox:workspace-runtime-0-10-1-40706b7356-agent-plugins-v8"
201
+ };
202
+ var MINIMUM_SERVED_WORKSPACE_RUNTIME_VERSION = SERVED_WORKSPACE_RUNTIME_FLOOR.version;
203
+ var SANDBOX_IMAGE_RUNTIME_VERSION_FILE = "opt/workspace-runtime/workspace-runtime-version";
204
+ function servedRuntimeVersionTest(file) {
205
+ return `version=$(cat ${file} 2>/dev/null) && [ -n "$version" ] && [ "$(printf '%s\\n%s\\n' ${MINIMUM_SERVED_WORKSPACE_RUNTIME_VERSION} "$version" | sort -V | head -n 1)" = ${MINIMUM_SERVED_WORKSPACE_RUNTIME_VERSION} ]`;
206
+ }
207
+
184
208
  // src/image-name.ts
185
209
  function assertSandboxImageReference(image) {
186
210
  if (!image || image !== image.trim() || image.startsWith("-") || /\s/.test(image)) {
@@ -200,6 +224,27 @@ var boatBilling = {
200
224
  large: { size: "large", vcpu: 8, memoryGb: 16, usdPerHour: 0.072 }
201
225
  }
202
226
  };
227
+ var AGENT37_USD_PER_MONTH = { vcpu: 0.8, memoryGb: 0.7, diskGb: 0.09 };
228
+ var AGENT37_MONTH_HOURS = 730;
229
+ var agent37MachineShapes = {
230
+ small: { cpu: 2, memory: 4, disk: 10 },
231
+ default: { cpu: 4, memory: 8, disk: 20 },
232
+ large: { cpu: 4, memory: 16, disk: 40 }
233
+ };
234
+ function agent37Rate(shape) {
235
+ const usdPerMonth = shape.cpu * AGENT37_USD_PER_MONTH.vcpu + shape.memory * AGENT37_USD_PER_MONTH.memoryGb + shape.disk * AGENT37_USD_PER_MONTH.diskGb;
236
+ return { size: `${shape.cpu} vCPU / ${shape.memory} GB`, vcpu: shape.cpu, memoryGb: shape.memory, usdPerHour: usdPerMonth / AGENT37_MONTH_HOURS };
237
+ }
238
+ var agent37Billing = {
239
+ source: { url: "https://www.agent37.com/docs/agents-api/billing", readOn: "2026-11-11" },
240
+ meter: "Agent37 meters an instance per minute at a monthly rate over 730 hours: $0.80 per vCPU, $0.70 per GB of memory and $0.09 per GB of disk while it runs, and the disk alone while it is stopped.",
241
+ rates: {
242
+ small: agent37Rate(agent37MachineShapes.small),
243
+ default: agent37Rate(agent37MachineShapes.default),
244
+ large: agent37Rate(agent37MachineShapes.large)
245
+ },
246
+ excludes: `the disk of a stopped instance, billed at $${AGENT37_USD_PER_MONTH.diskGb} per GB a month until the instance is deleted`
247
+ };
203
248
  var CLOUDFLARE_USD_PER_GIB_SECOND_MEMORY = 25e-7;
204
249
  var CLOUDFLARE_USD_PER_GB_SECOND_DISK = 7e-8;
205
250
  var CLOUDFLARE_USD_PER_VCPU_SECOND_ACTIVE = 2e-5;
@@ -285,8 +330,8 @@ var sandboxDriverCatalog = {
285
330
  hostWatchesIdle: true,
286
331
  targetAccess: "relay",
287
332
  secretBrokering: "native",
288
- // Credential handlers do not restrict unrelated destinations.
289
- egressControl: "none",
333
+ // The sandbox Worker starts the container without internet and routes each allowed host through itself; a Worker that cannot confirm that refuses the start.
334
+ egressControl: "hosts",
290
335
  // A container snapshot holds the whole root filesystem; the platform keeps it 30 days past its last restore and offers no deletion.
291
336
  persistence: {
292
337
  resume: "replacement-restore",
@@ -324,6 +369,33 @@ var sandboxDriverCatalog = {
324
369
  machineClasses: ["small", "default", "large"]
325
370
  }
326
371
  },
372
+ agent37: {
373
+ id: "agent37",
374
+ label: sandboxDriverLabels.agent37,
375
+ credentialFields: sandboxDriverCredentialFields.agent37,
376
+ runtimeNetwork: { controlPlane: "direct", relay: "configured" },
377
+ runtimeEnv: "callback",
378
+ preparedImage: true,
379
+ billing: agent37Billing,
380
+ metadata: {
381
+ driverRunsIn: ["worker", "node"],
382
+ hostStopBehavior: "suspends-host",
383
+ hostResumeBehavior: "same-host",
384
+ targetAccess: "relay",
385
+ secretBrokering: "none",
386
+ // An instance's network policy lists domains, reached over HTTPS on 443 alone, and IPv4 ranges; private ranges stay blocked whatever it lists.
387
+ egressControl: "hosts-and-cidrs",
388
+ persistence: {
389
+ resume: "same-sandbox",
390
+ capture: "none",
391
+ clone: false,
392
+ captureSource: "not-applicable",
393
+ retention: "provider-managed",
394
+ restoreMount: "same-resource"
395
+ },
396
+ machineClasses: ["small", "default", "large"]
397
+ }
398
+ },
327
399
  docker: {
328
400
  id: "docker",
329
401
  label: sandboxDriverLabels.docker,
@@ -396,10 +468,10 @@ var BoatApiError = class extends Error {
396
468
  }
397
469
  /** Boat answers 401 or 403 to the API key itself, never to a sandbox it was asked about. */
398
470
  providerKeyRefused;
399
- /** Create, fork and resume each count as one start (Boat's own refusal, 2026-10-15). */
471
+ /** Create, fork and resume each count as one start (Boat's own refusal). */
400
472
  providerRateLimited;
401
473
  startLimit;
402
- /** The state Boat's refusal gives the sandbox it refused, e.g. `archived` on a 400 `stop_failed` (2026-10-17). */
474
+ /** The state Boat's refusal gives the sandbox it refused, e.g. `archived` on a 400 `stop_failed`. */
403
475
  sandboxState;
404
476
  };
405
477
  function startCount(value) {
@@ -527,7 +599,7 @@ function createBoatClient(options) {
527
599
  },
528
600
  /**
529
601
  * `ttlSeconds` sets the auto-stop deadline to now plus that many seconds; `null` removes it.
530
- * Boat's reference example answers `sandbox.info`; the API answers `sandbox.updated` (2026-10-13).
602
+ * Boat's reference example answers `sandbox.info`; the API answers `sandbox.updated`.
531
603
  */
532
604
  async update(id, input) {
533
605
  const value = sandbox(await api(path(id), ["sandbox.updated", "sandbox.info"], { method: "PATCH", body: input }));
@@ -592,21 +664,10 @@ function isTransientDriverError(err, markers) {
592
664
  }
593
665
  var TRANSIENT_DRIVER_RETRY_MS = 2e3;
594
666
 
595
- // src/drivers/boat.ts
596
- var DEFAULT_RUNTIME_COMMAND = "/usr/local/bin/workspace-runtime";
597
- var DEFAULT_WORKSPACE_DIR = "/workspace";
598
- var DEFAULT_CONTAINER_NAME = "claxedo-runtime";
599
- var DEFAULT_PROVISION_TIMEOUT_MS = 12e4;
600
- var DEFAULT_STATE_POLL_MS = { firstMs: 250, maxMs: 2e3 };
601
- var DEFAULT_HEALTH_TIMEOUT_MS = 10 * 6e4;
602
- var DEFAULT_HEALTH_INTERVAL_MS = 100;
603
- var HEALTH_PROBE_WINDOW_SECONDS = 50;
604
- var HEALTH_PROBE_MARGIN_SECONDS = 10;
605
- var BOOTING_CONTAINER_STATES = /* @__PURE__ */ new Set(["running", "paused"]);
667
+ // src/drivers/boat-machine.ts
606
668
  var IMAGE_PREPARATION_TIMEOUT_SECONDS = 600;
607
669
  var CONTAINER_START_TIMEOUT_SECONDS = 120;
608
- var HOST_PUBLISH_TIMEOUT_SECONDS = 120;
609
- var BOOT_TTL_SECONDS = 30 * 60;
670
+ var DEFAULT_CONTAINER_NAME = "claxedo-runtime";
610
671
  var DOCKER_DAEMON_WAIT_SECONDS = 120;
611
672
  var RUNTIME_ENV_PATH = ".claxedo-runtime-env";
612
673
  var REGISTRY_PASSWORD_PATH = ".claxedo-registry-password";
@@ -632,17 +693,6 @@ var CONTAINER_CREATE_ATTEMPTS = 5;
632
693
  var CONTAINER_SECURITY_OPTIONS = "--security-opt seccomp=unconfined --security-opt apparmor=unconfined";
633
694
  var VM_DOCKER_INIT = "/usr/libexec/docker/docker-init";
634
695
  var CONTAINER_INIT_PATH = "/claxedo-init";
635
- var CONTAINER_STOP_GRACE_SECONDS = 30;
636
- var HOST_RELAY_NOT_STOPPED = "The sandbox could not stop the localhost relay of this port. Try again.";
637
- var STDERR_TAIL_CHARS = 600;
638
- var REDACTED_VALUE_MIN_LENGTH = 8;
639
- var READY_STATES = /* @__PURE__ */ new Set(["ready", "idle", "running"]);
640
- var PENDING_STATES = /* @__PURE__ */ new Set(["init", "provisioning", "provisioned", "cloning"]);
641
- var TRANSIENT_MARKERS = ["transport_failed", "idempotency_in_progress"];
642
- var GONE_CODES = /* @__PURE__ */ new Set(["not_found", "snapshot_data_expired"]);
643
- var RESUME_UNDER_WAY = "sandbox_resuming";
644
- var NOT_RUNNING_CODES = /* @__PURE__ */ new Set(["machine_not_running", "sandbox_not_ready"]);
645
- var TEMPLATE_UNREADABLE_CODES = /* @__PURE__ */ new Set(["snapshot_not_found", "api_key_action_forbidden", "api_key_sandbox_forbidden"]);
646
696
  function boatTemplateResetCommand() {
647
697
  const bootFiles = [
648
698
  `${PERSISTENT_ROOT}/workspace`,
@@ -653,6 +703,91 @@ function boatTemplateResetCommand() {
653
703
  ];
654
704
  return `{ docker rm -f ${DEFAULT_CONTAINER_NAME} >/dev/null 2>&1 || true; } && sudo rm -rf ${bootFiles.join(" ")} && [ -z "$(docker ps -aq)" ] && echo '[]' | sudo tee ${BOAT_CONTAINER_RECORD} >/dev/null && sudo find ${BOAT_SAVED_IMAGES} -mindepth 1 -delete && ls -d ${PERSISTENT_ROOT}/image-*/${IMAGE_TREES_COMPLETE_MARKER}`;
655
705
  }
706
+ function holdingBootLock(commandTimeoutSeconds, script) {
707
+ const wait = commandTimeoutSeconds - BOOT_LOCK_HEADROOM_SECONDS;
708
+ return `exec 9>>${BOOT_LOCK_PATH} && { flock -w ${wait} 9 || { echo "another boot of this machine held ${BOOT_LOCK_PATH} for ${wait} s" >&2; exit 1; }; } && ${script}`;
709
+ }
710
+ function containerHostname(workspaceId) {
711
+ return workspaceId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 63) || "claxedo";
712
+ }
713
+ function bootedImageFile(staged) {
714
+ return `${staged.env}.image`;
715
+ }
716
+ function bootedImage(prepareStdout) {
717
+ return prepareStdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).pop();
718
+ }
719
+ var stubLabel = `docker image inspect --format '{{index .Config.Labels "${IMAGE_LABEL}"}}' ${STUB_IMAGE} 2>/dev/null`;
720
+ var treesFunctions = [
721
+ `trees_of() { printf '%s/${PERSISTENT_ROOT}/image-%s' "$(pwd)" "$(printf %s "$1" | sed 's/[^A-Za-z0-9._-]/-/g')"; }`,
722
+ `seeded() { [ -f "$1/${IMAGE_TREES_COMPLETE_MARKER}" ] && [ -f "$1/${STUB_IMAGE_ARCHIVE}" ] && [ -f "$1/${IMAGE_ENV_FILE}" ]; }`,
723
+ `served() { ${servedRuntimeVersionTest(`"$1/${SANDBOX_IMAGE_RUNTIME_VERSION_FILE}"`)}; }`
724
+ ].join(" && ");
725
+ var IMAGE_DOWNLOAD_INTERRUPTED = "image_download_interrupted";
726
+ var KEPT_IMAGE_SKIPPED = "kept_image_skipped:";
727
+ var PULL_RESUMING = `pull_resuming() { finished=$(mktemp) && while :; do if output=$(docker pull "$1" 2>&1); then rm -f "$finished"; return 0; fi; case "$output" in *"connection reset by peer"*|*"unexpected EOF"*|*"i/o timeout"*|*"TLS handshake timeout"*) ;; *) printf '%s\\n' "$output" >&2; rm -f "$finished"; return 1 ;; esac; before=$(wc -l < "$finished"); { cat "$finished"; printf '%s\\n' "$output" | sed -n -e 's/^\\([0-9a-f]\\{12\\}\\): Download complete$/\\1/p' -e 's/^\\([0-9a-f]\\{12\\}\\): Pull complete$/\\1/p'; } | sort -u > "$finished.next" && mv "$finished.next" "$finished"; [ "$(wc -l < "$finished")" -gt "$before" ] || { printf '%s\\n' "$output" ${IMAGE_DOWNLOAD_INTERRUPTED} >&2; rm -f "$finished"; return 1; }; done; }`;
728
+ function machineBootScripts(boot) {
729
+ const named = shell(boot.image);
730
+ const trees = `"$(trees_of ${named})"`;
731
+ const bootedFile = bootedImageFile(boot.staged);
732
+ const containerOfBooted = `[ "$(docker container inspect --format '{{index .Config.Labels "${IMAGE_LABEL}"}}' ${boot.containerName} 2>/dev/null)" = "$booted" ]`;
733
+ const bootScript = [
734
+ `. ${CONTAINER_ENV_PATH}`,
735
+ `mkdir -p ${shell(boot.directory)}`,
736
+ `cd ${shell(boot.directory)}`,
737
+ `exec ${boot.runtimeCommand}`
738
+ ].join(" && ");
739
+ const mounts = [["workspace", boot.directory], ...PERSISTENT_HOME_MOUNTS];
740
+ const run = `docker run -d --name ${boot.containerName} --hostname ${shell(containerHostname(boot.workspaceId))} -p ${boot.port}:${boot.port} -v ${VM_DOCKER_INIT}:${CONTAINER_INIT_PATH}:ro -v "$(pwd)/${RUNTIME_ENV_PATH}:${CONTAINER_ENV_PATH}:ro" --env-file "$trees/${IMAGE_ENV_FILE}" ` + IMAGE_TREES.map((tree) => `-v "$trees/${tree}":/${tree} `).join("") + mounts.map(([source, target]) => `-v "$(pwd)/${PERSISTENT_ROOT}/${source}":${shell(target)} `).join("") + `${CONTAINER_SECURITY_OPTIONS} --entrypoint ${CONTAINER_INIT_PATH} ${STUB_IMAGE} -- sh -lc ${shell(bootScript)}`;
741
+ const seedTrees = `sudo rm -rf ${trees} && sudo mkdir -p ${trees} && { docker rm -f ${SEED_CONTAINER_NAME} >/dev/null 2>&1 || true; } && docker create --name ${SEED_CONTAINER_NAME} ${named} true >/dev/null && for tree in ${IMAGE_TREES.join(" ")}; do docker cp ${SEED_CONTAINER_NAME}:/$tree - | sudo tar -xf - -C ${trees} ${IMAGE_TREE_SEED_EXCLUDES.map((exclude) => `--exclude=${exclude}`).join(" ")} && [ -d ${trees}/$tree ] || exit 1; done && docker rm ${SEED_CONTAINER_NAME} >/dev/null && sudo touch ${trees}/${IMAGE_TREES_COMPLETE_MARKER}`;
742
+ const seedStub = `stub=$(mktemp -d) && docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' ${named} > "$stub/${IMAGE_ENV_FILE}" && workdir=$(docker image inspect --format '{{or .Config.WorkingDir "/"}}' ${named}) && mkdir "$stub/root" ${ROOT_DIRECTORIES.map((directory) => `"$stub/root/${directory}"`).join(" ")} && ${ROOT_LINKS.map((link) => `ln -s usr/${link} "$stub/root/${link}"`).join(" && ")} && chmod 1777 "$stub/root/tmp" && tar --owner=0 --group=0 --numeric-owner -cf "$stub/skeleton.tar" -C "$stub/root" . && docker import --change "WORKDIR $workdir" --change ${shell(`LABEL ${IMAGE_LABEL}=${boot.image}`)} "$stub/skeleton.tar" ${STUB_IMAGE} >/dev/null && docker save -o "$stub/${STUB_IMAGE_ARCHIVE}" ${STUB_IMAGE} && sudo mv "$stub/${IMAGE_ENV_FILE}" "$stub/${STUB_IMAGE_ARCHIVE}" ${trees}/ && rm -rf "$stub"`;
743
+ const seedNamed = `{ docker image inspect ${named} >/dev/null 2>&1 || pull_resuming ${named}; } && { [ -f ${trees}/${IMAGE_TREES_COMPLETE_MARKER} ] || { ${seedTrees}; }; } && ${seedStub}`;
744
+ const keepPrevious = `previous=$(${stubLabel}) && [ -n "$previous" ] && seeded "$(trees_of "$previous")" && served "$(trees_of "$previous")"`;
745
+ const keptImageSkipped = `echo "${KEPT_IMAGE_SKIPPED} stub=\${previous:-none} seeded=$(seeded "$(trees_of "$previous")" && echo yes || echo no) version=$(cat "$(trees_of "$previous")/${SANDBOX_IMAGE_RUNTIME_VERSION_FILE}" 2>/dev/null || echo none)" >&2`;
746
+ const removeImage = `if docker image inspect "$booted" >/dev/null 2>&1; then leftovers=$(docker ps -aq --filter ancestor="$booted") && { [ -z "$leftovers" ] || docker rm -f $leftovers >/dev/null; } && id=$(docker image inspect --format '{{.Id}}' "$booted") && docker rmi "$booted" >/dev/null && sudo rm -f ${BOAT_SAVED_IMAGES}/"\${id#sha256:}".tar; fi`;
747
+ const prepare = [
748
+ `chmod 600 ${boot.staged.env}`,
749
+ `find . -maxdepth 1 \\( -name '${RUNTIME_ENV_PATH}.*' -o -name '${REGISTRY_PASSWORD_PATH}.*' \\) -mmin +${boot.staleStagedMinutes} -exec rm -f {} +`,
750
+ `timeout ${DOCKER_DAEMON_WAIT_SECONDS} sh -c 'until docker info >/dev/null 2>&1; do sleep 1; done'`,
751
+ `[ -x ${VM_DOCKER_INIT} ]`,
752
+ ...boot.registry && boot.staged.password ? [`{ chmod 600 ${boot.staged.password}; docker login ${shell(boot.registry.server)} --username ${shell(boot.registry.username)} --password-stdin < ${boot.staged.password}; rc=$?; rm -f ${boot.staged.password}; (exit $rc); }`] : [],
753
+ treesFunctions,
754
+ PULL_RESUMING,
755
+ `booted=${named}`,
756
+ `if ! seeded ${trees}; then ` + (boot.keepPreviousImage ? `if ${keepPrevious}; then booted=$previous; else ${keptImageSkipped}; ${seedNamed}; fi` : seedNamed) + `; fi`,
757
+ `{ [ "$(${stubLabel})" = "$booted" ] || docker load -i "$(trees_of "$booted")/${STUB_IMAGE_ARCHIVE}" >/dev/null; }`,
758
+ `sudo find "$(pwd)/${PERSISTENT_ROOT}" -mindepth 1 -maxdepth 1 -name 'image-*' ! -path "$(trees_of "$booted")" -exec rm -rf {} +`,
759
+ `printf '%s\\n' "$booted" > ${bootedFile}`,
760
+ `printf '%s\\n' "$booted"`
761
+ ];
762
+ const start = `${treesFunctions} && booted=$(cat ${bootedFile}) && trees=$(trees_of "$booted") && rm -f ${bootedFile} && mv -f ${boot.staged.env} ${RUNTIME_ENV_PATH} && up=; for attempt in $(seq ${CONTAINER_CREATE_ATTEMPTS}); do [ "$attempt" = 1 ] || sleep 1; if ${containerOfBooted}; then failure=$(docker start ${boot.containerName} 2>&1 >/dev/null) && { up=1; break; }; else docker rm -f ${boot.containerName} >/dev/null 2>&1; failure=$(${run} 2>&1 >/dev/null) && { up=1; break; }; fi; case "$failure" in *${shell(DOCKER_NAME_IN_USE)}*) ;; *) break ;; esac; done; [ -n "$up" ] || { echo "$failure" >&2; false; } && ${removeImage}`;
763
+ return {
764
+ prepare: holdingBootLock(IMAGE_PREPARATION_TIMEOUT_SECONDS, prepare.join(" && ")),
765
+ start: holdingBootLock(CONTAINER_START_TIMEOUT_SECONDS, start)
766
+ };
767
+ }
768
+
769
+ // src/drivers/boat.ts
770
+ var DEFAULT_RUNTIME_COMMAND = "/usr/local/bin/workspace-runtime";
771
+ var DEFAULT_WORKSPACE_DIR = "/workspace";
772
+ var DEFAULT_PROVISION_TIMEOUT_MS = 12e4;
773
+ var DEFAULT_STATE_POLL_MS = { firstMs: 250, maxMs: 2e3 };
774
+ var DEFAULT_HEALTH_TIMEOUT_MS = 10 * 6e4;
775
+ var DEFAULT_HEALTH_INTERVAL_MS = 100;
776
+ var HEALTH_PROBE_WINDOW_SECONDS = 50;
777
+ var HEALTH_PROBE_MARGIN_SECONDS = 10;
778
+ var BOOTING_CONTAINER_STATES = /* @__PURE__ */ new Set(["running", "paused"]);
779
+ var HOST_PUBLISH_TIMEOUT_SECONDS = 120;
780
+ var BOOT_TTL_SECONDS = 30 * 60;
781
+ var CONTAINER_STOP_GRACE_SECONDS = 30;
782
+ var HOST_RELAY_NOT_STOPPED = "The sandbox could not stop the localhost relay of this port. Try again.";
783
+ var STDERR_TAIL_CHARS = 600;
784
+ var READY_STATES = /* @__PURE__ */ new Set(["ready", "idle", "running"]);
785
+ var PENDING_STATES = /* @__PURE__ */ new Set(["init", "provisioning", "provisioned", "cloning"]);
786
+ var TRANSIENT_MARKERS = ["transport_failed", "idempotency_in_progress"];
787
+ var GONE_CODES = /* @__PURE__ */ new Set(["not_found", "snapshot_data_expired"]);
788
+ var RESUME_UNDER_WAY = "sandbox_resuming";
789
+ var NOT_RUNNING_CODES = /* @__PURE__ */ new Set(["machine_not_running", "sandbox_not_ready"]);
790
+ var TEMPLATE_UNREADABLE_CODES = /* @__PURE__ */ new Set(["snapshot_not_found", "api_key_action_forbidden", "api_key_sandbox_forbidden"]);
656
791
  function hostGoneOrThrow(err) {
657
792
  if (err instanceof BoatApiError && GONE_CODES.has(err.code)) throw new SandboxHostGoneError(err.message);
658
793
  throw err;
@@ -665,24 +800,11 @@ function archivedOrThrow(err) {
665
800
  if (err instanceof BoatApiError && err.code === "stop_failed" && err.sandboxState === "archived") return;
666
801
  hostGoneOrThrow(err);
667
802
  }
668
- function holdingBootLock(commandTimeoutSeconds, script) {
669
- const wait = commandTimeoutSeconds - BOOT_LOCK_HEADROOM_SECONDS;
670
- return `exec 9>>${BOOT_LOCK_PATH} && { flock -w ${wait} 9 || { echo "another boot of this machine held ${BOOT_LOCK_PATH} for ${wait} s" >&2; exit 1; }; } && ${script}`;
671
- }
672
803
  function sleep(ms) {
673
804
  return new Promise((resolve) => setTimeout(resolve, ms));
674
805
  }
675
- function scrubbed(text, secrets) {
676
- return secrets.filter((secret) => secret.length >= REDACTED_VALUE_MIN_LENGTH).reduce((output, secret) => output.split(secret).join("[redacted]"), text);
677
- }
678
806
  function scrubbedTail(text, secrets) {
679
- return scrubbed(text, secrets).trim().slice(-STDERR_TAIL_CHARS);
680
- }
681
- function imageTreesDirectory(image) {
682
- return `image-${image.replace(/[^A-Za-z0-9._-]/g, "-")}`;
683
- }
684
- function containerHostname(workspaceId) {
685
- return workspaceId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 63) || "claxedo";
807
+ return redactWorkspaceRuntimeBootSecrets(text, secrets).trim().slice(-STDERR_TAIL_CHARS);
686
808
  }
687
809
  var BoatDriverError = class extends Error {
688
810
  constructor(message) {
@@ -764,43 +886,6 @@ function createBoatSandboxDriver(options) {
764
886
  wait = Math.min(wait * 2, statePollMs.maxMs);
765
887
  }
766
888
  }
767
- function containerScripts(input, staged) {
768
- const port = runtimePort(input);
769
- const directory = workspaceDirectory(input);
770
- const reference = resolveImage(input);
771
- const image = shell(reference);
772
- const trees = `"$(pwd)/${PERSISTENT_ROOT}/${imageTreesDirectory(reference)}"`;
773
- const labelledWithImage = (object, name) => `[ "$(docker ${object} inspect --format '{{index .Config.Labels "${IMAGE_LABEL}"}}' ${name} 2>/dev/null)" = ${image} ]`;
774
- const bootScript = [
775
- `. ${CONTAINER_ENV_PATH}`,
776
- `mkdir -p ${shell(directory)}`,
777
- `cd ${shell(directory)}`,
778
- `exec ${runtimeCommand}`
779
- ].join(" && ");
780
- const mounts = [["workspace", directory], ...PERSISTENT_HOME_MOUNTS];
781
- const run = `docker run -d --name ${containerName} --hostname ${shell(containerHostname(input.workspaceId))} -p ${port}:${port} -v ${VM_DOCKER_INIT}:${CONTAINER_INIT_PATH}:ro -v "$(pwd)/${RUNTIME_ENV_PATH}:${CONTAINER_ENV_PATH}:ro" --env-file ${trees}/${IMAGE_ENV_FILE} ` + IMAGE_TREES.map((tree) => `-v ${trees}/${tree}:/${tree} `).join("") + mounts.map(([source, target]) => `-v "$(pwd)/${PERSISTENT_ROOT}/${source}":${shell(target)} `).join("") + `${CONTAINER_SECURITY_OPTIONS} --entrypoint ${CONTAINER_INIT_PATH} ${STUB_IMAGE} -- sh -lc ${shell(bootScript)}`;
782
- const seedTrees = `sudo rm -rf ${trees} && sudo mkdir -p ${trees} && { docker rm -f ${SEED_CONTAINER_NAME} >/dev/null 2>&1 || true; } && docker create --name ${SEED_CONTAINER_NAME} ${image} true >/dev/null && for tree in ${IMAGE_TREES.join(" ")}; do docker cp ${SEED_CONTAINER_NAME}:/$tree - | sudo tar -xf - -C ${trees} ${IMAGE_TREE_SEED_EXCLUDES.map((exclude) => `--exclude=${exclude}`).join(" ")} && [ -d ${trees}/$tree ] || exit 1; done && docker rm ${SEED_CONTAINER_NAME} >/dev/null && sudo touch ${trees}/${IMAGE_TREES_COMPLETE_MARKER}`;
783
- const seedStub = `stub=$(mktemp -d) && docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' ${image} > "$stub/${IMAGE_ENV_FILE}" && workdir=$(docker image inspect --format '{{or .Config.WorkingDir "/"}}' ${image}) && mkdir "$stub/root" ${ROOT_DIRECTORIES.map((directory2) => `"$stub/root/${directory2}"`).join(" ")} && ${ROOT_LINKS.map((link) => `ln -s usr/${link} "$stub/root/${link}"`).join(" && ")} && chmod 1777 "$stub/root/tmp" && tar --owner=0 --group=0 --numeric-owner -cf "$stub/skeleton.tar" -C "$stub/root" . && docker import --change "WORKDIR $workdir" --change ${shell(`LABEL ${IMAGE_LABEL}=${reference}`)} "$stub/skeleton.tar" ${STUB_IMAGE} >/dev/null && docker save -o "$stub/${STUB_IMAGE_ARCHIVE}" ${STUB_IMAGE} && sudo mv "$stub/${IMAGE_ENV_FILE}" "$stub/${STUB_IMAGE_ARCHIVE}" ${trees}/ && rm -rf "$stub"`;
784
- const removeImage = `if docker image inspect ${image} >/dev/null 2>&1; then leftovers=$(docker ps -aq --filter ancestor=${image}) && { [ -z "$leftovers" ] || docker rm -f $leftovers >/dev/null; } && id=$(docker image inspect --format '{{.Id}}' ${image}) && docker rmi ${image} >/dev/null && sudo rm -f ${BOAT_SAVED_IMAGES}/"\${id#sha256:}".tar; fi`;
785
- const seeded = [IMAGE_TREES_COMPLETE_MARKER, STUB_IMAGE_ARCHIVE, IMAGE_ENV_FILE].map((file) => `[ -f ${trees}/${file} ]`).join(" && ");
786
- const prepare = [
787
- `chmod 600 ${staged.env}`,
788
- `find . -maxdepth 1 \\( -name '${RUNTIME_ENV_PATH}.*' -o -name '${REGISTRY_PASSWORD_PATH}.*' \\) -mmin +${BOOT_TTL_SECONDS / 60} -exec rm -f {} +`,
789
- `timeout ${DOCKER_DAEMON_WAIT_SECONDS} sh -c 'until docker info >/dev/null 2>&1; do sleep 1; done'`,
790
- `[ -x ${VM_DOCKER_INIT} ]`,
791
- ...options.registryAuth && staged.password ? [`{ chmod 600 ${staged.password}; docker login ${shell(options.registryAuth.server)} --username ${shell(options.registryAuth.username)} --password-stdin < ${staged.password}; rc=$?; rm -f ${staged.password}; (exit $rc); }`] : [],
792
- // Trees an earlier start marked complete hold what a person or a harness
793
- // changed since, so a seed missing only its stub or env builds those alone.
794
- `if ! { ${seeded}; }; then { docker image inspect ${image} >/dev/null 2>&1 || docker pull ${image} >/dev/null; } && { [ -f ${trees}/${IMAGE_TREES_COMPLETE_MARKER} ] || { ${seedTrees}; }; } && ${seedStub}; fi`,
795
- `{ ${labelledWithImage("image", STUB_IMAGE)} || docker load -i ${trees}/${STUB_IMAGE_ARCHIVE} >/dev/null; }`,
796
- `sudo find "$(pwd)/${PERSISTENT_ROOT}" -mindepth 1 -maxdepth 1 -name 'image-*' ! -path ${trees} -exec rm -rf {} +`
797
- ];
798
- const start = `mv -f ${staged.env} ${RUNTIME_ENV_PATH} && up=; for attempt in $(seq ${CONTAINER_CREATE_ATTEMPTS}); do [ "$attempt" = 1 ] || sleep 1; if ${labelledWithImage("container", containerName)}; then failure=$(docker start ${containerName} 2>&1 >/dev/null) && { up=1; break; }; else docker rm -f ${containerName} >/dev/null 2>&1; failure=$(${run} 2>&1 >/dev/null) && { up=1; break; }; fi; case "$failure" in *${shell(DOCKER_NAME_IN_USE)}*) ;; *) break ;; esac; done; [ -n "$up" ] || { echo "$failure" >&2; false; } && ${removeImage}`;
799
- return {
800
- prepare: holdingBootLock(IMAGE_PREPARATION_TIMEOUT_SECONDS, prepare.join(" && ")),
801
- start: holdingBootLock(CONTAINER_START_TIMEOUT_SECONDS, start)
802
- };
803
- }
804
889
  function healthProbe(port, windowSeconds) {
805
890
  const booting = [...BOOTING_CONTAINER_STATES].join("|");
806
891
  return `end=$(($(date +%s) + ${windowSeconds})); while :; do code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${port}/global/health); [ "$code" = 200 ] && break; state=$(docker inspect --format '{{.State.Status}}' ${shell(containerName)} 2>/dev/null || echo missing); case "$state" in ${booting}) ;; *) break ;; esac; [ "$(date +%s)" -lt "$end" ] || break; sleep ${healthIntervalMs / 1e3}; done; echo "$code"; echo "$state"`;
@@ -829,29 +914,48 @@ function createBoatSandboxDriver(options) {
829
914
  command: `docker inspect --format 'status={{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' ${shell(containerName)}; docker logs --tail 20 ${shell(containerName)} 2>&1`,
830
915
  timeoutSeconds: 10
831
916
  });
832
- diagnostic = runtimeBootDiagnostic(scrubbed(`${result.stdout}
917
+ diagnostic = runtimeBootDiagnostic(redactWorkspaceRuntimeBootSecrets(`${result.stdout}
833
918
  ${result.stderr}`, secrets), STDERR_TAIL_CHARS);
834
919
  } catch {
835
920
  diagnostic = "container diagnostics unavailable";
836
921
  }
837
922
  throw new SandboxRuntimeBootError(`Boat ${sandboxId} runtime did not become healthy: ${last}; ${diagnostic}`);
838
923
  }
839
- async function startContainer(sandboxId, input, hostId) {
924
+ async function startContainer(sandboxId, input, hostId, keepPreviousImage) {
840
925
  const env = { ...bootEnv(input, hostId), ...await options.env?.(input, { id: hostId }) };
841
926
  const boot2 = crypto.randomUUID();
842
927
  const staged = {
843
928
  env: `${RUNTIME_ENV_PATH}.${boot2}`,
844
929
  ...options.registryAuth ? { password: `${REGISTRY_PASSWORD_PATH}.${boot2}` } : {}
845
930
  };
846
- const scripts = containerScripts(input, staged);
931
+ const image = resolveImage(input);
932
+ const scripts = machineBootScripts({
933
+ image,
934
+ keepPreviousImage: keepPreviousImage && image === options.image,
935
+ workspaceId: input.workspaceId,
936
+ directory: workspaceDirectory(input),
937
+ port: runtimePort(input),
938
+ runtimeCommand,
939
+ containerName,
940
+ ...options.registryAuth ? { registry: { server: options.registryAuth.server, username: options.registryAuth.username } } : {},
941
+ staged,
942
+ staleStagedMinutes: BOOT_TTL_SECONDS / 60
943
+ });
847
944
  await client.writeFile(sandboxId, { path: staged.env, content: envFile(env) });
848
945
  if (options.registryAuth && staged.password) {
849
946
  await client.writeFile(sandboxId, { path: staged.password, content: options.registryAuth.password });
850
947
  }
851
948
  const secrets = [...workspaceRuntimeBootSecrets(env), ...options.registryAuth ? [options.registryAuth.password] : []];
852
949
  let failure;
950
+ let booted;
853
951
  try {
854
- await execOrThrow(sandboxId, scripts.prepare, "runtime image preparation", { timeoutSeconds: IMAGE_PREPARATION_TIMEOUT_SECONDS, secrets });
952
+ const prepared = await execOrThrow(sandboxId, scripts.prepare, "runtime image preparation", { timeoutSeconds: IMAGE_PREPARATION_TIMEOUT_SECONDS, secrets }).catch((error) => {
953
+ throw error instanceof BoatDriverError && error.message.includes(IMAGE_DOWNLOAD_INTERRUPTED) ? new SandboxImageDownloadError(error.message) : error;
954
+ });
955
+ booted = bootedImage(prepared.stdout);
956
+ if (!booted) throw new BoatDriverError(`Boat ${sandboxId} runtime image preparation named no image it booted`);
957
+ const skipped = keepPreviousImage ? prepared.stderr.split("\n").find((line) => line.startsWith(KEPT_IMAGE_SKIPPED)) : void 0;
958
+ if (skipped) console.warn("A Boat wake took the deployment's image instead of the one the machine last booted", { sandboxId, workspaceId: input.workspaceId, found: skipped.slice(KEPT_IMAGE_SKIPPED.length).trim() });
855
959
  try {
856
960
  await execOrThrow(sandboxId, scripts.start, "container start", { timeoutSeconds: CONTAINER_START_TIMEOUT_SECONDS, secrets });
857
961
  } catch (error) {
@@ -862,19 +966,19 @@ ${result.stderr}`, secrets), STDERR_TAIL_CHARS);
862
966
  }
863
967
  try {
864
968
  if (options.registryAuth || failure !== void 0) {
865
- await execOrThrow(sandboxId, `rm -f ${[staged.env, staged.password].filter(Boolean).join(" ")}`, "staged file cleanup", { secrets });
969
+ await execOrThrow(sandboxId, `rm -f ${[staged.env, staged.password, bootedImageFile(staged)].filter(Boolean).join(" ")}`, "staged file cleanup", { secrets });
866
970
  }
867
971
  } catch (cleanupFailure) {
868
972
  if (failure === void 0) throw cleanupFailure;
869
973
  if (failure instanceof Error) failure.message = `${failure.message}; ${cleanupFailure instanceof Error ? cleanupFailure.message : String(cleanupFailure)}`;
870
974
  }
871
- if (failure !== void 0) throw failure;
872
- return secrets;
975
+ if (failure !== void 0 || !booted) throw failure;
976
+ return { secrets, booted };
873
977
  }
874
- async function boot(sandboxId, input, hostId) {
978
+ async function boot(sandboxId, input, hostId, keepPreviousImage) {
875
979
  await input.onMachineReady?.();
876
980
  const port = runtimePort(input);
877
- const secrets = await startContainer(sandboxId, input, hostId);
981
+ const { secrets, booted } = await startContainer(sandboxId, input, hostId, keepPreviousImage);
878
982
  await input.onImageReady?.();
879
983
  await execOrThrow(sandboxId, holdingBootLock(HOST_PUBLISH_TIMEOUT_SECONDS, `host ${port} --public`), "host publish", {
880
984
  timeoutSeconds: HOST_PUBLISH_TIMEOUT_SECONDS,
@@ -894,7 +998,7 @@ ${result.stderr}`, secrets), STDERR_TAIL_CHARS);
894
998
  hostId,
895
999
  driverResourceId: sandboxId,
896
1000
  driver: { id: "boat", resourceId: sandboxId },
897
- labels: { ...input.labels, ...resolveImage(input) === options.image ? { [SANDBOX_IMAGE_LABEL]: options.image } : {} }
1001
+ labels: { ...input.labels, ...resolveImage(input) === options.image ? { [SANDBOX_IMAGE_LABEL]: booted } : {} }
898
1002
  };
899
1003
  }
900
1004
  function assertNetwork(input) {
@@ -943,7 +1047,7 @@ ${result.stderr}`, secrets), STDERR_TAIL_CHARS);
943
1047
  if (failures.length > 1) throw new AggregateError(failures, `Boat ${created.id} resource handoff and deletion failed`);
944
1048
  const ready = await waitUntilReady(created.id);
945
1049
  if (!ready) return { provisioning: true, retryAfterMs: 0 };
946
- return boot(created.id, input, hostId);
1050
+ return boot(created.id, input, hostId, false);
947
1051
  }
948
1052
  async function resumeHost(input) {
949
1053
  const sandboxId = input.lease.sandboxId;
@@ -963,7 +1067,7 @@ ${result.stderr}`, secrets), STDERR_TAIL_CHARS);
963
1067
  }
964
1068
  const ready = await waitUntilReady(sandboxId);
965
1069
  if (!ready) return { provisioning: true, retryAfterMs: 0 };
966
- return boot(sandboxId, input.ensure, hostId);
1070
+ return boot(sandboxId, input.ensure, hostId, !input.ensure.updateImage);
967
1071
  }
968
1072
  async function stop(target) {
969
1073
  await client.command(target.sandboxId, {
@@ -22,12 +22,7 @@ export type BoatSandboxDriverOptions = {
22
22
  workspaceDir?: string;
23
23
  nativeHarness?: string;
24
24
  controlEnv?: WorkspaceRuntimeControlEnv;
25
- /** Container name used for the runtime container inside the sandbox. */
26
25
  containerName?: string;
27
- /**
28
- * Optional `docker login` before the pull, for a private runtime registry.
29
- * Omit for public images.
30
- */
31
26
  registryAuth?: {
32
27
  server: string;
33
28
  username: string;
@@ -49,16 +44,4 @@ export type BoatSandboxDriverOptions = {
49
44
  operationTimeoutMs?: number;
50
45
  fetchImpl?: BoatFetch;
51
46
  };
52
- /**
53
- * Turns a booted machine into one any workspace can start from: it keeps what
54
- * every machine of the image shares (the image's trees, stub archive and env,
55
- * and Boat's ~/.ascii/playbook.json) and removes what this machine's boot made
56
- * (the runtime container and Boat's record and image archive of it, the
57
- * workspace, the runtime's state, the boot's env). Nothing the runtime wrote
58
- * into the image's trees was found there (Boat, 2026-10-16: the trees held only
59
- * the image's own entries after two boots); what else differs on the machine
60
- * is Boat's own, and a machine created from the template gets its own
61
- * machine-id, SSH host keys, hostname and no hosted port.
62
- */
63
- export declare function boatTemplateResetCommand(): string;
64
47
  export declare function createBoatSandboxDriver(options: BoatSandboxDriverOptions): SandboxDriver;