@claxedo/sandbox-manager 0.10.2 → 0.10.4
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 +67 -29
- package/dist/checkpoint-manager.mjs +11 -0
- package/dist/contract.d.ts +28 -16
- package/dist/driver-billing.d.ts +23 -0
- package/dist/driver-catalog.d.ts +1 -2
- package/dist/driver-catalog.mjs +61 -11
- package/dist/driver-ensure-input.d.ts +3 -2
- package/dist/drivers/agent37-client.d.ts +87 -0
- package/dist/drivers/agent37.d.ts +25 -0
- package/dist/drivers/agent37.mjs +886 -0
- package/dist/drivers/boat-client.d.ts +5 -5
- package/dist/drivers/boat-machine.d.ts +7 -17
- package/dist/drivers/boat-template.mjs +87 -35
- package/dist/drivers/boat.d.ts +0 -5
- package/dist/drivers/boat.mjs +81 -26
- package/dist/drivers/cloudflare.d.ts +5 -9
- package/dist/drivers/cloudflare.mjs +97 -43
- package/dist/drivers/docker.mjs +70 -19
- package/dist/drivers/local-brokering.mjs +90 -18
- package/dist/drivers/modal.mjs +56 -11
- package/dist/drivers/transient-error.d.ts +0 -1
- package/dist/drivers/vercel.mjs +65 -15
- 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/index.mjs +103 -78
- package/dist/json.d.ts +1 -13
- package/dist/lease-failure.d.ts +3 -3
- package/dist/lease-types.d.ts +1 -10
- package/dist/runtime-env.d.ts +6 -7
- package/dist/runtime-env.mjs +5 -10
- package/dist/runtime-version.d.ts +4 -0
- package/dist/runtime-version.mjs +6 -0
- package/dist/start-admission.d.ts +32 -0
- 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
|
|
@@ -109,7 +141,7 @@ for any other image, the first start seeds them. The seed runs once per image, a
|
|
|
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
143
|
out too) and npm's cache. Boat's link to ghcr resets mid-pull (2 of 8 staging
|
|
112
|
-
updates
|
|
144
|
+
updates, `failed to copy: … connection reset by peer`), and
|
|
113
145
|
Docker ends a pull at the first reset, keeping the layers it finished and none
|
|
114
146
|
of the ones in flight. So the pull runs again after a transport failure
|
|
115
147
|
(connection reset, unexpected EOF, a timeout) for as long as each run finishes
|
|
@@ -162,7 +194,7 @@ the connection. A wake whose machine holds only an older image keeps serving
|
|
|
162
194
|
that image: its trees, stub and env are there, and the runtime in them is no
|
|
163
195
|
older than `MINIMUM_SERVED_WORKSPACE_RUNTIME_VERSION`. The wake answers
|
|
164
196
|
with that image's label, so the workspace reads as outdated, instead of waiting
|
|
165
|
-
60–100 s on the new image's pull and seed (staging
|
|
197
|
+
60–100 s on the new image's pull and seed (staging). The update
|
|
166
198
|
runs only when a start asks for it with `updateImage` (a person's Restart to
|
|
167
199
|
update): the new image is pulled and seeded as above, its stub replaces the
|
|
168
200
|
old one, and the old image's trees are removed. That step is a start of its
|
|
@@ -171,7 +203,7 @@ update whose step fails leaves the lease serving its image and answers the
|
|
|
171
203
|
start with the driver's reason, which the manager logs, and a start asking for
|
|
172
204
|
the update of a workspace already serving the deployment's image answers it as
|
|
173
205
|
it is. A cold start, and a wake whose older runtime is
|
|
174
|
-
below the floor, takes the new image at once. On staging (
|
|
206
|
+
below the floor, takes the new image at once. On staging (0.10.1
|
|
175
207
|
to 0.10.2) the wake that kept the image answered in 22 s and the update's step
|
|
176
208
|
ran 92.7 s.
|
|
177
209
|
|
|
@@ -252,9 +284,12 @@ no runtime state and no boot env (`boatTemplateResetCommand`). On Boat,
|
|
|
252
284
|
Lifecycle, in `drivers/boat-template.ts`, run by
|
|
253
285
|
`bun run --cwd packages/claxedo-server sandbox:boat-template --image=<image>`:
|
|
254
286
|
|
|
255
|
-
1. **Name.**
|
|
287
|
+
1. **Name.** A prefix and the first 12 hex digits of the SHA-256 of the
|
|
256
288
|
image reference. The reference is content-addressed (its tag names the
|
|
257
|
-
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.
|
|
258
293
|
2. **Build, once per image.** A ready template is reused and one still saving
|
|
259
294
|
is waited for; a missing or failed one is built on a builder sandbox: the
|
|
260
295
|
Boat driver boots the image there (pull, seed, stub, runtime healthy), the
|
|
@@ -369,28 +404,31 @@ spend the owner's delivered accounts (owner ruling, 2026-09-28).
|
|
|
369
404
|
## Egress containment
|
|
370
405
|
|
|
371
406
|
`SandboxManagerInput.net` restricts what the sandbox can reach on the network.
|
|
372
|
-
It is a *separate* control from brokered secrets,
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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:
|
|
390
427
|
|
|
391
428
|
```text
|
|
392
|
-
[sandbox-manager] SANDBOX EGRESS IS UNRESTRICTED: driver "
|
|
393
|
-
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 …
|
|
394
432
|
```
|
|
395
433
|
|
|
396
434
|
Pass `onEgressUnenforced` to route that into telemetry instead of `console.warn`
|
|
@@ -398,9 +436,9 @@ Pass `onEgressUnenforced` to route that into telemetry instead of `console.warn`
|
|
|
398
436
|
replaces the console warning, so only do it if the replacement is as visible.
|
|
399
437
|
|
|
400
438
|
`driver.metadata.egressControl` is the machine-readable source of truth, and
|
|
401
|
-
`sandboxEgressDisposition(control, net)` is the pure predicate the
|
|
402
|
-
so you can ask the same question before composing.
|
|
403
|
-
|
|
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
|
|
404
442
|
(`sandbox_egress_policy_unenforceable`) rather than degraded, because that
|
|
405
443
|
driver *does* enforce egress — it just cannot express that encoding.
|
|
406
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" },
|
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;
|
|
@@ -505,10 +507,20 @@ export type SandboxManagerInput = {
|
|
|
505
507
|
source?: SandboxSource;
|
|
506
508
|
exposure?: SandboxExposure;
|
|
507
509
|
/**
|
|
508
|
-
* @see SandboxDriverEnsureInput.net — enforced by a capable driver
|
|
509
|
-
*
|
|
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.
|
|
510
513
|
*/
|
|
511
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;
|
|
512
524
|
snapshot?: string;
|
|
513
525
|
/** @see SandboxDriverEnsureInput.updateImage */
|
|
514
526
|
updateImage?: true;
|
|
@@ -682,9 +694,9 @@ export type SandboxManagerOptions = {
|
|
|
682
694
|
now?: () => number;
|
|
683
695
|
appLabel?: string;
|
|
684
696
|
/**
|
|
685
|
-
* Sink for the
|
|
686
|
-
*
|
|
687
|
-
*
|
|
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"`).
|
|
688
700
|
*
|
|
689
701
|
* Defaults to `console.warn(event.message)`. Pass your own to route the gap
|
|
690
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.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { validateSandboxPersistenceCapabilities, type SandboxDriverMetadata } from "./contract";
|
|
2
2
|
import { type SandboxDriverBilling } from "./driver-billing";
|
|
3
|
-
import { sandboxDriverId, type
|
|
3
|
+
import { sandboxDriverId, type SandboxDriverID } from "@claxedo/sandbox-contract";
|
|
4
4
|
export { sandboxDriverId };
|
|
5
5
|
export { sandboxMachineCostUsd, sandboxMachineRate, type SandboxDriverBilling, type SandboxMachineRate } from "./driver-billing";
|
|
6
6
|
export type SandboxDriverCatalogEntry<ID extends string = SandboxDriverID> = {
|
|
@@ -50,4 +50,3 @@ export declare const localBrokeringTestDriverCatalogEntry: {
|
|
|
50
50
|
};
|
|
51
51
|
};
|
|
52
52
|
export declare const sandboxDriverCatalog: Record<SandboxDriverID, SandboxDriverCatalogEntry>;
|
|
53
|
-
export declare function dockerSandboxDriverEnabled(env?: SandboxDriverEnv): boolean;
|
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,10 @@ function trimToUndefined(value) {
|
|
|
61
82
|
const trimmed = value.trim();
|
|
62
83
|
return trimmed.length > 0 ? trimmed : void 0;
|
|
63
84
|
}
|
|
64
|
-
|
|
85
|
+
function isEnabledFlag(value) {
|
|
86
|
+
return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
|
|
87
|
+
}
|
|
88
|
+
var sandboxDriverIds = ["modal", "vercel", "cloudflare", "boat", "agent37", "docker"];
|
|
65
89
|
var sandboxDriverCredentialFields = {
|
|
66
90
|
modal: [
|
|
67
91
|
{ key: "token_id", label: "Token ID", secret: true },
|
|
@@ -77,6 +101,7 @@ var sandboxDriverCredentialFields = {
|
|
|
77
101
|
{ key: "worker_url", label: "Worker URL" }
|
|
78
102
|
],
|
|
79
103
|
boat: [{ key: "api_key", label: "API Key", secret: true }],
|
|
104
|
+
agent37: [{ key: "api_key", label: "API Key", secret: true }],
|
|
80
105
|
docker: [{ key: "image", label: "Image" }]
|
|
81
106
|
};
|
|
82
107
|
var sandboxDriverLabels = {
|
|
@@ -84,16 +109,14 @@ var sandboxDriverLabels = {
|
|
|
84
109
|
vercel: "Vercel",
|
|
85
110
|
cloudflare: "Cloudflare",
|
|
86
111
|
boat: "Boat",
|
|
112
|
+
agent37: "Agent37",
|
|
87
113
|
docker: "Docker"
|
|
88
114
|
};
|
|
89
115
|
function isSandboxDriverID(input) {
|
|
90
116
|
return !!input && sandboxDriverIds.includes(input);
|
|
91
117
|
}
|
|
92
|
-
function enabled(input) {
|
|
93
|
-
return ["1", "true", "yes", "on"].includes(input?.trim().toLowerCase() ?? "");
|
|
94
|
-
}
|
|
95
118
|
function dockerSandboxDriverEnabled(env = process.env) {
|
|
96
|
-
return
|
|
119
|
+
return isEnabledFlag(env.CLAXEDO_ENABLE_DOCKER_SANDBOX) || isEnabledFlag(env.CLAXEDO_DEV_DOCKER_SANDBOX);
|
|
97
120
|
}
|
|
98
121
|
function sandboxDriverAuthValues(cfg, id, env = process.env) {
|
|
99
122
|
if (id === "modal") {
|
|
@@ -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,
|
|
@@ -297,11 +351,7 @@ var sandboxDriverCatalog = {
|
|
|
297
351
|
}
|
|
298
352
|
}
|
|
299
353
|
};
|
|
300
|
-
function dockerSandboxDriverEnabled2(env = process.env) {
|
|
301
|
-
return dockerSandboxDriverEnabled(env);
|
|
302
|
-
}
|
|
303
354
|
export {
|
|
304
|
-
dockerSandboxDriverEnabled2 as dockerSandboxDriverEnabled,
|
|
305
355
|
localBrokeringTestDriverCatalogEntry,
|
|
306
356
|
sandboxDriverCatalog,
|
|
307
357
|
sandboxDriverId,
|
|
@@ -7,8 +7,9 @@ export declare function leaseHostId(driver: SandboxDriver, workspaceId: string,
|
|
|
7
7
|
* what makes the egress guarantee structural rather than a promise kept by each
|
|
8
8
|
* call site: `net` is copied across only when `sandboxEgressDisposition` says
|
|
9
9
|
* the driver can enforce it. A driver declaring `egressControl: "none"` — every
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
12
13
|
*/
|
|
13
14
|
export declare function ensureHostInput(input: {
|
|
14
15
|
driver: SandboxDriver;
|
|
@@ -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;
|