@claxedo/sandbox-manager 0.10.1 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -30
- package/dist/checkpoint-manager.mjs +19 -0
- package/dist/contract.d.ts +40 -17
- package/dist/driver-billing.d.ts +23 -0
- package/dist/driver-catalog.mjs +57 -3
- package/dist/driver-ensure-input.d.ts +22 -0
- package/dist/drivers/agent37-client.d.ts +87 -0
- package/dist/drivers/agent37.d.ts +25 -0
- package/dist/drivers/agent37.mjs +888 -0
- package/dist/drivers/boat-client.d.ts +4 -4
- package/dist/drivers/boat-machine.d.ts +80 -0
- package/dist/drivers/boat-preparation.test-support.d.ts +2 -0
- package/dist/drivers/boat-template.mjs +196 -92
- package/dist/drivers/boat.d.ts +0 -17
- package/dist/drivers/boat.mjs +200 -108
- package/dist/drivers/cloudflare.d.ts +5 -9
- package/dist/drivers/cloudflare.mjs +71 -7
- package/dist/drivers/docker.mjs +57 -2
- package/dist/drivers/local-brokering.d.ts +12 -0
- package/dist/drivers/local-brokering.mjs +99 -24
- package/dist/drivers/modal.mjs +57 -2
- package/dist/drivers/transient-error.d.ts +0 -1
- package/dist/drivers/vercel.mjs +61 -3
- package/dist/egress-policy.d.ts +33 -32
- package/dist/egress-warnings.d.ts +4 -6
- package/dist/hosted-network-policy.d.ts +10 -64
- package/dist/hosted-network-policy.mjs +1 -1
- package/dist/image-name.d.ts +0 -1
- package/dist/image-name.mjs +5 -0
- package/dist/image.d.ts +1 -0
- package/dist/image.mjs +6 -0
- package/dist/index.mjs +180 -112
- package/dist/json.d.ts +1 -10
- package/dist/lease-failure.d.ts +20 -1
- package/dist/lease-types.d.ts +1 -10
- package/dist/runtime-env.d.ts +6 -0
- package/dist/runtime-env.mjs +5 -0
- package/dist/runtime-version.d.ts +25 -7
- package/dist/runtime-version.mjs +13 -2
- package/dist/start-admission.d.ts +32 -0
- package/dist/start-telemetry.d.ts +5 -3
- package/package.json +12 -4
package/README.md
CHANGED
|
@@ -70,7 +70,8 @@ await manager.stop("workspace-1")
|
|
|
70
70
|
|
|
71
71
|
Swap `createDockerSandboxDriver` for `createModalSandboxDriver`,
|
|
72
72
|
`createVercelSandboxDriver`,
|
|
73
|
-
`createCloudflareSandboxDriver`,
|
|
73
|
+
`createCloudflareSandboxDriver`, `createBoatSandboxDriver`, or
|
|
74
|
+
`createAgent37SandboxDriver` (all under
|
|
74
75
|
`@claxedo/sandbox-manager/drivers/*`) to place on a hosted provider instead —
|
|
75
76
|
see [`docs/architecture.md`](docs/architecture.md) for the full driver
|
|
76
77
|
comparison and each provider's required options. Swap `createMemoryLeaseStore`
|
|
@@ -83,6 +84,37 @@ tail and container exit information, with staged environment and registry secret
|
|
|
83
84
|
values redacted before truncation. A diagnostic request failure preserves the
|
|
84
85
|
original health failure.
|
|
85
86
|
|
|
87
|
+
### Agent37: one container per instance
|
|
88
|
+
|
|
89
|
+
An Agent37 instance runs one container of a registered image in a gVisor
|
|
90
|
+
sandbox with a disk of its own, so the driver has no VM to prepare. It
|
|
91
|
+
registers the sandbox image as a template on the account the first time that
|
|
92
|
+
account boots it (34 s for the 3.7 GB image), named from the image and the
|
|
93
|
+
command, with a command that boots the runtime. The boot env travels as the
|
|
94
|
+
one instance variable `CLAXEDO_RUNTIME_ENV`, a sourceable script the command
|
|
95
|
+
evaluates and unsets: Agent37 merges an env edit into what it stores and never
|
|
96
|
+
returns the stored names, so separate variables could not be cleared at a later
|
|
97
|
+
boot. The runtime's port is a public port of the instance, a URL that asks for
|
|
98
|
+
no credential, and the runtime authenticates every route but `/global/health`.
|
|
99
|
+
|
|
100
|
+
A stop keeps the whole filesystem except `/tmp` and releases CPU and memory;
|
|
101
|
+
the disk is billed until the instance is deleted. A resume stores the boot's
|
|
102
|
+
env, network policy and labels, then starts the instance, which is what hands a
|
|
103
|
+
new container the stored env. An image update (`update` onto the new image's
|
|
104
|
+
template) keeps only the instance's mount path, `/claxedo-persistent`, so the
|
|
105
|
+
workspace, `~/.claxedo` and `~/.workspace-runtime` are symlinks into it; the
|
|
106
|
+
sandbox refuses bind mounts. A resume keeps the image the instance last ran
|
|
107
|
+
unless the person asked for the deployment's or its runtime is older than
|
|
108
|
+
`MINIMUM_SERVED_WORKSPACE_RUNTIME_VERSION`.
|
|
109
|
+
|
|
110
|
+
Measured 2026-11-11 with `scripts/agent37-live-check.ts` on the 2 vCPU / 4 GB
|
|
111
|
+
shape, from the driver call to the runtime's first healthy answer: 3.5 s cold
|
|
112
|
+
with the template registered (33 s the first time a host pulled the image),
|
|
113
|
+
1.1 s to stop, 4.2 s to resume, 5.7 s to resume a running instance onto another
|
|
114
|
+
size. A runtime that cannot start is reported 13 s after the create with its
|
|
115
|
+
container's log. Not measured: Codex's command sandbox under gVisor, which
|
|
116
|
+
refused `unshare -m` as root and has no `bwrap` on the image's path.
|
|
117
|
+
|
|
86
118
|
### Boat: what survives a stop, and what survives an image update
|
|
87
119
|
|
|
88
120
|
A Boat sandbox is a Linux VM whose disk Boat snapshots at every stop; the
|
|
@@ -108,7 +140,13 @@ nothing. Without a template, or when the organization's key cannot read it, or
|
|
|
108
140
|
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
141
|
image, streams the trees out of a created container of it with `docker cp` and
|
|
110
142
|
unpacks them as root, leaving out `~/.cache` (which Boat's own snapshots leave
|
|
111
|
-
out too) and npm's cache.
|
|
143
|
+
out too) and npm's cache. Boat's link to ghcr resets mid-pull (2 of 8 staging
|
|
144
|
+
updates, `failed to copy: … connection reset by peer`), and
|
|
145
|
+
Docker ends a pull at the first reset, keeping the layers it finished and none
|
|
146
|
+
of the ones in flight. So the pull runs again after a transport failure
|
|
147
|
+
(connection reset, unexpected EOF, a timeout) for as long as each run finishes
|
|
148
|
+
a layer no run before it did; any other failure, and a run that finished
|
|
149
|
+
nothing new, fails the boot with the pull's own output. The persistent root is created as root too: Docker
|
|
112
150
|
made it as root on every sandbox that booted before the trees existed, and the
|
|
113
151
|
VM user cannot create inside it. The seed then saves beside the trees what the
|
|
114
152
|
container needs besides them: the image's env (`image.env`, given to the
|
|
@@ -152,9 +190,35 @@ the container leaves `running`/`paused`. A sandbox still archiving from a stop,
|
|
|
152
190
|
which Boat offers no wait for, is read again after 250 ms, doubling to 2 s.
|
|
153
191
|
|
|
154
192
|
A wake that pulled (before the stub) took 57–77 s, and failed when ghcr reset
|
|
155
|
-
the connection.
|
|
156
|
-
|
|
157
|
-
|
|
193
|
+
the connection. A wake whose machine holds only an older image keeps serving
|
|
194
|
+
that image: its trees, stub and env are there, and the runtime in them is no
|
|
195
|
+
older than `MINIMUM_SERVED_WORKSPACE_RUNTIME_VERSION`. The wake answers
|
|
196
|
+
with that image's label, so the workspace reads as outdated, instead of waiting
|
|
197
|
+
60–100 s on the new image's pull and seed (staging). The update
|
|
198
|
+
runs only when a start asks for it with `updateImage` (a person's Restart to
|
|
199
|
+
update): the new image is pulled and seeded as above, its stub replaces the
|
|
200
|
+
old one, and the old image's trees are removed. That step is a start of its
|
|
201
|
+
own on the serving lease's epoch, whose phases are timed from the update. An
|
|
202
|
+
update whose step fails leaves the lease serving its image and answers the
|
|
203
|
+
start with the driver's reason, which the manager logs, and a start asking for
|
|
204
|
+
the update of a workspace already serving the deployment's image answers it as
|
|
205
|
+
it is. A cold start, and a wake whose older runtime is
|
|
206
|
+
below the floor, takes the new image at once. On staging (0.10.1
|
|
207
|
+
to 0.10.2) the wake that kept the image answered in 22 s and the update's step
|
|
208
|
+
ran 92.7 s.
|
|
209
|
+
|
|
210
|
+
`MINIMUM_SERVED_WORKSPACE_RUNTIME_VERSION` (`src/runtime-version.ts`) is the one
|
|
211
|
+
floor for every driver: the oldest runtime this control plane and relay serve,
|
|
212
|
+
whether a Cloudflare restore brings it back or a Boat wake keeps it. Below it a
|
|
213
|
+
workspace updates and keeps its files on every driver: a Boat wake takes the
|
|
214
|
+
new image over the same `/workspace` and homes, and a Cloudflare restore
|
|
215
|
+
carries the repository and HOME into the current image. A runtime
|
|
216
|
+
security fix, or a control-plane or relay contract an older runtime cannot
|
|
217
|
+
speak, raises the floor and the workspace-runtime version together, in the
|
|
218
|
+
commit that makes the change; a test keeps the floor at or below the runtime
|
|
219
|
+
version the tree builds. Each connection names the image it serves
|
|
220
|
+
(`runtimeImage`), and every mint of an outdated one is the
|
|
221
|
+
`sandbox.runtime_outdated_served` event.
|
|
158
222
|
|
|
159
223
|
A start runs as two commands: the preparation (daemon, login, seed, stub)
|
|
160
224
|
changes nothing a running container serves from, so its failure leaves a
|
|
@@ -220,9 +284,12 @@ no runtime state and no boot env (`boatTemplateResetCommand`). On Boat,
|
|
|
220
284
|
Lifecycle, in `drivers/boat-template.ts`, run by
|
|
221
285
|
`bun run --cwd packages/claxedo-server sandbox:boat-template --image=<image>`:
|
|
222
286
|
|
|
223
|
-
1. **Name.**
|
|
287
|
+
1. **Name.** A prefix and the first 12 hex digits of the SHA-256 of the
|
|
224
288
|
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.
|
|
289
|
+
build) and names the trees directory, so it is the template's key. The
|
|
290
|
+
prefix defaults to `claxedo-rt-`; deploy-cloud.yml passes
|
|
291
|
+
`--prefix=claxedo-rt-<environment>-`, since one Boat account holds every
|
|
292
|
+
environment's templates and pruning (step 5) works within a prefix.
|
|
226
293
|
2. **Build, once per image.** A ready template is reused and one still saving
|
|
227
294
|
is waited for; a missing or failed one is built on a builder sandbox: the
|
|
228
295
|
Boat driver boots the image there (pull, seed, stub, runtime healthy), the
|
|
@@ -337,28 +404,31 @@ spend the owner's delivered accounts (owner ruling, 2026-09-28).
|
|
|
337
404
|
## Egress containment
|
|
338
405
|
|
|
339
406
|
`SandboxManagerInput.net` restricts what the sandbox can reach on the network.
|
|
340
|
-
It is a *separate* control from brokered secrets,
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
407
|
+
It is a *separate* control from brokered secrets, and both fail closed:
|
|
408
|
+
|
|
409
|
+
- **Brokered secrets.** A driver that cannot broker refuses to provision
|
|
410
|
+
(`error: "secret_brokering_unsupported"`).
|
|
411
|
+
- **Egress.** `cloudflare` and `vercel` (names) and `agent37` (names and IPv4
|
|
412
|
+
ranges) enforce an allowlist. `docker`,
|
|
413
|
+
`modal` and `boat` declare `metadata.egressControl: "none"` and throw when
|
|
414
|
+
handed a restricted policy, so a start with one on those drivers is refused
|
|
415
|
+
before a lease exists (`error: "sandbox_egress_uncontained:<driver>"`), and
|
|
416
|
+
`sandboxStartFailureReason` turns that into the one line a person reads.
|
|
417
|
+
|
|
418
|
+
A start may instead pass `uncontainedEgress: "allow"` in its
|
|
419
|
+
`SandboxManagerInput`: the manager then **withholds** the policy and provisions
|
|
420
|
+
anyway, so the sandbox runs with unrestricted egress. The setting is read per
|
|
421
|
+
start, not composed into the manager, because it is the workspace owner's
|
|
422
|
+
decision (the hosted control plane reads its organization's opt-in at each
|
|
423
|
+
start), and a withdrawn allowance refuses the very next start. Withholding
|
|
424
|
+
rather than passing keeps the throwing drivers' throws as their own last line
|
|
425
|
+
of defence. That gap is never silent: the manager warns on the first withheld
|
|
426
|
+
start per driver per isolate:
|
|
358
427
|
|
|
359
428
|
```text
|
|
360
|
-
[sandbox-manager] SANDBOX EGRESS IS UNRESTRICTED: driver "
|
|
361
|
-
egressControl: "none"
|
|
429
|
+
[sandbox-manager] SANDBOX EGRESS IS UNRESTRICTED: driver "boat" declares
|
|
430
|
+
egressControl: "none" and this start allows uncontained egress, so
|
|
431
|
+
workspace ws_1 can reach ANY host on the internet …
|
|
362
432
|
```
|
|
363
433
|
|
|
364
434
|
Pass `onEgressUnenforced` to route that into telemetry instead of `console.warn`
|
|
@@ -366,9 +436,9 @@ Pass `onEgressUnenforced` to route that into telemetry instead of `console.warn`
|
|
|
366
436
|
replaces the console warning, so only do it if the replacement is as visible.
|
|
367
437
|
|
|
368
438
|
`driver.metadata.egressControl` is the machine-readable source of truth, and
|
|
369
|
-
`sandboxEgressDisposition(control, net)` is the pure predicate the
|
|
370
|
-
so you can ask the same question before composing.
|
|
371
|
-
|
|
439
|
+
`sandboxEgressDisposition(control, net, uncontained)` is the pure predicate the
|
|
440
|
+
manager uses, so you can ask the same question before composing. A hosts-only
|
|
441
|
+
driver handed an address-only policy is refused
|
|
372
442
|
(`sandbox_egress_policy_unenforceable`) rather than degraded, because that
|
|
373
443
|
driver *does* enforce egress — it just cannot express that encoding.
|
|
374
444
|
|
|
@@ -21,6 +21,11 @@ var PUBLIC_API_ERRORS = {
|
|
|
21
21
|
org_invitation_invalid: { family: "access", status: 409, message: "Invitation is invalid or unavailable" },
|
|
22
22
|
org_invitation_pending: { family: "access", status: 409, message: "An invitation to this address is already pending" },
|
|
23
23
|
org_invitation_rate_limited: { family: "access", status: 429, message: "Organization invitation limit exceeded" },
|
|
24
|
+
org_limit_reached: { family: "access", status: 403, message: "An account owns at most 3 organizations" },
|
|
25
|
+
org_creation_rate_limited: { family: "access", status: 429, message: "Organization creation limit exceeded" },
|
|
26
|
+
project_limit_reached: { family: "access", status: 402, message: "Free accounts have 3 projects. Get Cloud or Self-host for unlimited projects." },
|
|
27
|
+
seats_full: { family: "access", status: 402, message: "Add teammate seats to invite more people." },
|
|
28
|
+
email_verification_required: { family: "access", status: 403, message: "A verified email address is required" },
|
|
24
29
|
org_invitation_email_mismatch: { family: "access", status: 403, message: "Sign in with the invited verified email address" },
|
|
25
30
|
org_invitation_delivery_unavailable: { family: "access", status: 503, message: "Invitation delivery is unavailable" },
|
|
26
31
|
team_not_found: { family: "access", status: 404, message: "Team not found" },
|
|
@@ -30,6 +35,12 @@ var PUBLIC_API_ERRORS = {
|
|
|
30
35
|
project_member_not_found: { family: "access", status: 404, message: "Project member not found" },
|
|
31
36
|
project_member_owner_immutable: { family: "access", status: 409, message: "The project owner's access cannot be changed" },
|
|
32
37
|
resource_conflict: { family: "access", status: 409, message: "Organization or team authority changed concurrently" },
|
|
38
|
+
org_not_deletable: { family: "access", status: 409, message: "A personal organization goes with its account, and a deployment's with the deployment" },
|
|
39
|
+
org_has_workspaces: { family: "access", status: 409, message: "Delete the organization's workspaces first" },
|
|
40
|
+
account_owns_workspaces: { family: "access", status: 409, message: "Delete your workspaces first" },
|
|
41
|
+
account_founds_organization: { family: "access", status: 409, message: "Delete the organizations you founded first" },
|
|
42
|
+
account_has_cloud_subscription: { family: "access", status: 409, message: "Cancel Cloud before deleting this account" },
|
|
43
|
+
recent_sign_in_required: { family: "access", status: 403, message: "Sign in again to confirm this deletion" },
|
|
33
44
|
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
45
|
session_share_admin_required: { family: "session_share", status: 403, message: "Only the person who started this session can manage its People." },
|
|
35
46
|
session_share_target_required: { family: "session_share", status: 400, message: "A session is shared with exactly one person" },
|
|
@@ -53,11 +64,19 @@ var PUBLIC_API_ERRORS = {
|
|
|
53
64
|
enrollment_paused: { status: 403, message: "enrollment_paused" },
|
|
54
65
|
enrollment_owner_ineligible: { status: 403, message: "enrollment_owner_ineligible" },
|
|
55
66
|
enrollment_key_version_mismatch: { status: 403, message: "enrollment_key_version_mismatch" },
|
|
67
|
+
enrollment_denied: { status: 403, message: "enrollment_denied" },
|
|
68
|
+
enrollment_approval_expired: { status: 403, message: "enrollment_approval_expired" },
|
|
69
|
+
enrollment_awaiting_approval: { status: 403, message: "enrollment_awaiting_approval" },
|
|
56
70
|
invitation_invalid: { status: 403, message: "invitation_invalid" },
|
|
57
71
|
invitation_expired: { status: 410, message: "invitation_expired" },
|
|
58
72
|
invitation_revoked: { status: 410, message: "invitation_revoked" },
|
|
59
73
|
invitation_redeemed: { status: 409, message: "invitation_redeemed" },
|
|
60
74
|
invitation_host_conflict: { status: 409, message: "invitation_host_conflict" },
|
|
75
|
+
pairing_invalid: { status: 403, message: "pairing_invalid" },
|
|
76
|
+
pairing_expired: { status: 410, message: "pairing_expired" },
|
|
77
|
+
pairing_used: { status: 409, message: "pairing_used" },
|
|
78
|
+
pairing_owner_signed_out: { status: 410, message: "pairing_owner_signed_out" },
|
|
79
|
+
pairing_decided: { status: 409, message: "pairing_decided" },
|
|
61
80
|
enrollment_generation_superseded: { status: 409, message: "enrollment_generation_superseded" },
|
|
62
81
|
host_assignment_outside_scope: { status: 400, message: "host_assignment_outside_scope" },
|
|
63
82
|
host_sealing_key_undeclared: { status: 409, message: "host_sealing_key_undeclared" },
|
package/dist/contract.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { SandboxMachineClass, SandboxPhaseTiming, SandboxSecretBrokering, S
|
|
|
3
3
|
export type { SandboxMachineClass, SandboxSecretBrokering } from "@claxedo/sandbox-contract";
|
|
4
4
|
import { type SandboxCheckpointCaptureInput, type SandboxCheckpointRestoreInput, type SandboxCheckpointResult, type SandboxStopInput } from "./checkpoint-manager";
|
|
5
5
|
import type { SandboxLifecycleEvent } from "./lease-lifecycle";
|
|
6
|
-
import type { SandboxBrokeredSecret, SandboxEgressControl, SandboxEgressUnenforcedEvent, SandboxNetworkPolicy } from "./egress-policy";
|
|
6
|
+
import type { SandboxBrokeredSecret, SandboxEgressControl, SandboxEgressUnenforcedEvent, SandboxNetworkPolicy, SandboxUncontainedEgress } from "./egress-policy";
|
|
7
7
|
import type { SandboxLeaseFailure } from "./lease-failure";
|
|
8
8
|
export { DEFAULT_WORKSPACE_RUNTIME_PORT };
|
|
9
9
|
export type SandboxRegion = string;
|
|
@@ -101,18 +101,20 @@ export type SandboxDriverMetadata = {
|
|
|
101
101
|
* else; the encodings are alternatives, not additive requirements.
|
|
102
102
|
*
|
|
103
103
|
* - `"hosts-and-cidrs"` — the provider filters by name AND by address.
|
|
104
|
-
* - `"hosts"` — the provider filters by hostname only (Vercel firewall
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
104
|
+
* - `"hosts"` — the provider filters by hostname only (the Vercel firewall;
|
|
105
|
+
* the Cloudflare container started without internet, with each allowed
|
|
106
|
+
* host routed through the sandbox Worker).
|
|
107
|
+
* - `"none"` — the driver cannot express an egress allowlist: docker, boat
|
|
108
|
+
* and modal throw when handed a restricted policy. Modal is `"none"` even
|
|
109
|
+
* though it can cut the network entirely: a total blackout is not an
|
|
110
|
+
* allowlist and cannot serve a workspace that has to clone a repo and
|
|
111
|
+
* reach a model provider.
|
|
112
112
|
*
|
|
113
113
|
* The manager reads this to decide what the driver is handed:
|
|
114
|
-
* `sandboxEgressDisposition` enforces where it can and
|
|
115
|
-
*
|
|
114
|
+
* `sandboxEgressDisposition` enforces where it can, and where it cannot
|
|
115
|
+
* refuses the start unless the start allows uncontained egress
|
|
116
|
+
* (`SandboxManagerInput.uncontainedEgress`), in which case the policy is
|
|
117
|
+
* withheld and the gap reported. See `SandboxEgressDisposition` and
|
|
116
118
|
* `public-docs/sandbox-egress.md` for the operator-facing consequence.
|
|
117
119
|
*/
|
|
118
120
|
egressControl: SandboxEgressControl;
|
|
@@ -337,6 +339,13 @@ export type SandboxDriverEnsureInput = {
|
|
|
337
339
|
net?: SandboxNetworkPolicy;
|
|
338
340
|
/** Optional snapshot/image to boot from (user-supplied or pre-built). */
|
|
339
341
|
snapshot?: string;
|
|
342
|
+
/**
|
|
343
|
+
* The person asked for the deployment's image. Without it, a resume of a
|
|
344
|
+
* machine that holds only an older image may keep serving that one and
|
|
345
|
+
* report it, so the workspace reads as outdated instead of waiting on the
|
|
346
|
+
* new image's preparation.
|
|
347
|
+
*/
|
|
348
|
+
updateImage?: true;
|
|
340
349
|
};
|
|
341
350
|
export type SandboxTarget = {
|
|
342
351
|
workspaceId?: string;
|
|
@@ -498,11 +507,23 @@ export type SandboxManagerInput = {
|
|
|
498
507
|
source?: SandboxSource;
|
|
499
508
|
exposure?: SandboxExposure;
|
|
500
509
|
/**
|
|
501
|
-
* @see SandboxDriverEnsureInput.net — enforced by a capable driver
|
|
502
|
-
*
|
|
510
|
+
* @see SandboxDriverEnsureInput.net — enforced by a capable driver; on one
|
|
511
|
+
* that declares `egressControl: "none"` the start is refused, or the policy
|
|
512
|
+
* withheld with a warning where `uncontainedEgress` allows it.
|
|
503
513
|
*/
|
|
504
514
|
net?: SandboxNetworkPolicy;
|
|
515
|
+
/**
|
|
516
|
+
* What this start does with a restricted `net` on a driver that declares
|
|
517
|
+
* `egressControl: "none"`: refused (`sandbox_egress_uncontained`, the
|
|
518
|
+
* default), or allowed to run with the policy withheld and the gap reported
|
|
519
|
+
* through `onEgressUnenforced`. Allowing is the workspace owner's explicit
|
|
520
|
+
* decision, read per start so a withdrawn allowance refuses the next one:
|
|
521
|
+
* the sandbox then holds a private checkout and can reach any host.
|
|
522
|
+
*/
|
|
523
|
+
uncontainedEgress?: SandboxUncontainedEgress;
|
|
505
524
|
snapshot?: string;
|
|
525
|
+
/** @see SandboxDriverEnsureInput.updateImage */
|
|
526
|
+
updateImage?: true;
|
|
506
527
|
/**
|
|
507
528
|
* Called by the one ensure whose acquire created the workspace's first
|
|
508
529
|
* lease, before its driver is asked for anything; a concurrent start that
|
|
@@ -551,6 +572,8 @@ export type SandboxEnsureResult = ({
|
|
|
551
572
|
* delivered — the provider edge still holds the previous set.
|
|
552
573
|
*/
|
|
553
574
|
stale?: true;
|
|
575
|
+
/** @see SandboxTargetResult */
|
|
576
|
+
imageOutdated?: true;
|
|
554
577
|
}) | {
|
|
555
578
|
status: "provisioning";
|
|
556
579
|
retryAfterMs: number;
|
|
@@ -572,7 +595,7 @@ export type SandboxTargetResult = ({
|
|
|
572
595
|
epoch: number;
|
|
573
596
|
routingId?: string;
|
|
574
597
|
homeRegion: SandboxRegion;
|
|
575
|
-
/** The runtime booted from an image other than the driver's current one;
|
|
598
|
+
/** The runtime booted from an image other than the driver's current one; an ensure asked to `updateImage` boots the current one. */
|
|
576
599
|
imageOutdated?: true;
|
|
577
600
|
/** The start that brought this epoch up, as observed; the run still delivering its settings is timed from it. */
|
|
578
601
|
start?: SandboxEpochStart;
|
|
@@ -671,9 +694,9 @@ export type SandboxManagerOptions = {
|
|
|
671
694
|
now?: () => number;
|
|
672
695
|
appLabel?: string;
|
|
673
696
|
/**
|
|
674
|
-
* Sink for the
|
|
675
|
-
*
|
|
676
|
-
*
|
|
697
|
+
* Sink for the warning raised the first time per driver per isolate that a
|
|
698
|
+
* start's restricted policy is withheld from a driver that cannot enforce it
|
|
699
|
+
* (`SandboxManagerInput.uncontainedEgress: "allow"`).
|
|
677
700
|
*
|
|
678
701
|
* Defaults to `console.warn(event.message)`. Pass your own to route the gap
|
|
679
702
|
* into a telemetry pipeline instead — but note the default is deliberately
|
package/dist/driver-billing.d.ts
CHANGED
|
@@ -23,6 +23,29 @@ export type SandboxDriverBilling = {
|
|
|
23
23
|
excludes?: string;
|
|
24
24
|
};
|
|
25
25
|
export declare const boatBilling: SandboxDriverBilling;
|
|
26
|
+
/**
|
|
27
|
+
* The shape an Agent37 instance of each class is created at. Agent37 sells four
|
|
28
|
+
* shapes and none above 4 vCPU, so `large` is its 4 vCPU / 16 GB one. The disk
|
|
29
|
+
* holds the workspace and the runtime's state and is billed while stopped too.
|
|
30
|
+
*/
|
|
31
|
+
export declare const agent37MachineShapes: {
|
|
32
|
+
readonly small: {
|
|
33
|
+
readonly cpu: 2;
|
|
34
|
+
readonly memory: 4;
|
|
35
|
+
readonly disk: 10;
|
|
36
|
+
};
|
|
37
|
+
readonly default: {
|
|
38
|
+
readonly cpu: 4;
|
|
39
|
+
readonly memory: 8;
|
|
40
|
+
readonly disk: 20;
|
|
41
|
+
};
|
|
42
|
+
readonly large: {
|
|
43
|
+
readonly cpu: 4;
|
|
44
|
+
readonly memory: 16;
|
|
45
|
+
readonly disk: 40;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
export declare const agent37Billing: SandboxDriverBilling;
|
|
26
49
|
export declare const cloudflareBilling: SandboxDriverBilling;
|
|
27
50
|
/** The rate a sandbox of this class runs at; a driver with one size answers it for the class it was not asked for. */
|
|
28
51
|
export declare function sandboxMachineRate(billing: SandboxDriverBilling | undefined, machineClass: SandboxMachineClass | null): SandboxMachineRate | undefined;
|
package/dist/driver-catalog.mjs
CHANGED
|
@@ -26,6 +26,27 @@ var boatBilling = {
|
|
|
26
26
|
large: { size: "large", vcpu: 8, memoryGb: 16, usdPerHour: 0.072 }
|
|
27
27
|
}
|
|
28
28
|
};
|
|
29
|
+
var AGENT37_USD_PER_MONTH = { vcpu: 0.8, memoryGb: 0.7, diskGb: 0.09 };
|
|
30
|
+
var AGENT37_MONTH_HOURS = 730;
|
|
31
|
+
var agent37MachineShapes = {
|
|
32
|
+
small: { cpu: 2, memory: 4, disk: 10 },
|
|
33
|
+
default: { cpu: 4, memory: 8, disk: 20 },
|
|
34
|
+
large: { cpu: 4, memory: 16, disk: 40 }
|
|
35
|
+
};
|
|
36
|
+
function agent37Rate(shape) {
|
|
37
|
+
const usdPerMonth = shape.cpu * AGENT37_USD_PER_MONTH.vcpu + shape.memory * AGENT37_USD_PER_MONTH.memoryGb + shape.disk * AGENT37_USD_PER_MONTH.diskGb;
|
|
38
|
+
return { size: `${shape.cpu} vCPU / ${shape.memory} GB`, vcpu: shape.cpu, memoryGb: shape.memory, usdPerHour: usdPerMonth / AGENT37_MONTH_HOURS };
|
|
39
|
+
}
|
|
40
|
+
var agent37Billing = {
|
|
41
|
+
source: { url: "https://www.agent37.com/docs/agents-api/billing", readOn: "2026-11-11" },
|
|
42
|
+
meter: "Agent37 meters an instance per minute at a monthly rate over 730 hours: $0.80 per vCPU, $0.70 per GB of memory and $0.09 per GB of disk while it runs, and the disk alone while it is stopped.",
|
|
43
|
+
rates: {
|
|
44
|
+
small: agent37Rate(agent37MachineShapes.small),
|
|
45
|
+
default: agent37Rate(agent37MachineShapes.default),
|
|
46
|
+
large: agent37Rate(agent37MachineShapes.large)
|
|
47
|
+
},
|
|
48
|
+
excludes: `the disk of a stopped instance, billed at $${AGENT37_USD_PER_MONTH.diskGb} per GB a month until the instance is deleted`
|
|
49
|
+
};
|
|
29
50
|
var CLOUDFLARE_USD_PER_GIB_SECOND_MEMORY = 25e-7;
|
|
30
51
|
var CLOUDFLARE_USD_PER_GB_SECOND_DISK = 7e-8;
|
|
31
52
|
var CLOUDFLARE_USD_PER_VCPU_SECOND_ACTIVE = 2e-5;
|
|
@@ -61,7 +82,7 @@ function trimToUndefined(value) {
|
|
|
61
82
|
const trimmed = value.trim();
|
|
62
83
|
return trimmed.length > 0 ? trimmed : void 0;
|
|
63
84
|
}
|
|
64
|
-
var sandboxDriverIds = ["modal", "vercel", "cloudflare", "boat", "docker"];
|
|
85
|
+
var sandboxDriverIds = ["modal", "vercel", "cloudflare", "boat", "agent37", "docker"];
|
|
65
86
|
var sandboxDriverCredentialFields = {
|
|
66
87
|
modal: [
|
|
67
88
|
{ key: "token_id", label: "Token ID", secret: true },
|
|
@@ -77,6 +98,7 @@ var sandboxDriverCredentialFields = {
|
|
|
77
98
|
{ key: "worker_url", label: "Worker URL" }
|
|
78
99
|
],
|
|
79
100
|
boat: [{ key: "api_key", label: "API Key", secret: true }],
|
|
101
|
+
agent37: [{ key: "api_key", label: "API Key", secret: true }],
|
|
80
102
|
docker: [{ key: "image", label: "Image" }]
|
|
81
103
|
};
|
|
82
104
|
var sandboxDriverLabels = {
|
|
@@ -84,6 +106,7 @@ var sandboxDriverLabels = {
|
|
|
84
106
|
vercel: "Vercel",
|
|
85
107
|
cloudflare: "Cloudflare",
|
|
86
108
|
boat: "Boat",
|
|
109
|
+
agent37: "Agent37",
|
|
87
110
|
docker: "Docker"
|
|
88
111
|
};
|
|
89
112
|
function isSandboxDriverID(input) {
|
|
@@ -117,6 +140,10 @@ function sandboxDriverAuthValues(cfg, id, env = process.env) {
|
|
|
117
140
|
const api_key = trimToUndefined(cfg?.auth?.boat?.api_key) ?? trimToUndefined(env.BOAT_API_KEY);
|
|
118
141
|
return api_key ? { api_key } : void 0;
|
|
119
142
|
}
|
|
143
|
+
if (id === "agent37") {
|
|
144
|
+
const api_key = trimToUndefined(cfg?.auth?.agent37?.api_key) ?? trimToUndefined(env.AGENT37_API_KEY);
|
|
145
|
+
return api_key ? { api_key } : void 0;
|
|
146
|
+
}
|
|
120
147
|
if (!dockerSandboxDriverEnabled(env))
|
|
121
148
|
return;
|
|
122
149
|
const image = trimToUndefined(cfg?.auth?.docker?.image) ?? trimToUndefined(env.CLAXEDO_DOCKER_SANDBOX_IMAGE) ?? trimToUndefined(env.CLAXEDO_SANDBOX_IMAGE);
|
|
@@ -233,8 +260,8 @@ var sandboxDriverCatalog = {
|
|
|
233
260
|
hostWatchesIdle: true,
|
|
234
261
|
targetAccess: "relay",
|
|
235
262
|
secretBrokering: "native",
|
|
236
|
-
//
|
|
237
|
-
egressControl: "
|
|
263
|
+
// The sandbox Worker starts the container without internet and routes each allowed host through itself; a Worker that cannot confirm that refuses the start.
|
|
264
|
+
egressControl: "hosts",
|
|
238
265
|
// A container snapshot holds the whole root filesystem; the platform keeps it 30 days past its last restore and offers no deletion.
|
|
239
266
|
persistence: {
|
|
240
267
|
resume: "replacement-restore",
|
|
@@ -272,6 +299,33 @@ var sandboxDriverCatalog = {
|
|
|
272
299
|
machineClasses: ["small", "default", "large"]
|
|
273
300
|
}
|
|
274
301
|
},
|
|
302
|
+
agent37: {
|
|
303
|
+
id: "agent37",
|
|
304
|
+
label: sandboxDriverLabels.agent37,
|
|
305
|
+
credentialFields: sandboxDriverCredentialFields.agent37,
|
|
306
|
+
runtimeNetwork: { controlPlane: "direct", relay: "configured" },
|
|
307
|
+
runtimeEnv: "callback",
|
|
308
|
+
preparedImage: true,
|
|
309
|
+
billing: agent37Billing,
|
|
310
|
+
metadata: {
|
|
311
|
+
driverRunsIn: ["worker", "node"],
|
|
312
|
+
hostStopBehavior: "suspends-host",
|
|
313
|
+
hostResumeBehavior: "same-host",
|
|
314
|
+
targetAccess: "relay",
|
|
315
|
+
secretBrokering: "none",
|
|
316
|
+
// An instance's network policy lists domains, reached over HTTPS on 443 alone, and IPv4 ranges; private ranges stay blocked whatever it lists.
|
|
317
|
+
egressControl: "hosts-and-cidrs",
|
|
318
|
+
persistence: {
|
|
319
|
+
resume: "same-sandbox",
|
|
320
|
+
capture: "none",
|
|
321
|
+
clone: false,
|
|
322
|
+
captureSource: "not-applicable",
|
|
323
|
+
retention: "provider-managed",
|
|
324
|
+
restoreMount: "same-resource"
|
|
325
|
+
},
|
|
326
|
+
machineClasses: ["small", "default", "large"]
|
|
327
|
+
}
|
|
328
|
+
},
|
|
275
329
|
docker: {
|
|
276
330
|
id: "docker",
|
|
277
331
|
label: sandboxDriverLabels.docker,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SandboxDriver, SandboxDriverEnsureInput, SandboxLease, SandboxManagerInput, SandboxRegion } from "./contract";
|
|
2
|
+
export declare function leaseHostId(driver: SandboxDriver, workspaceId: string, lease: SandboxLease, managerInput: SandboxManagerInput | undefined): string;
|
|
3
|
+
/**
|
|
4
|
+
* Build the driver-facing ensure input.
|
|
5
|
+
*
|
|
6
|
+
* This is the ONLY place a `SandboxDriverEnsureInput` is constructed, which is
|
|
7
|
+
* what makes the egress guarantee structural rather than a promise kept by each
|
|
8
|
+
* call site: `net` is copied across only when `sandboxEgressDisposition` says
|
|
9
|
+
* the driver can enforce it. A driver declaring `egressControl: "none"` — every
|
|
10
|
+
* one of which throws on a restricted policy — cannot be reached by one through
|
|
11
|
+
* any path; `admit` has already refused the start, or the start allowed it to
|
|
12
|
+
* run uncontained.
|
|
13
|
+
*/
|
|
14
|
+
export declare function ensureHostInput(input: {
|
|
15
|
+
driver: SandboxDriver;
|
|
16
|
+
workspaceId: string;
|
|
17
|
+
lease: SandboxLease;
|
|
18
|
+
homeRegion: SandboxRegion;
|
|
19
|
+
managerInput?: SandboxManagerInput;
|
|
20
|
+
leaseEnv?: Record<string, string>;
|
|
21
|
+
appLabel: string;
|
|
22
|
+
}): SandboxDriverEnsureInput;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export type Agent37Fetch = (input: string, init?: RequestInit) => Promise<Response>;
|
|
2
|
+
declare const INSTANCE_STATUSES: readonly ["provisioning", "running", "stopping", "stopped", "starting", "restarting", "updating", "sleeping", "waking", "failed", "deleting", "deleted"];
|
|
3
|
+
export type Agent37InstanceStatus = (typeof INSTANCE_STATUSES)[number];
|
|
4
|
+
export type Agent37Resources = {
|
|
5
|
+
cpu: number;
|
|
6
|
+
memory: number;
|
|
7
|
+
disk: number;
|
|
8
|
+
};
|
|
9
|
+
/** `"deny-all"` cuts DNS too; an `allow` entry is a domain (HTTPS on 443 only) or an IPv4 address or range. */
|
|
10
|
+
export type Agent37Network = "allow-all" | "deny-all" | {
|
|
11
|
+
allow: string[];
|
|
12
|
+
};
|
|
13
|
+
export type Agent37Instance = {
|
|
14
|
+
id: string;
|
|
15
|
+
status: Agent37InstanceStatus;
|
|
16
|
+
/** The registry reference the instance's image was registered from. */
|
|
17
|
+
imageRef?: string;
|
|
18
|
+
resources: Agent37Resources;
|
|
19
|
+
metadata: Record<string, string>;
|
|
20
|
+
/** Each port's permanent URL that asks for no credential. */
|
|
21
|
+
publicPorts: {
|
|
22
|
+
port: number;
|
|
23
|
+
url: string;
|
|
24
|
+
}[];
|
|
25
|
+
};
|
|
26
|
+
export type Agent37ContainerHealth = {
|
|
27
|
+
logs: string;
|
|
28
|
+
running: boolean;
|
|
29
|
+
restartCount: number;
|
|
30
|
+
};
|
|
31
|
+
export declare class Agent37ApiError extends Error {
|
|
32
|
+
readonly code: string;
|
|
33
|
+
readonly status?: number | undefined;
|
|
34
|
+
/** Agent37 answers these to the key itself; an instance of another workspace reads as `not_found`. */
|
|
35
|
+
readonly providerKeyRefused: boolean;
|
|
36
|
+
constructor(message: string, code: string, status?: number | undefined);
|
|
37
|
+
}
|
|
38
|
+
export type Agent37ClientOptions = {
|
|
39
|
+
apiKey: string;
|
|
40
|
+
baseUrl?: string;
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
fetchImpl?: Agent37Fetch;
|
|
43
|
+
};
|
|
44
|
+
export declare function createAgent37Client(options: Agent37ClientOptions): {
|
|
45
|
+
templateExists(name: string): Promise<boolean>;
|
|
46
|
+
/** A name another registration took first holds the same image and command, since the name is derived from both. */
|
|
47
|
+
registerTemplate(input: {
|
|
48
|
+
name: string;
|
|
49
|
+
imageRef: string;
|
|
50
|
+
entrypoint: string[];
|
|
51
|
+
}): Promise<void>;
|
|
52
|
+
create(input: {
|
|
53
|
+
template: string;
|
|
54
|
+
name: string;
|
|
55
|
+
metadata: Record<string, string>;
|
|
56
|
+
env: Record<string, string>;
|
|
57
|
+
network: Agent37Network;
|
|
58
|
+
resources: Agent37Resources;
|
|
59
|
+
publicPort: number;
|
|
60
|
+
mountPath: string;
|
|
61
|
+
}): Promise<Agent37Instance>;
|
|
62
|
+
get(id: string): Promise<Agent37Instance>;
|
|
63
|
+
list(): Promise<Agent37Instance[]>;
|
|
64
|
+
/** `env` reaches the container at its next start, restart, update or resize; `network` applies to a running one within seconds; `metadata` replaces what is stored. */
|
|
65
|
+
edit(id: string, input: {
|
|
66
|
+
env?: Record<string, string>;
|
|
67
|
+
network?: Agent37Network;
|
|
68
|
+
metadata?: Record<string, string>;
|
|
69
|
+
}): Promise<Agent37Instance>;
|
|
70
|
+
start: (id: string) => Promise<"deleted" | "deleting" | "failed" | "provisioning" | "restarting" | "running" | "sleeping" | "starting" | "stopped" | "stopping" | "updating" | "waking">;
|
|
71
|
+
stop: (id: string) => Promise<"deleted" | "deleting" | "failed" | "provisioning" | "restarting" | "running" | "sleeping" | "starting" | "stopped" | "stopping" | "updating" | "waking">;
|
|
72
|
+
restart: (id: string) => Promise<"deleted" | "deleting" | "failed" | "provisioning" | "restarting" | "running" | "sleeping" | "starting" | "stopped" | "stopping" | "updating" | "waking">;
|
|
73
|
+
/** Moves the instance onto `template`, keeping only its mount path; a stopped one stays stopped and boots that image at its next start. */
|
|
74
|
+
update: (id: string, template: string) => Promise<"deleted" | "deleting" | "failed" | "provisioning" | "restarting" | "running" | "sleeping" | "starting" | "stopped" | "stopping" | "updating" | "waking">;
|
|
75
|
+
/** Recreates the running container at the new size. Disk only grows. */
|
|
76
|
+
resize: (id: string, resources: Agent37Resources) => Promise<"deleted" | "deleting" | "failed" | "provisioning" | "restarting" | "running" | "sleeping" | "starting" | "stopped" | "stopping" | "updating" | "waking">;
|
|
77
|
+
publishPort(id: string, port: number): Promise<string>;
|
|
78
|
+
delete(id: string): Promise<void>;
|
|
79
|
+
/** Runs through `sh -c` as the image's user; a command that exits nonzero is an answer, not an error. */
|
|
80
|
+
exec(id: string, command: string): Promise<{
|
|
81
|
+
exitCode: number;
|
|
82
|
+
stdout: string;
|
|
83
|
+
}>;
|
|
84
|
+
/** Answers in any state; Agent37 starts a container that exited again, and `restartCount` counts each boot after the first. */
|
|
85
|
+
containerHealth(id: string, tail: number): Promise<Agent37ContainerHealth>;
|
|
86
|
+
};
|
|
87
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type SandboxDriver, type SandboxDriverEnsureInput } from "../contract";
|
|
2
|
+
import { type WorkspaceRuntimeControlEnv } from "../runtime-env";
|
|
3
|
+
import { type Agent37Fetch } from "./agent37-client";
|
|
4
|
+
export type { Agent37Fetch };
|
|
5
|
+
export type Agent37SandboxDriverOptions = {
|
|
6
|
+
apiKey: string;
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
image: string;
|
|
9
|
+
runtimePort?: number;
|
|
10
|
+
runtimeCommand?: string;
|
|
11
|
+
workspaceDir?: string;
|
|
12
|
+
nativeHarness?: string;
|
|
13
|
+
controlEnv?: WorkspaceRuntimeControlEnv;
|
|
14
|
+
env?: (input: SandboxDriverEnsureInput, host: {
|
|
15
|
+
id: string;
|
|
16
|
+
}) => Record<string, string> | Promise<Record<string, string>>;
|
|
17
|
+
provisionTimeoutMs?: number;
|
|
18
|
+
healthTimeoutMs?: number;
|
|
19
|
+
healthIntervalMs?: number;
|
|
20
|
+
/** How long a boot waits between two reads of its container's restart count. */
|
|
21
|
+
containerCheckMs?: number;
|
|
22
|
+
operationTimeoutMs?: number;
|
|
23
|
+
fetchImpl?: Agent37Fetch;
|
|
24
|
+
};
|
|
25
|
+
export declare function createAgent37SandboxDriver(options: Agent37SandboxDriverOptions): SandboxDriver;
|