@claxedo/sandbox-manager 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +233 -26
- package/dist/checkpoint-manager.d.ts +29 -4
- package/dist/checkpoint-manager.mjs +133 -24
- package/dist/command.d.ts +9 -2
- package/dist/command.mjs +8 -8
- package/dist/contract.d.ts +687 -0
- package/dist/driver-billing.d.ts +29 -0
- package/dist/driver-catalog.d.ts +42 -7
- package/dist/driver-catalog.mjs +115 -161
- package/dist/drivers/boat-client.d.ts +95 -0
- package/dist/drivers/boat-named-snapshots.d.ts +31 -0
- package/dist/drivers/boat-template.d.ts +49 -0
- package/dist/drivers/boat-template.mjs +1196 -0
- package/dist/drivers/boat.d.ts +64 -0
- package/dist/drivers/boat.mjs +990 -0
- package/dist/drivers/cloudflare.d.ts +3 -3
- package/dist/drivers/cloudflare.mjs +208 -147
- package/dist/drivers/docker.d.ts +4 -3
- package/dist/drivers/docker.mjs +154 -126
- package/dist/drivers/local-brokering.d.ts +14 -0
- package/dist/drivers/local-brokering.mjs +1029 -0
- package/dist/drivers/modal.d.ts +2 -2
- package/dist/drivers/modal.mjs +121 -111
- package/dist/drivers/transient-error.d.ts +2 -0
- package/dist/drivers/vercel.d.ts +3 -2
- package/dist/drivers/vercel.mjs +151 -115
- package/dist/egress-policy.d.ts +134 -0
- package/dist/egress-warnings.d.ts +22 -0
- package/dist/garbage-collect.d.ts +14 -0
- package/dist/hosted-network-policy.d.ts +9 -2
- package/dist/hosted-network-policy.mjs +218 -1
- package/dist/image-name.d.ts +12 -2
- package/dist/image-name.mjs +14 -5
- package/dist/image.d.ts +1 -20
- package/dist/image.mjs +11 -88
- package/dist/index.d.ts +8 -578
- package/dist/index.mjs +1379 -320
- package/dist/lease-failure.d.ts +173 -0
- package/dist/lease-lifecycle.d.ts +65 -0
- package/dist/lease-retry-wait.d.ts +8 -0
- package/dist/lease-start.d.ts +21 -0
- package/dist/lease-types.d.ts +4 -3
- package/dist/manager.d.ts +2 -0
- package/dist/runtime-env.d.ts +4 -2
- package/dist/runtime-env.mjs +37 -2
- package/dist/runtime-snapshot.d.ts +49 -0
- package/dist/runtime-version.d.ts +9 -0
- package/dist/runtime-version.mjs +2 -0
- package/dist/service-url-exposure.d.ts +0 -1
- package/dist/service-url-exposure.mjs +0 -9
- package/dist/start-fresh.d.ts +17 -0
- package/dist/start-telemetry.d.ts +37 -0
- package/dist/stores/memory.d.ts +1 -1
- package/dist/stores/memory.mjs +63 -52
- package/package.json +26 -42
- package/dist/daytona-allow-list.d.ts +0 -16
- package/dist/daytona-allow-list.mjs +0 -11
- package/dist/drivers/box.d.ts +0 -52
- package/dist/drivers/box.mjs +0 -574
- package/dist/drivers/daytona.d.ts +0 -163
- package/dist/drivers/daytona.mjs +0 -683
- package/dist/drivers/exe.d.ts +0 -26
- package/dist/drivers/exe.mjs +0 -492
- package/dist/drivers/fetch-bridge.d.ts +0 -10
- package/dist/drivers/fetch-bridge.mjs +0 -126
- package/dist/lease-policy.d.ts +0 -52
- 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,
|
|
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 `
|
|
57
|
-
`
|
|
58
|
-
`createCloudflareSandboxDriver`, or `
|
|
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
|
|
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 `
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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: `
|
|
133
|
-
|
|
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
|
|
169
|
-
>
|
|
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"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
9
|
-
|
|
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.
|
|
13
|
-
await input.
|
|
14
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
...
|
|
111
|
+
...input.endsLease?.stoppedError ? { lastError: input.endsLease.stoppedError } : {},
|
|
112
|
+
...input.endsLease || persistence.captureSource === "stopped" || persistence.captureSource === "deleted" ? { status: "stopped" } : {}
|
|
34
113
|
});
|
|
35
|
-
if (!updated)
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
215
|
+
throw publicApiFailure("workspace_checkpoint_conflict", "workspace_checkpoint_capabilities_invalid");
|
|
108
216
|
}
|
|
109
217
|
function requireRestoreMount(value) {
|
|
110
|
-
if (value === "same-resource" || value === "
|
|
111
|
-
throw
|
|
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
|
-
|
|
3
|
-
|
|
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;
|