@claxedo/sandbox-manager 0.9.0 → 0.10.1

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 (67) hide show
  1. package/README.md +233 -26
  2. package/dist/checkpoint-manager.d.ts +29 -4
  3. package/dist/checkpoint-manager.mjs +133 -24
  4. package/dist/command.d.ts +9 -2
  5. package/dist/command.mjs +8 -8
  6. package/dist/contract.d.ts +687 -0
  7. package/dist/driver-billing.d.ts +29 -0
  8. package/dist/driver-catalog.d.ts +42 -7
  9. package/dist/driver-catalog.mjs +115 -161
  10. package/dist/drivers/boat-client.d.ts +95 -0
  11. package/dist/drivers/boat-named-snapshots.d.ts +31 -0
  12. package/dist/drivers/boat-template.d.ts +49 -0
  13. package/dist/drivers/boat-template.mjs +1196 -0
  14. package/dist/drivers/boat.d.ts +64 -0
  15. package/dist/drivers/boat.mjs +990 -0
  16. package/dist/drivers/cloudflare.d.ts +3 -3
  17. package/dist/drivers/cloudflare.mjs +208 -147
  18. package/dist/drivers/docker.d.ts +4 -3
  19. package/dist/drivers/docker.mjs +154 -126
  20. package/dist/drivers/local-brokering.d.ts +14 -0
  21. package/dist/drivers/local-brokering.mjs +1029 -0
  22. package/dist/drivers/modal.d.ts +2 -2
  23. package/dist/drivers/modal.mjs +121 -111
  24. package/dist/drivers/transient-error.d.ts +2 -0
  25. package/dist/drivers/vercel.d.ts +3 -2
  26. package/dist/drivers/vercel.mjs +151 -115
  27. package/dist/egress-policy.d.ts +134 -0
  28. package/dist/egress-warnings.d.ts +22 -0
  29. package/dist/garbage-collect.d.ts +14 -0
  30. package/dist/hosted-network-policy.d.ts +9 -2
  31. package/dist/hosted-network-policy.mjs +218 -1
  32. package/dist/image-name.d.ts +12 -2
  33. package/dist/image-name.mjs +14 -5
  34. package/dist/image.d.ts +1 -20
  35. package/dist/image.mjs +11 -88
  36. package/dist/index.d.ts +8 -578
  37. package/dist/index.mjs +1379 -320
  38. package/dist/lease-failure.d.ts +173 -0
  39. package/dist/lease-lifecycle.d.ts +65 -0
  40. package/dist/lease-retry-wait.d.ts +8 -0
  41. package/dist/lease-start.d.ts +21 -0
  42. package/dist/lease-types.d.ts +4 -3
  43. package/dist/manager.d.ts +2 -0
  44. package/dist/runtime-env.d.ts +4 -2
  45. package/dist/runtime-env.mjs +37 -2
  46. package/dist/runtime-snapshot.d.ts +49 -0
  47. package/dist/runtime-version.d.ts +9 -0
  48. package/dist/runtime-version.mjs +2 -0
  49. package/dist/service-url-exposure.d.ts +0 -1
  50. package/dist/service-url-exposure.mjs +0 -9
  51. package/dist/start-fresh.d.ts +17 -0
  52. package/dist/start-telemetry.d.ts +37 -0
  53. package/dist/stores/memory.d.ts +1 -1
  54. package/dist/stores/memory.mjs +63 -52
  55. package/package.json +26 -42
  56. package/dist/daytona-allow-list.d.ts +0 -16
  57. package/dist/daytona-allow-list.mjs +0 -11
  58. package/dist/drivers/box.d.ts +0 -52
  59. package/dist/drivers/box.mjs +0 -574
  60. package/dist/drivers/daytona.d.ts +0 -163
  61. package/dist/drivers/daytona.mjs +0 -683
  62. package/dist/drivers/exe.d.ts +0 -26
  63. package/dist/drivers/exe.mjs +0 -492
  64. package/dist/drivers/fetch-bridge.d.ts +0 -10
  65. package/dist/drivers/fetch-bridge.mjs +0 -126
  66. package/dist/lease-policy.d.ts +0 -52
  67. package/dist/lease-policy.mjs +0 -290
package/README.md CHANGED
@@ -9,7 +9,22 @@ The package owns the generic pieces:
9
9
  - `SandboxTarget`
10
10
  - `SandboxLeaseStore`
11
11
  - `SandboxDriver`
12
- - provider drivers for Cloudflare, Daytona, Docker, fetch bridge, Modal, and Vercel
12
+ - provider drivers for Cloudflare, Docker, fetch bridge, Modal, and Vercel
13
+
14
+ `drivers/local-brokering` is a macOS test driver. The e2e hosted stack's
15
+ sandbox fixture (`packages/harness/e2e/harness/hosted-sandbox-worker.ts`)
16
+ injects its instance programmatically; it has no product driver ID or product
17
+ configuration path. It runs the workspace runtime in a private directory under
18
+ `sandbox-exec`, permitting outbound TCP to its HTTP proxy and the specified
19
+ control-plane and relay loopback ports. The profile lets only `/bin/ps` execute
20
+ outside the sandbox because macOS refuses its setuid binary inside
21
+ `sandbox-exec`; the runtime stays under the network policy. The proxy accepts
22
+ HTTPS CONNECT only for a registered secret host with an explicit loopback test
23
+ upstream, terminates it with a private test CA, and applies the egress broker's
24
+ placeholder substitution before forwarding. The runtime trusts the CA
25
+ certificate, while its sandbox profile denies access to the CA and leaf private
26
+ keys. Other CONNECT destinations are refused. It is not a production sandbox
27
+ provider.
13
28
 
14
29
  It deliberately does not own Claxedo product auth, billing, app storage schema, routes, or relay tokens. Applications provide those through adapters and call `createSandboxManager`.
15
30
 
