@claxedo/sandbox-manager 0.10.1 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +100 -30
  2. package/dist/checkpoint-manager.mjs +19 -0
  3. package/dist/contract.d.ts +40 -17
  4. package/dist/driver-billing.d.ts +23 -0
  5. package/dist/driver-catalog.mjs +57 -3
  6. package/dist/driver-ensure-input.d.ts +22 -0
  7. package/dist/drivers/agent37-client.d.ts +87 -0
  8. package/dist/drivers/agent37.d.ts +25 -0
  9. package/dist/drivers/agent37.mjs +888 -0
  10. package/dist/drivers/boat-client.d.ts +4 -4
  11. package/dist/drivers/boat-machine.d.ts +80 -0
  12. package/dist/drivers/boat-preparation.test-support.d.ts +2 -0
  13. package/dist/drivers/boat-template.mjs +196 -92
  14. package/dist/drivers/boat.d.ts +0 -17
  15. package/dist/drivers/boat.mjs +200 -108
  16. package/dist/drivers/cloudflare.d.ts +5 -9
  17. package/dist/drivers/cloudflare.mjs +71 -7
  18. package/dist/drivers/docker.mjs +57 -2
  19. package/dist/drivers/local-brokering.d.ts +12 -0
  20. package/dist/drivers/local-brokering.mjs +99 -24
  21. package/dist/drivers/modal.mjs +57 -2
  22. package/dist/drivers/transient-error.d.ts +0 -1
  23. package/dist/drivers/vercel.mjs +61 -3
  24. package/dist/egress-policy.d.ts +33 -32
  25. package/dist/egress-warnings.d.ts +4 -6
  26. package/dist/hosted-network-policy.d.ts +10 -64
  27. package/dist/hosted-network-policy.mjs +1 -1
  28. package/dist/image-name.d.ts +0 -1
  29. package/dist/image-name.mjs +5 -0
  30. package/dist/image.d.ts +1 -0
  31. package/dist/image.mjs +6 -0
  32. package/dist/index.mjs +180 -112
  33. package/dist/json.d.ts +1 -10
  34. package/dist/lease-failure.d.ts +20 -1
  35. package/dist/lease-types.d.ts +1 -10
  36. package/dist/runtime-env.d.ts +6 -0
  37. package/dist/runtime-env.mjs +5 -0
  38. package/dist/runtime-version.d.ts +25 -7
  39. package/dist/runtime-version.mjs +13 -2
  40. package/dist/start-admission.d.ts +32 -0
  41. package/dist/start-telemetry.d.ts +5 -3
  42. package/package.json +12 -4
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`, or `createBoatSandboxDriver` (all under
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. The persistent root is created as root too: Docker
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. 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.
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.** `claxedo-rt-` and the first 12 hex digits of the SHA-256 of the
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, with a **different** posture,
341
- and the difference matters:
342
-
343
- - **Brokered secrets fail closed.** A driver that cannot broker refuses to
344
- provision (`error: "secret_brokering_unsupported"`).
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.
349
-
350
- Withholding rather than passing is deliberate: `docker`, `modal` and `boat`
351
- throw when handed a restricted policy, and `cloudflare` and the fetch
352
- bridge accept one and silently ignore it. Withholding at the manager means the
353
- throwing drivers never see a policy (their throws stay as their own last line of
354
- defence) and the silently-dropping ones stop pretending.
355
-
356
- The gap is never silent. `createSandboxManager` warns at composition, and again
357
- each time a policy is withheld:
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 "cloudflare" declares
361
- egressControl: "none", so workspace ws_1 can reach ANY host on the internet …
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 manager uses,
370
- so you can ask the same question before composing. One exception stays fail
371
- closed: a hosts-only driver handed an address-only policy is refused
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" },
@@ -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
- * - `"none"` — the driver cannot express an egress allowlist. This covers
106
- * BOTH drivers that throw when handed a restricted policy (docker, boat,
107
- * and modal for host policy) AND — more dangerously — drivers that
108
- * silently ignore `net` and run wide open (cloudflare, the fetch bridge).
109
- * Modal is `"none"` even though it can cut the network entirely: a total
110
- * blackout is not an allowlist and cannot serve a workspace that has to
111
- * clone a repo and reach a model provider.
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 WITHHOLDS (loudly)
115
- * where it cannot. See `SandboxEgressDisposition` for the posture and
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, withheld
502
- * (with a warning) by one that declares `egressControl: "none"`.
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; its next ensure boots the 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 loud half of "enforce where we can, document where we can't".
675
- * Called at composition time and again every time a restricted policy is
676
- * withheld from a driver that cannot enforce it.
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
@@ -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;
@@ -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
- // Credential handlers do not restrict unrelated destinations.
237
- egressControl: "none",
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;