@@ -53,15 +68,200 @@ if (result.status === "ready") {
53
68
  await manager.stop("workspace-1")
54
69
  ```
55
70
 
56
- Swap `createDockerSandboxDriver` for `createDaytonaSandboxDriver`,
57
- `createModalSandboxDriver`, `createVercelSandboxDriver`,
58
- `createCloudflareSandboxDriver`, or `createBoxSandboxDriver` (all under
71
+ Swap `createDockerSandboxDriver` for `createModalSandboxDriver`,
72
+ `createVercelSandboxDriver`,
73
+ `createCloudflareSandboxDriver`, or `createBoatSandboxDriver` (all under
59
74
  `@claxedo/sandbox-manager/drivers/*`) to place on a hosted provider instead —
60
75
  see [`docs/architecture.md`](docs/architecture.md) for the full driver
61
76
  comparison and each provider's required options. Swap `createMemoryLeaseStore`
62
77
  for a persisted `SandboxLeaseStore` implementation (e.g. backed by SQLite) to
63
78
  survive process restarts.
64
79
 
80
+ The Boat driver checks the runtime's health endpoint after starting its container.
81
+ If startup never becomes healthy, the failure includes a bounded container log
82
+ tail and container exit information, with staged environment and registry secret
83
+ values redacted before truncation. A diagnostic request failure preserves the
84
+ original health failure.
85
+
86
+ ### Boat: what survives a stop, and what survives an image update
87
+
88
+ A Boat sandbox is a Linux VM whose disk Boat snapshots at every stop; the
89
+ runtime runs inside it as a Docker container over the canonical sandbox image's
90
+ trees. Boat's snapshot keeps a container's settings but not its writable layer,
91
+ and a resume creates every container again from its image
92
+ ([what a snapshot saves](https://docs.boat.dev/snapshots/whats-saved#docker);
93
+ confirmed on a live sandbox 2026-10-14), so the driver keeps the machine's
94
+ state on the VM's disk and mounts it into the container:
95
+
96
+ | Path in the machine | Source on the VM disk | Stop and resume | Image update |
97
+ | --- | --- | --- | --- |
98
+ | `/workspace` | `claxedo-persistent/workspace` | kept | kept |
99
+ | `~/.claxedo`, `~/.workspace-runtime` (runtime stores, harness homes) | `claxedo-persistent/claxedo`, `claxedo-persistent/workspace-runtime` | kept | kept |
100
+ | `/root`, `/usr`, `/etc`, `/var`, `/opt`, `/home`, `/srv` | `claxedo-persistent/image-<image>/<tree>`, seeded from the image once per image | kept: dotfiles, `npm i -g`, `pip install`, `apt install`, anything written there | replaced by the new image's trees; the old image's are removed |
101
+ | the container's hostname | `--hostname <workspace id>` | kept | kept |
102
+ | everything else (`/tmp`, `/run`, processes) | none | gone, as on any reboot | gone |
103
+
104
+ A new sandbox gets the image's trees one of two ways. A sandbox of the
105
+ deployment's image is created from the release's runtime template (below), a
106
+ named snapshot that already holds them, so its first start pulls and seeds
107
+ nothing. Without a template, or when the organization's key cannot read it, or
108
+ for any other image, the first start seeds them. The seed runs once per image, at the start that first names it: it pulls the
109
+ image, streams the trees out of a created container of it with `docker cp` and
110
+ unpacks them as root, leaving out `~/.cache` (which Boat's own snapshots leave
111
+ out too) and npm's cache. The persistent root is created as root too: Docker
112
+ made it as root on every sandbox that booted before the trees existed, and the
113
+ VM user cannot create inside it. The seed then saves beside the trees what the
114
+ container needs besides them: the image's env (`image.env`, given to the
115
+ container with `--env-file`) and a stub image (`stub.tar`, 67 kB) holding the
116
+ image's top level outside the trees (Debian's merged-`/usr` links and empty
117
+ directories), its working directory, and a `claxedo.image` label naming the
118
+ image. The seed starts no container of the image: Boat records the containers
119
+ it sees, and one the seed ran for a second to read the image's root was
120
+ recreated by a later restore as soon as the image was back, so the next seed
121
+ could not remove the image. The trees are marked complete once they all
122
+ landed, and a seed whose trees did not complete is redone from the pull; trees
123
+ marked complete are never seeded again, since they hold what was changed since,
124
+ so a seed missing only `stub.tar` or `image.env` (as one an earlier driver left)
125
+ pulls the image and builds those alone. Once the runtime container runs from the
126
+ stub, the start removes the image, any leftover container of it, and the archive
127
+ Boat keeps of it in `/var/lib/ascii-docker-images` (Boat loads that archive
128
+ again at every resume, 16–19 s for this image, and removing the image leaves it
129
+ behind). It waits for the start because the container being replaced may run
130
+ from the image itself, as an earlier driver's did.
131
+
132
+ Every start then runs the container from the stub, loading `stub.tar` when
133
+ Docker does not hold this image's stub, with every path above mounted. A wake
134
+ therefore pulls nothing. On Boat, 2026-10-15, from the create or resume call to
135
+ a healthy runtime, with a public repository checked out:
136
+
137
+ | Start | Time | Where it goes |
138
+ | --- | --- | --- |
139
+ | cold, from the runtime template | 20.7, 26.8, 29.5 s | create to `machine_ready` 5–8 s, `image_ready` (stub and container start) 2 s later, the rest the runtime reading its files while Boat still downloads them |
140
+ | cold, seeding (no template, or an unreadable one) | 55–96 s (81.5 s for the fallback) | the pull and seed, as fast as ghcr serves the image |
141
+ | wake of a sandbox created from the template | 14.7, 17.2, 12.3 s | `machine_ready` 5 s, `image_ready` 1–3 s, then the runtime |
142
+ | wake of a seeded sandbox | 8–20 s | the same phases, with no record of what the runtime reads first |
143
+
144
+ The driver waits on Boat itself rather than reading the sandbox on a timer.
145
+ Boat holds a command sent while a sandbox starts and runs it once the sandbox
146
+ is ready, for up to 60 s, then refuses it as still starting
147
+ ([readiness](https://docs.boat.dev/readiness)), so the first command after a
148
+ create or resume returns the moment the machine is ready and is sent again at
149
+ once after a refusal. The runtime's health is probed inside the machine every
150
+ 100 ms by one command per 50 s window, which ends at the first answer or when
151
+ the container leaves `running`/`paused`. A sandbox still archiving from a stop,
152
+ which Boat offers no wait for, is read again after 250 ms, doubling to 2 s.
153
+
154
+ A wake that pulled (before the stub) took 57–77 s, and failed when ghcr reset
155
+ the connection. An image update re-seeds:
156
+ the new image is pulled and seeded as above, its stub replaces the old one, and
157
+ the old image's trees are removed.
158
+
159
+ A start runs as two commands: the preparation (daemon, login, seed, stub)
160
+ changes nothing a running container serves from, so its failure leaves a
161
+ runtime of the previous image serving and the lease ready; the container start
162
+ that follows replaces that container, so its failure is a boot failure the
163
+ manager demotes. The container carries the stub's label, so a start finds a
164
+ container of its own image (one Boat created again after a stop that was not
165
+ this driver's, or the one the stop kept) and starts it rather than replacing
166
+ it. Boat's restore creates a resumed sandbox's containers again about 2 s after
167
+ Docker answers; when that create takes the runtime's name after the start looked
168
+ for it, Docker answers that the name is in use, and the start waits a second
169
+ (Docker holds the name from the start of Boat's create) and looks again, up to
170
+ five times: this image's container (the one the stop kept, created again by
171
+ Boat) is started, and another image's is removed and the runtime created again.
172
+ Removing Boat's container instead lost the race at all five creates on a
173
+ machine's first wake, as Boat created it again each time (2026-10-15). Docker named volumes were measured and rejected: Boat
174
+ saves them through a path of its own, and a stop with 3.7 GB of them had not
175
+ finished after ten minutes, while the same bytes under the work directory
176
+ archived in 27 s.
177
+
178
+ `stop` ends the runtime container with the 30 s grace Boat's own stop gives
179
+ and keeps it, so Boat's snapshot records it stopped: the restore creates it
180
+ again without starting it, and the start starts it after writing this boot's
181
+ env file (each wake's runtime read that wake's env, 2026-10-15). Removed, it
182
+ left Boat the set an earlier snapshot recorded, and Boat created and started the
183
+ runtime again from it on the previous boot's env file. `host <port> --public` is
184
+ sent once more when Boat answers that it could not stop the port's localhost
185
+ relay, which is Boat's own request to try again.
186
+
187
+ Two boots of one machine can run at once: Cloudflare re-ran a start's alarm on
188
+ another machine while the first run still booted (staging, 2026-10-15), and a
189
+ retried epoch resumes the same VM, so two seeds removed each other's trees.
190
+ Each boot therefore stages its env and registry password under names of its own
191
+ (`.claxedo-runtime-env.<boot>`, moved into place by its start), and its
192
+ preparation, container start and `host --public` each hold `flock` on
193
+ `/run/lock/claxedo-boot.lock` (two publishes of one port at once made Boat
194
+ answer 500 on its own record of it). A second boot waits for the first's command to
195
+ end, then finds the seed complete and the first's container, and starts it. The
196
+ kernel releases a lock with its holder, so a holder Boat ended (a command past
197
+ its time limit) releases it at once, and one whose caller went away holds it
198
+ until its command ends, which Boat lets run (measured 2026-10-15). A wait past
199
+ the command's limit less 60 s fails the boot with that reason. Staged files
200
+ older than the 30-minute boot deadline belong to no running boot and are removed.
201
+ A resume Boat refuses with `sandbox_resuming`, because the other boot resumed the
202
+ sandbox first, waits for that resume instead of failing.
203
+
204
+ ### Boat runtime template
205
+
206
+ Boat has no custom image: a named snapshot is its template
207
+ ([named snapshots](https://docs.boat.dev/snapshots/templates)). The release
208
+ saves one per runtime image in its own Boat account, and every organization's
209
+ key creates its sandboxes from it with a share code.
210
+
211
+ What a template holds: a machine on which the driver already booted the image,
212
+ then reset. It keeps `claxedo-persistent/image-<image>/` (the trees, `stub.tar`,
213
+ `image.env`, marked complete) and Boat's `~/.ascii/playbook.json`, the record of
214
+ the files the runtime opened first, which Boat downloads first at every start
215
+ from the template. It holds no image and no Boat archive of one (the start
216
+ removed them once the runtime ran from the stub), no container, no workspace,
217
+ no runtime state and no boot env (`boatTemplateResetCommand`). On Boat,
218
+ 2026-10-15, the template of the staging image was 4.46 GB.
219
+
220
+ Lifecycle, in `drivers/boat-template.ts`, run by
221
+ `bun run --cwd packages/claxedo-server sandbox:boat-template --image=<image>`:
222
+
223
+ 1. **Name.** `claxedo-rt-` and the first 12 hex digits of the SHA-256 of the
224
+ image reference. The reference is content-addressed (its tag names the
225
+ build) and names the trees directory, so it is the template's key.
226
+ 2. **Build, once per image.** A ready template is reused and one still saving
227
+ is waited for; a missing or failed one is built on a builder sandbox: the
228
+ Boat driver boots the image there (pull, seed, stub, runtime healthy), the
229
+ builder is stopped and woken (Boat records what a sandbox opens first only
230
+ while it starts from a snapshot), the build waits for `hydrated` (Boat writes
231
+ the record once the download ends), resets the machine, stops it, and saves
232
+ it under the name. The builder is deleted whatever the outcome. On Boat,
233
+ 2026-10-15, a build took 144 s: boot 56 s, stop 25 s, wake 31 s, hydration
234
+ 7 s, reset and stop 14 s, save 8 s.
235
+ 3. **Share, at every deploy.** A share code (`bsh_…`) is a secret: whoever holds
236
+ it deploys the template, and Boat shows it once. Before the Worker deploy,
237
+ `--share-to` mints a new code, revoking none, and the deploy uploads
238
+ `CLAXEDO_BOAT_RUNTIME_TEMPLATE` (`{"image", "shareCode"}`) as a Worker secret.
239
+ Only after that deploy succeeded does `--deployed-share=<share id>` revoke
240
+ every other code of the template, so a failed deploy leaves the live
241
+ Worker's code working.
242
+ 4. **Use.** `ensureHost` creates a sandbox whose image is the template's
243
+ `from` the share code, under a create key of its own
244
+ (`claxedo:<workspace>:<epoch>:template`: Boat refuses a key sent again with
245
+ another body for 24 h). Its first start finds the trees seeded and loads the
246
+ stub. A template of another image is never used. When the organization's key
247
+ cannot read the template (`snapshot_not_found` for a wrong, revoked or
248
+ removed code; `api_key_action_forbidden` or `api_key_sandbox_forbidden` for a
249
+ key without `snapshot.read`), the driver logs it and creates the sandbox that
250
+ pulls and seeds; any other refusal fails the start as before. A wake resumes
251
+ the sandbox it has and never touches the template.
252
+ 5. **Prune.** After a successful deploy, the newest three templates of the
253
+ prefix are kept, the deployed image's among them, and the rest removed; Boat
254
+ removes their share codes with them. Pruning never runs before a deploy
255
+ succeeded, so it never removes the template the live Worker creates from.
256
+
257
+ Cost: an account keeps 10 named snapshots free, then pays $1.70 a month for
258
+ each, so three templates fit in the free ten unless the account saves others.
259
+ A sandbox created from a share code is billed to the account that creates it.
260
+ A deploy from a share code does not get the source's per-sandbox env, and Boat
261
+ strips the source's logins, keys and SSH keys from it; the builder is created
262
+ with `noEnv` and its reset removes everything a boot wrote outside the image's
263
+ trees.
264
+
65
265
  ## Credentials & secrets
66
266
 
67
267
  There are two distinct channels for getting values into a sandbox, chosen by
@@ -89,7 +289,7 @@ await manager.ensure(workspaceId, {
89
289
  name: "NOTION_TOKEN",
90
290
  value: notionToken, // never enters the sandbox in plaintext
91
291
  hosts: ["api.notion.com"], // substituted/injected only for these hosts
92
- header: "Authorization", // required for Vercel; optional for Daytona
292
+ header: "Authorization", // required for Vercel
93
293
  }],
94
294
  })
95
295
  ```
@@ -98,7 +298,6 @@ Per-provider mechanism (from each provider's official docs):
98
298
 
99
299
  | Driver | `secretBrokering` | Mechanism |
100
300
  | --- | --- | --- |
101
- | Daytona | `native` | `daytona.secret.create({name, value, hosts})`; the sandbox env holds an opaque `dtn_secret_…` placeholder and an egress proxy substitutes the real value only for allowlisted hosts ([docs](https://www.daytona.io/docs/en/secrets/)). |
102
301
  | Vercel | `native` | Firewall header-transform (`updateNetworkPolicy`): the value is spliced onto egress to the allowlisted hosts as `header`, so the sandbox makes an unauthenticated request ([docs](https://vercel.com/docs/sandbox/concepts/firewall)). `updateNetworkPolicy` replaces the whole policy, so the driver sends the **union** of the create-time allow-list and the brokered hosts — attaching a credential must not revoke egress the caller was already granted (a mid-run `npm install` would start failing), nor widen it to a brokered host the create-time policy never approved. |
103
302
  | Cloudflare | `native` | The driver sends named registrations to the API-token-gated Worker. KV holds the values outside the container; native HTTPS outbound handlers replace named placeholders in registered headers. Each request reads KV, so there is no expiring container JWT. KV propagation delay still applies to rotation and withdrawal. |
104
303
  | Modal | `none` | Modal [Secrets](https://modal.com/docs/guide/secrets) are an encrypted by-reference store, but exposed as **readable env vars** inside the sandbox — they cannot satisfy the never-readable contract. |
@@ -109,13 +308,32 @@ readable env. Only `native` keeps the value out of the sandbox and provisions
109
308
  normally; a `none` driver makes the manager refuse to provision (`status:
110
309
  "unavailable", error: "secret_brokering_unsupported"`) rather than expose or
111
310
  drop the credential. Brokered secret values are also never written to labels,
112
- never logged, and never captured in a driver snapshot.
311
+ never logged, and never captured in a driver snapshot. A `none` driver's
312
+ sandbox is handed no provider secret at all: on the hosted control plane its
313
+ owner's model accounts reach the harness as placeholders spent through the
314
+ plane's own egress broker (`@claxedo/server`'s
315
+ `credentials/hosted-egress-broker/`), which the driver never sees. Its private
316
+ repository is cloned the same way: `SandboxManagerInput.leaseEnv` mints, for
317
+ the epoch and host the driver is about to boot, a broker placeholder and the
318
+ binding URL the runtime points its git at; the broker attaches the
319
+ connection's current token to that repository's smart-HTTP requests while the
320
+ lease boots or serves, and the control plane re-delivers the placeholder at
321
+ half its hour.
113
322
 
114
323
  > Native brokering keeps the original upstream URL. Cloudflare accepts its
115
324
  > named placeholder as the complete credential header, or with a Bearer prefix
116
325
  > for Authorization API-key clients. Modal remains `none`: its encrypted store
117
326
  > exposes credentials as readable environment variables inside the sandbox.
118
327
 
328
+ **Never readable is not never spendable.** The placeholder sits in every
329
+ process's environment, and the edge attaches the real value to any request to
330
+ an allowlisted host that carries it, with no condition on which session or
331
+ person sent it. Anything that runs in the sandbox can therefore spend what it
332
+ was delivered. Claxedo delivers a workspace's sandbox only its owner's chosen
333
+ provider accounts, never another person's, and treats the sandbox as shared
334
+ trust among everyone admitted to that workspace: their sessions and shells can
335
+ spend the owner's delivered accounts (owner ruling, 2026-09-28).
336
+
119
337
  ## Egress containment
120
338
 
121
339
  `SandboxManagerInput.net` restricts what the sandbox can reach on the network.
@@ -124,13 +342,13 @@ and the difference matters:
124
342
 
125
343
  - **Brokered secrets fail closed.** A driver that cannot broker refuses to
126
344
  provision (`error: "secret_brokering_unsupported"`).
127
- - **Egress does not.** Only `daytona` (names + CIDRs) and `vercel` (names) can
128
- enforce an allowlist. Every other driver declares
129
- `metadata.egressControl: "none"`, and for those the manager **withholds** the
130
- policy and provisions anyway — the sandbox runs with unrestricted egress.
345
+ - **Egress does not.** Only `vercel` (names) can enforce an allowlist. Every
346
+ other driver declares `metadata.egressControl: "none"`, and for those the
347
+ manager **withholds** the policy and provisions anyway — the sandbox runs
348
+ with unrestricted egress.
131
349
 
132
- Withholding rather than passing is deliberate: `exe`, `docker`, `modal` and
133
- `box` throw when handed a restricted policy, and `cloudflare` and the fetch
350
+ Withholding rather than passing is deliberate: `docker`, `modal` and `boat`
351
+ throw when handed a restricted policy, and `cloudflare` and the fetch
134
352
  bridge accept one and silently ignore it. Withholding at the manager means the
135
353
  throwing drivers never see a policy (their throws stay as their own last line of
136
354
  defence) and the silently-dropping ones stop pretending.
@@ -147,17 +365,6 @@ Pass `onEgressUnenforced` to route that into telemetry instead of `console.warn`
147
365
  — it receives a structured `SandboxEgressUnenforcedEvent`. Overriding the sink
148
366
  replaces the console warning, so only do it if the replacement is as visible.
149
367
 
150
- **Reuse and resume reapply the policy.** A restricted policy only reaches a
151
- provider as *creation* parameters, so a sandbox handed back rather than created
152
- — reuse in `ensureHost`, resume in `resumeHost` — would otherwise run on the
153
- policy in force when it was first created, possibly a wider one from an earlier
154
- caller. Daytona's allow-list is mutable post-create
155
- (`Sandbox.updateNetworkSettings`), so `daytona` reapplies the requested policy
156
- on both paths. A client too old to expose that call makes the driver **refuse
157
- the reuse**: a policy reported as applied but not in force is worse than no
158
- sandbox. Requesting no containment leaves the existing policy alone rather than
159
- clearing it — "not requested" is not "please unrestrict".
160
-
161
368
  `driver.metadata.egressControl` is the machine-readable source of truth, and
162
369
  `sandboxEgressDisposition(control, net)` is the pure predicate the manager uses,
163
370
  so you can ask the same question before composing. One exception stays fail
@@ -165,8 +372,8 @@ closed: a hosts-only driver handed an address-only policy is refused
165
372
  (`sandbox_egress_policy_unenforceable`) rather than degraded, because that
166
373
  driver *does* enforce egress — it just cannot express that encoding.
167
374
 
168
- > SDK conformance: the Daytona secret API and Vercel network-policy transform
169
- > shapes follow the providers' official docs and are validated structurally
375
+ > SDK conformance: the Vercel network-policy transform shapes follow the
376
+ > provider's official docs and are validated structurally
170
377
  > against the pinned SDK types; like all provider calls in this package they
171
378
  > are exercised against mocks in unit tests, with live-SDK integration
172
379
  > verified at deploy time.
@@ -1,6 +1,8 @@
1
- import type { SandboxCheckpointReference, SandboxEnsureResult, SandboxLease, SandboxLeaseStore, SandboxManagerInput, SandboxSnapshotManagerResult, SandboxTargetResult } from ".";
1
+ import type { SandboxCheckpointReference, SandboxEnsureResult, SandboxLease, SandboxLeaseStore, SandboxManagerInput, SandboxResource, SandboxSnapshotManagerResult, SandboxTargetResult } from "./contract";
2
2
  export type SandboxCheckpointRuntime = {
3
- freeze: (policy: "drain" | "interrupt") => Promise<void>;
3
+ freeze: (policy: "drain" | "interrupt", options: {
4
+ idleBefore?: number;
5
+ }) => Promise<void>;
4
6
  flush: () => Promise<void>;
5
7
  scrub: () => Promise<void>;
6
8
  resume: () => Promise<void>;
@@ -12,7 +14,18 @@ export type SandboxCheckpointRuntime = {
12
14
  export type SandboxCheckpointCaptureInput = {
13
15
  runtime: SandboxCheckpointRuntime;
14
16
  policy?: "drain" | "interrupt";
15
- retentionExpiresAt?: number;
17
+ /** Capture only a workspace that has had no work since this time; the runtime refuses the freeze otherwise. */
18
+ idleBefore?: number;
19
+ };
20
+ export type SandboxStopInput = {
21
+ /** Present when the workspace's state is captured before its host stops. */
22
+ runtime?: SandboxCheckpointRuntime;
23
+ idleBefore?: number;
24
+ expectedEpoch?: number;
25
+ /** The caller is the host: it stops itself once the lease says stopped, so the manager stops no host. */
26
+ hostStopsItself?: boolean;
27
+ /** How long the runtime had answered nothing when the caller stopped it; the stop is metered as unresponsive and the stopped lease says so. */
28
+ unresponsiveForMs?: number;
16
29
  };
17
30
  export type SandboxCheckpointRestoreInput = {
18
31
  runtime: SandboxCheckpointRuntime;
@@ -34,10 +47,22 @@ export declare function captureSandboxCheckpoint(input: {
34
47
  request: SandboxCheckpointCaptureInput;
35
48
  leaseStore: SandboxLeaseStore;
36
49
  target: (workspaceId: string) => Promise<SandboxTargetResult>;
37
- snapshot: (workspaceId: string) => Promise<SandboxSnapshotManagerResult>;
50
+ snapshot: (workspaceId: string, committed?: string) => Promise<SandboxSnapshotManagerResult>;
51
+ deleteSnapshot?: (target: SandboxResource, snapshotId: string) => Promise<void>;
52
+ /**
53
+ * The capture ends this lease: its commit stops it, recording `stoppedError`
54
+ * as why, and the runtime stays frozen for the host stop that follows. A
55
+ * runtime that does not freeze is captured unfrozen, since its host stops
56
+ * either way; only an idle stop is the runtime's to refuse.
57
+ */
58
+ endsLease?: {
59
+ stoppedError?: string;
60
+ };
38
61
  now?: () => number;
39
62
  checkpointId?: () => string;
40
63
  }): Promise<SandboxCheckpointResult>;
64
+ /** Deletes a provider snapshot no lease references; a failure leaks storage, never the checkpoint just taken. */
65
+ export declare function discardSnapshot(discard: ((target: SandboxResource, snapshotId: string) => Promise<void>) | undefined, target: SandboxResource, snapshotId: string): Promise<void>;
41
66
  export declare function restoreSandboxCheckpoint(input: {
42
67
  workspaceId: string;
43
68
  request: SandboxCheckpointRestoreInput;
@@ -1,18 +1,95 @@
1
+ // ../claxedo-helpers/dist/api-error.mjs
2
+ var PUBLIC_API_ERRORS = {
3
+ repository_provider_unauthorized: { status: 502, message: "Repository provider refused authorization" },
4
+ repository_provider_unavailable: { status: 502, message: "Repository provider is unavailable" },
5
+ host_tunnel_timeout: { status: 503, retryable: true, message: "Host tunnel timed out" },
6
+ host_tunnel_response_body_too_large: { status: 413, message: "Host tunnel response body exceeds the relay limit" },
7
+ host_tunnel_unavailable: { status: 503, retryable: true, message: "Host tunnel is unavailable" },
8
+ workspace_checkpoint_unavailable: { status: 503, retryable: true, message: "Workspace checkpoint is unavailable" },
9
+ workspace_checkpoint_conflict: { status: 409, message: "Workspace checkpoint conflicted" },
10
+ invalid_input: { family: "access", status: 400, message: "The request names an invalid value" },
11
+ organization_policy_denied: { family: "access", status: 403, message: "Organization creation is disabled for this deployment" },
12
+ org_admin_required: { family: "access", status: 403, message: "Organization administrator authority is required" },
13
+ org_owner_required: { family: "access", status: 403, message: "Only an organization owner may grant, change or remove the owner role" },
14
+ org_owner_protected: { family: "access", status: 409, message: "The organization's founding owner cannot be removed or demoted" },
15
+ team_member_org_membership_required: { family: "access", status: 403, message: "The team member must belong to the team organization" },
16
+ project_member_org_membership_required: { family: "access", status: 403, message: "The project member must belong to the project organization" },
17
+ org_membership_required: { family: "access", status: 403, message: "Organization membership is required" },
18
+ project_admin_required: { family: "access", status: 403, message: "Project administrator authority is required" },
19
+ team_not_allowed_on_personal_org: { family: "access", status: 400, message: "Personal organizations cannot contain teams" },
20
+ team_member_target_required: { family: "access", status: 400, message: "Exactly one team member target is required" },
21
+ org_invitation_invalid: { family: "access", status: 409, message: "Invitation is invalid or unavailable" },
22
+ org_invitation_pending: { family: "access", status: 409, message: "An invitation to this address is already pending" },
23
+ org_invitation_rate_limited: { family: "access", status: 429, message: "Organization invitation limit exceeded" },
24
+ org_invitation_email_mismatch: { family: "access", status: 403, message: "Sign in with the invited verified email address" },
25
+ org_invitation_delivery_unavailable: { family: "access", status: 503, message: "Invitation delivery is unavailable" },
26
+ team_not_found: { family: "access", status: 404, message: "Team not found" },
27
+ team_member_not_found: { family: "access", status: 404, message: "Team member not found" },
28
+ org_member_not_found: { family: "access", status: 404, message: "Organization member not found" },
29
+ project_not_found: { family: "access", status: 404, message: "Project not found" },
30
+ project_member_not_found: { family: "access", status: 404, message: "Project member not found" },
31
+ project_member_owner_immutable: { family: "access", status: 409, message: "The project owner's access cannot be changed" },
32
+ resource_conflict: { family: "access", status: 409, message: "Organization or team authority changed concurrently" },
33
+ session_not_found: { family: "session_share", status: 404, message: "This session is not on the control plane, so it cannot be shared from People yet." },
34
+ session_share_admin_required: { family: "session_share", status: 403, message: "Only the person who started this session can manage its People." },
35
+ session_share_target_required: { family: "session_share", status: 400, message: "A session is shared with exactly one person" },
36
+ session_share_target_not_found: { family: "session_share", status: 404, message: "That person was not found in this organization." },
37
+ session_share_level_invalid: { family: "session_share", status: 400, message: "A share level is either follow or send." },
38
+ workspace_backing_conflict: { status: 409, message: "Workspace is provisioned in the cloud and cannot be placed on a machine" },
39
+ invalid_session_list_cursor: { status: 400, message: "Session list cursor does not match this query" },
40
+ BAD_REQUEST: { status: 400, message: "Invalid request" },
41
+ INTERNAL_SERVER_ERROR: { status: 500, message: "Internal error" },
42
+ CONTROL_PLANE_UNAVAILABLE: { status: 503, message: "Control Plane services are not configured" },
43
+ credential_verification_unsupported: { status: 400, message: "Credential provider does not support verification" },
44
+ credential_shape_invalid: { status: 400, message: "Credential secret has an unsupported shape" },
45
+ credential_provider_unavailable: { status: 502, retryable: true, message: "Credential provider request failed" },
46
+ credential_verification_failed: { status: 502, message: "Credential provider verification failed" },
47
+ credential_redirect_denied: { status: 502, message: "Sandbox provider redirects are not allowed" },
48
+ credential_endpoint_invalid: { status: 400, message: "Invalid credential provider endpoint" },
49
+ host_attestation_denied: { status: 403, message: "host_attestation_denied" },
50
+ signature_replayed: { status: 409, message: "signature_replayed" },
51
+ host_enrollment_not_found: { status: 404, message: "host_enrollment_not_found" },
52
+ enrollment_revoked: { status: 403, message: "enrollment_revoked" },
53
+ enrollment_paused: { status: 403, message: "enrollment_paused" },
54
+ enrollment_owner_ineligible: { status: 403, message: "enrollment_owner_ineligible" },
55
+ enrollment_key_version_mismatch: { status: 403, message: "enrollment_key_version_mismatch" },
56
+ invitation_invalid: { status: 403, message: "invitation_invalid" },
57
+ invitation_expired: { status: 410, message: "invitation_expired" },
58
+ invitation_revoked: { status: 410, message: "invitation_revoked" },
59
+ invitation_redeemed: { status: 409, message: "invitation_redeemed" },
60
+ invitation_host_conflict: { status: 409, message: "invitation_host_conflict" },
61
+ enrollment_generation_superseded: { status: 409, message: "enrollment_generation_superseded" },
62
+ host_assignment_outside_scope: { status: 400, message: "host_assignment_outside_scope" },
63
+ host_sealing_key_undeclared: { status: 409, message: "host_sealing_key_undeclared" },
64
+ host_provider_config_revision_stale: { status: 409, message: "host_provider_config_revision_stale" },
65
+ identity_conflict: { status: 409, message: "identity_conflict" },
66
+ registration_transition_denied: { status: 403, message: "registration_transition_denied" },
67
+ session_reservation_spent: { status: 409, message: "This session's create was undone; reserve it again to retry" },
68
+ actor_authorization_denied: { status: 403, message: "actor_authorization_denied" }
69
+ };
70
+ function publicApiErrorShape(code) {
71
+ const entry = PUBLIC_API_ERRORS[code];
72
+ return { status: entry.status, retryable: entry.retryable === true };
73
+ }
74
+ function publicApiFailure(code, message = PUBLIC_API_ERRORS[code].message) {
75
+ return Object.assign(new Error(message), { code, ...publicApiErrorShape(code) });
76
+ }
77
+
1
78
  // src/checkpoint-manager.ts
2
79
  async function captureSandboxCheckpoint(input) {
3
80
  const lease = await requireLease(input.leaseStore, input.workspaceId);
4
- if (lease.status !== "ready") throw new Error("workspace_checkpoint_lease_not_ready");
81
+ if (lease.status !== "ready") throw publicApiFailure("workspace_checkpoint_conflict", "workspace_checkpoint_lease_not_ready");
5
82
  const persistence = lease.persistence;
6
- if (!persistence || persistence.capture === "none") throw new Error("workspace_checkpoint_unsupported");
83
+ if (!persistence || persistence.capture === "none") throw publicApiFailure("workspace_checkpoint_conflict", "workspace_checkpoint_unsupported");
7
84
  const target = await input.target(input.workspaceId);
8
- if (target.status !== "ready") throw new Error(target.reason);
9
- await input.request.runtime.freeze(input.request.policy ?? "drain");
85
+ if (target.status !== "ready") throw publicApiFailure("workspace_checkpoint_unavailable", target.reason);
86
+ const discard = persistence.capture === "same-resource" ? void 0 : input.deleteSnapshot;
10
87
  let capturedSource = false;
88
+ let result;
11
89
  try {
12
- await input.request.runtime.flush();
13
- await input.request.runtime.scrub();
14
- const captured = persistence.capture === "same-resource" ? { ok: true, snapshotId: target.driverResourceId ?? target.sandboxId } : await input.snapshot(input.workspaceId);
15
- if (!captured.ok) throw new Error(captured.reason);
90
+ const frozen = await freezeForCapture(input.request, input.endsLease !== void 0, input.workspaceId);
91
+ const captured = persistence.capture === "same-resource" ? { ok: true, snapshotId: target.driverResourceId ?? target.sandboxId } : await input.snapshot(input.workspaceId, (await input.leaseStore.get(input.workspaceId))?.checkpoint?.providerReference);
92
+ if (!captured.ok) throw publicApiFailure("workspace_checkpoint_conflict", captured.reason);
16
93
  capturedSource = true;
17
94
  const capturedAt = (input.now ?? Date.now)();
18
95
  const checkpoint = {
@@ -23,28 +100,59 @@ async function captureSandboxCheckpoint(input) {
23
100
  metadata: {
24
101
  scope: persistence.capture,
25
102
  sourceBehavior: requireCaptureSource(persistence.captureSource),
26
- ...input.request.retentionExpiresAt === void 0 ? {} : { retentionExpiresAt: input.request.retentionExpiresAt },
27
- restoreMount: requireRestoreMount(persistence.restoreMount)
103
+ restoreMount: requireRestoreMount(persistence.restoreMount),
104
+ ...frozen ? {} : { unfrozen: true }
28
105
  }
29
106
  };
107
+ const replaced = (await input.leaseStore.get(input.workspaceId))?.checkpoint;
30
108
  const updated = await input.leaseStore.update(input.workspaceId, lease.epoch, {
31
109
  checkpoint,
32
110
  restore: null,
33
- ...persistence.captureSource === "stopped" || persistence.captureSource === "deleted" ? { status: "stopped" } : {}
111
+ ...input.endsLease?.stoppedError ? { lastError: input.endsLease.stoppedError } : {},
112
+ ...input.endsLease || persistence.captureSource === "stopped" || persistence.captureSource === "deleted" ? { status: "stopped" } : {}
34
113
  });
35
- if (!updated) throw new Error("workspace_checkpoint_epoch_fenced");
36
- return { status: "ready", lease: updated, checkpoint };
37
- } finally {
38
- if (!capturedSource || persistence.captureSource === "preserved") {
39
- await input.request.runtime.resume();
114
+ if (!updated) {
115
+ await discardSnapshot(discard, target, captured.snapshotId);
116
+ throw publicApiFailure("workspace_checkpoint_conflict", "workspace_checkpoint_epoch_fenced");
40
117
  }
118
+ if (replaced && replaced.providerReference !== captured.snapshotId) {
119
+ await discardSnapshot(discard, target, replaced.providerReference);
120
+ }
121
+ result = { status: "ready", lease: updated, checkpoint };
122
+ } catch (error) {
123
+ if (!capturedSource || persistence.captureSource === "preserved") await thawAfterFailure(input.request.runtime, input.workspaceId);
124
+ throw error;
41
125
  }
126
+ if (!input.endsLease && persistence.captureSource === "preserved") await input.request.runtime.resume();
127
+ return result;
128
+ }
129
+ async function freezeForCapture(request, endsLease, workspaceId) {
130
+ try {
131
+ await request.runtime.freeze(request.policy ?? "drain", { idleBefore: request.idleBefore });
132
+ await request.runtime.flush();
133
+ await request.runtime.scrub();
134
+ return true;
135
+ } catch (error) {
136
+ if (!endsLease || request.idleBefore !== void 0) throw error;
137
+ console.warn("A stopping sandbox's runtime did not freeze; its files are captured as they are", { workspaceId, error: String(error) });
138
+ return false;
139
+ }
140
+ }
141
+ async function thawAfterFailure(runtime, workspaceId) {
142
+ await runtime.resume().catch((error) => {
143
+ console.warn("A runtime was not thawed after its capture failed", { workspaceId, error: String(error) });
144
+ });
145
+ }
146
+ async function discardSnapshot(discard, target, snapshotId) {
147
+ await discard?.(target, snapshotId).catch((error) => {
148
+ console.warn("A superseded sandbox snapshot could not be deleted", { workspaceId: target.workspaceId, snapshotId, error: String(error) });
149
+ });
42
150
  }
43
151
  async function restoreSandboxCheckpoint(input) {
44
152
  const lease = await requireLease(input.leaseStore, input.workspaceId);
45
153
  const checkpoint = lease.checkpoint;
46
154
  if (!checkpoint || input.request.checkpointId && checkpoint.id !== input.request.checkpointId) {
47
- throw new Error("workspace_checkpoint_not_found");
155
+ throw publicApiFailure("workspace_checkpoint_conflict", "workspace_checkpoint_not_found");
48
156
  }
49
157
  if (lease.restore?.state === "ready" && lease.restore.checkpointId === checkpoint.id) {
50
158
  return { status: "ready", lease, checkpoint };
@@ -58,7 +166,7 @@ async function restoreSandboxCheckpoint(input) {
58
166
  startedAt: (input.now ?? Date.now)()
59
167
  };
60
168
  const staged = lease.status === "ready" && lease.epoch === checkpoint.sourceEpoch ? await input.leaseStore.update(input.workspaceId, lease.epoch, { status: "stopped", restore: restoring }) : await input.leaseStore.update(input.workspaceId, lease.epoch, { restore: restoring });
61
- if (!staged) throw new Error("workspace_restore_epoch_fenced");
169
+ if (!staged) throw publicApiFailure("workspace_checkpoint_conflict", "workspace_restore_epoch_fenced");
62
170
  const result = await input.ensure(input.workspaceId, {
63
171
  homeRegion: staged.homeRegion,
64
172
  ...input.request.ensure,
@@ -81,7 +189,7 @@ async function restoreSandboxCheckpoint(input) {
81
189
  error: result.error ?? "workspace_restore_unavailable"
82
190
  }
83
191
  });
84
- throw new Error(result.error ?? "workspace_restore_unavailable");
192
+ throw publicApiFailure("workspace_checkpoint_unavailable", result.error ?? "workspace_restore_unavailable");
85
193
  }
86
194
  await input.request.runtime.reconcile({ epoch: result.epoch, checkpointId: checkpoint.id });
87
195
  const ready = await input.leaseStore.update(input.workspaceId, result.epoch, {
@@ -94,23 +202,24 @@ async function restoreSandboxCheckpoint(input) {
94
202
  completedAt: (input.now ?? Date.now)()
95
203
  }
96
204
  });
97
- if (!ready) throw new Error("workspace_restore_epoch_fenced");
205
+ if (!ready) throw publicApiFailure("workspace_checkpoint_conflict", "workspace_restore_epoch_fenced");
98
206
  return { status: "ready", lease: ready, checkpoint };
99
207
  }
100
208
  async function requireLease(store, workspaceId) {
101
209
  const lease = await store.get(workspaceId);
102
- if (!lease) throw new Error("workspace_checkpoint_lease_missing");
210
+ if (!lease) throw publicApiFailure("workspace_checkpoint_conflict", "workspace_checkpoint_lease_missing");
103
211
  return lease;
104
212
  }
105
213
  function requireCaptureSource(value) {
106
214
  if (value === "preserved" || value === "stopped" || value === "deleted") return value;
107
- throw new Error("workspace_checkpoint_capabilities_invalid");
215
+ throw publicApiFailure("workspace_checkpoint_conflict", "workspace_checkpoint_capabilities_invalid");
108
216
  }
109
217
  function requireRestoreMount(value) {
110
- if (value === "same-resource" || value === "copy-on-write" || value === "new-resource") return value;
111
- throw new Error("workspace_checkpoint_capabilities_invalid");
218
+ if (value === "same-resource" || value === "new-resource") return value;
219
+ throw publicApiFailure("workspace_checkpoint_conflict", "workspace_checkpoint_capabilities_invalid");
112
220
  }
113
221
  export {
114
222
  captureSandboxCheckpoint,
223
+ discardSnapshot,
115
224
  restoreSandboxCheckpoint
116
225
  };
package/dist/command.d.ts CHANGED
@@ -1,3 +1,10 @@
1
1
  export declare function shell(input: string): string;
2
- export declare function file(...parts: string[]): string;
3
- export declare function env(input: Record<string, string>): string;
2
+ /**
3
+ * Env rendered as a sourceable script (one `export KEY='value'` line per entry)
4
+ * so drivers can deliver it through a provider file channel and have the
5
+ * workload source it, instead of serializing values into exec argv where
6
+ * command logs and process listings expose them. Names that `export` cannot
7
+ * carry are refused outright — silently dropping them would boot the runtime
8
+ * missing configuration.
9
+ */
10
+ export declare function envFile(input: Record<string, string>): string;