Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
49b1830
silkd: a pty's stdin pump ends with its shell
CMGS Nov 4, 2026
e97e816
pool: an unfence on a lifted node leaves the fence channel alone
CMGS Nov 4, 2026
bb0c839
config: refuse an unspecified mesh bind address
CMGS Nov 4, 2026
1490f6b
review: silkd reads session output into its accumulator and keeps the…
CMGS Nov 4, 2026
5e370ba
cut: mesh epochs seed from the clock alone
CMGS Nov 4, 2026
96604aa
cut: drop the prober's heal singleflight and redirect-cache epoch
CMGS Nov 4, 2026
cadfca4
cut: tenant lookups cache only when no eviction overlapped them
CMGS Nov 4, 2026
aa63188
cut: guards no real call site can reach
CMGS Nov 4, 2026
f048e64
docs: match the code on tokens, wakes, deadlines and SDK surface
CMGS Nov 4, 2026
9e5f211
review: one publish-counting test store
CMGS Nov 4, 2026
4c0f5b5
cut: mcp guards around sdk options and the jsonText marshal branch
CMGS Nov 4, 2026
b718913
cut: wire bulk decoder builder and IsContinuation tag handling
CMGS Nov 4, 2026
b6cf234
cut: Env.Hidden, Sandbox replicated accessors and two hand-rolled hel…
CMGS Nov 4, 2026
2f2f378
cut: pg nullTime and scan wrap, unreachable mesh branches, poolTargets
CMGS Nov 4, 2026
54892bc
cut: engine arg and silkd stream duplicates, server wake and relay fo…
CMGS Nov 4, 2026
9e66954
cut: tenant apply slices, store guards and hand-rolled helpers
CMGS Nov 4, 2026
d74b812
cut: egress CA template, routeEvent and Evaluator methods the proxy n…
CMGS Nov 4, 2026
ead1150
cut: key the wire bulk table by frame type
CMGS Nov 4, 2026
2355fc6
sdk/python: a refused upgrade reports the server's error text
CMGS Nov 4, 2026
0dcf36e
review: silkdCollect takes its limit error, peer tar drops an unreach…
CMGS Nov 4, 2026
a2277ca
review: python docstring budget, test classes above tests, tls_client…
CMGS Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ digest. Nodes gossip only their currently available catalog names: host paths
and access lists never leave the node. After config load the set appears on the
next gossip tick; later image distribution or removal is detected the same way.
The node epoch bumps only when one of the gossiped sets — warm counts,
template hashes, volume names — actually changes.
template hashes, volume names — or the config digest actually changes.

A writable name (`writable: true`) still needs its catalog entry — name,
access list, and the `writable` flag — declared identically on every node
Expand Down
2 changes: 1 addition & 1 deletion docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -1028,7 +1028,7 @@ Effects on what already runs:
point at a different variable, but only one that was already set when
sandboxd started.

A successful reload writes an `op:"config_reload"` audit record listing what
A reload that changes a setting writes an `op:"config_reload"` audit record listing what
changed. Each node reloads its own file; on a cluster, distribute the file and
reload every node.

Expand Down
3 changes: 2 additions & 1 deletion docs/egress.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,8 @@ recorded as a decision of its own before its inner requests. The HTTP and
CONNECT doors deny a host longer than 253 bytes (the DNS limit); the audit
record clips it. A sandbox records at most 10
`deny` and `rate_limited` decisions per second after a burst of 100, dropping
the rest, so a guest cannot flood the journals.
the rest, so a guest cannot flood the journals. `rate_limited` comes from
[`egress_conn_rate`](deploy.md#configuration).

## Claim env and secrets

Expand Down
2 changes: 1 addition & 1 deletion docs/langchain.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ schemas, sync-native with `asyncio.to_thread` async bridges):

| tool | what it does |
|---|---|
| `sandbox_exec` | run a shell command, cut off after 5 minutes with the reply saying so (the cut also kills the command in the guest, which can hold the reply up to 5 s more); the budget is one wall clock over the claim, the dial and the command, so a first call that waits on a slow or redirecting cluster still answers inside it; stdout/stderr/exit code; disk state persists across calls |
| `sandbox_exec` | run a shell command, with an optional `cwd`, cut off after 5 minutes with the reply saying so (the cut also kills the command in the guest, which can hold the reply up to 5 s more); the budget is one wall clock over the claim, the dial and the command, so a first call that waits on a slow or redirecting cluster still answers inside it; stdout/stderr/exit code; disk state persists across calls |
| `sandbox_write_file` | write a text file (atomic on the guest; the parent directory must exist) |
| `sandbox_read_file` | read a text file |
| `sandbox_list_dir` | list a directory as JSON |
Expand Down
10 changes: 5 additions & 5 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,18 @@ in the guest, which can hold its reply up to 5 s past the cap.
| tool | what it does |
|---|---|
| `create_sandbox` | claim a microVM and return its id plus deadline; optional `template`, `net` (`none` default, or `egress`), `size` (`small` default, `medium`, `large`, `xlarge`, `2xlarge`) and `ttl_seconds` (0 means one hour); warm claims take milliseconds, nothing renews the deadline |
| `exec` | run a shell command to completion; returns stdout, stderr and the exit code, each stream keeping its first 1 MiB with `truncated` set past that; a cut-off or dropped run returns the output collected so far next to an `error` field; a hibernated sandbox wakes transparently |
| `spawn` | start a command detached and return its pid; output goes to a 256 KiB ring buffer that `logs` replays |
| `exec` | run a shell command to completion, with an optional `cwd`; returns stdout, stderr and the exit code, each stream keeping its first 1 MiB with `truncated` set past that; a cut-off or dropped run returns the output collected so far next to an `error` field; a hibernated sandbox wakes transparently |
| `spawn` | start a command detached, with an optional `cwd`, and return its pid; output goes to a 256 KiB ring buffer that `logs` replays |
| `ps` | list tracked processes (exec, spawn, pty) with state, exit code and start time |
| `logs` | replay the newest 256 KiB of a tracked process's stdout/stderr (+ exit code once ended); an exited process is forgotten after 5 minutes |
| `kill` | signal a tracked process (0 = SIGKILL); an exited process is a no-op success |
| `kill` | send `signal` to a tracked process (0 = SIGKILL); an exited process is a no-op success |
| `write_file` / `read_file` / `list_dir` | atomic whole-file write (parent must exist); whole-file text read of a regular file up to 1 MiB (a larger file, a directory or a device is an error, invalid UTF-8 replaced, missing path is an error); one-level listing of `{name, kind, size}` entries |
| `fork` | clone into N children (1 to the node's `max_fork_count`, default 16) carrying exact memory + disk state, all-or-nothing; the parent keeps running and each child lives one hour |
| `checkpoint` | capture full state without stopping; returns a `checkpoint_id` that can be branched repeatedly |
| `checkpoint` | capture full state without stopping, with an optional `name`; returns a `checkpoint_id` that can be branched repeatedly |
| `branch_checkpoint` | claim a fresh sandbox from a checkpoint's captured moment; it lives one hour |
| `list_checkpoints` / `delete_checkpoint` | checkpoint lifecycle |
| `hibernate` | snapshot + stop, freeing memory while keeping id, files and processes; the next call that reaches the guest wakes it |
| `promote` | publish the sandbox as a named template on its node; re-promoting replaces it |
| `promote` | publish the sandbox as the template `template_name` on its node; re-promoting replaces it |
| `release` | destroy the sandbox and its files; the session forgets the id, so a second release is rejected as unknown |
| `node_info` | warm pools, promoted templates, live claims, drain state, capacity and mesh peers; needs the operator token |

Expand Down
22 changes: 12 additions & 10 deletions docs/sandboxd-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

All bodies are JSON. Three token kinds:

- **root** — the node-level `api_token`: full access to every endpoint.
- **root** — the node-level `api_token`: every endpoint except the agent relay, exec,
owner and the preview mint, which need the sandbox's own token.
- **tenant** — a token the [tenant API](#tenants-v1tenants) added: accepted by the
resource-creating verbs (claim, fork, promote, checkpoint create/claim,
preview mint) and the tenant-scoped listings/deletes below; everything a
Expand All @@ -15,7 +16,7 @@ All bodies are JSON. Three token kinds:
[`meta_store`](deploy.md#shared-tenant-database), a tenant token the node
has not cached answers `503` while the database is unreachable; retry.
- **sandbox** — every claimed sandbox carries its own bearer token guarding
the sandbox-scoped endpoints, regardless of the other two.
the sandbox-scoped endpoints; each endpoint says whether root also acts by id.

Endpoints below that say "node API token" accept root or tenant unless
marked root-only. Errors are `{"error": "message"}` with the status codes
Expand Down Expand Up @@ -530,7 +531,8 @@ Auth: the sandbox's own token, or the root token by id (operator). Restores
a hibernated (or archived) sandbox and leaves it running — waking is
otherwise only a side effect of the next agent access, so this is the
explicit form for warming a sandbox ahead of use. Idempotent on one already
running. A hibernated sandbox retains its existing deadline. An archived
running. A hibernated sandbox retains its existing deadline, unless it is past its lease
and archives at expiry, which starts a fresh lease. An archived
sandbox instead uses `archive_delete_after_seconds` as its retention deadline
while stored, and waking it starts a fresh lease of the length the claim was granted (the server default when it asked for none). A
[failed](deploy.md#dead-vmms-and-host-restarts) sandbox is cold-booted from
Expand Down Expand Up @@ -866,8 +868,8 @@ resolving — no revocation list. A browser request to the minted URL answers 50
Auth: node API token; body `{"token": "<sandbox token>", "name": "..."}`
(name optional); the root `api_token` with an empty body `token` acts by id as
the operator. Captures the sandbox's full state without stopping it and
answers `200 {"checkpoint": {id, name, sandbox_id, key, tenant?,
created_at}}` — `tenant` records the calling tenant, absent for root.
answers `200 {"checkpoint": {id, name?, sandbox_id, key, tenant?, created_at,
policy_source?, no_egress?, guest_env?}}` — `tenant` records the calling tenant, absent for root.
400 bad body or name, 401 bad api token, 404 unknown id or wrong sandbox
token, 409 egress-lane sandbox, one with volumes attached (see
[egress](egress.md)), or an archived one (`POST /wake` or an exec wakes it
Expand All @@ -893,7 +895,7 @@ and answers with a redirect shaped like a warm-miss
the claim. See the [placement lifecycle](cluster.md#checkpoints-on-a-cluster).

404 for an unknown checkpoint (locally, and after redirect and heal both
miss), 409 for an egress-lane checkpoint (see [egress](egress.md)), 429 node
miss), 429 node
or calling tenant at `max_claims`, the node at `max_running`, the node draining, the disk under
`disk_reserve`, or memory over `memory_limit`, 503 when the node's
concurrent-heal cap is already full — retryable, and the response carries a
Expand Down Expand Up @@ -927,7 +929,7 @@ part of the public API; an SDK caller has no reason to call it directly.
Auth: node API token. Lists this node's checkpoints, newest first. A tenant
sees only its own records; root sees everything. A checkpoint backing an
archived claim is that claim's wake image, not a listable record. Answers
`200 {"checkpoints": [{id, name, sandbox_id, key, tenant?, created_at}]}`, the
`200 {"checkpoints": [{id, name?, sandbox_id, key, tenant?, created_at, policy_source?, no_egress?, guest_env?}]}`, the
record shape `POST /v1/sandboxes/{id}/checkpoint` returns.

## DELETE /v1/checkpoints/{id}
Expand All @@ -942,7 +944,7 @@ healed replica does not outlive it — eventual cleanup, not a fleet-wide
revocation.** A peer offline during the broadcast keeps its copy until the
checkpoint TTL ages it out, so an id-holder can still branch it for that
window; [placement lifecycle](cluster.md#delete-is-eventual-not-a-fleet-wide-revocation)
has the bound and why heal requires a nonzero, fleet-matching TTL. A shared
has the bound and why heal requires a nonzero, fleet-matching TTL. The s3
checkpoint store skips the broadcast: every node already resolves every
record directly, so there is no replica to chase. `?no_forward=1` marks a
delete already arriving from another node's own broadcast, so it is not
Expand Down Expand Up @@ -1185,8 +1187,8 @@ them). 426 without the upgrade header, 404 unknown sandbox, 409 a failed sandbox
claim of a replicating pool that is already open when the lease turns overdue
is closed, whether or not it has a replica yet.

A hibernated or archived sandbox wakes on the first frame that opens an RPC,
unless the sandbox's pool sets [`reader`](deploy.md#reading-sleeping-claims-without-waking-them).
A hibernated or archived sandbox wakes when the upgrade request arrives, before the `101`,
unless its last sleep is reader-safe, which the pool's [`reader`](deploy.md#reading-sleeping-claims-without-waking-them) setting provides.
With it, the node answers the `101` before it touches the sandbox, reads the
first frame, and serves `fs_stat`, `fs_list`, `fs_read` and `fs_pull` from a
reader VM, a warm VM of the sandbox's pool that mounts a copy of the sandbox's disk read-only: the sandbox stays
Expand Down
4 changes: 2 additions & 2 deletions docs/sdk-python.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ Responses include `volumes_want`, `volumes`, `states`, and `converging`.
name the three states. A pending state with `converging=False` needs an explicit
retry. `VolumeError`, exported from `cocoonsandbox` and `cocoonsandbox.errors`,
retains a complete failure view in `.response`. Complete replies update both
public sets; incomplete errors preserve the last view. Serialize calls on one
public sets (`sb.volumes_want`, `sb.volumes`); incomplete errors preserve the last view. Serialize calls on one
handle. See the [replacement tutorial](live-volumes.md) and
[API recovery contract](sandboxd-api.md#partial-failure-and-recovery).

Expand Down Expand Up @@ -387,7 +387,7 @@ split multi-byte sequences) and returns the exit code. `user` de-escalates
inside the guest; `session=` routes the command into a persistent session,
where only `argv` applies — the session owns the cwd, environment and user,
and stderr arrives merged into stdout. A command's environment is `PATH`,
`TERM` and the lane's proxy variables plus `env`, not the image's `ENV`. The
`TERM`, the lane's proxy variables and the claim env, plus `env`, not the image's `ENV`. The
guest kills a foreground command whose connection dropped only once it next
writes output, so a `run(timeout=)` cut does not leave that to the drop: once
the guest has reported the command's pid, the cut sends that pid a `kill` over
Expand Down
12 changes: 8 additions & 4 deletions docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ and `.Converging`. `VolumePhase` has `VolumeAttached`, `VolumePending`, and
`VolumeDetaching` constants. Python exposes `VolumePhase` and the corresponding
`VOLUME_ATTACHED`, `VOLUME_PENDING`, and `VOLUME_DETACHING` constants. Each state
can include an `error`. Methods use the client API token on the owning node
and update both public sets from each complete response, including a partial failure. Serialize changes to one handle as with
and update both public sets (`Sandbox.VolumesWant`, `Sandbox.Volumes`) from each complete response, including a partial failure. Serialize changes to one handle as with
`Renew`. Returned views do not share the handle's slices or dictionaries.

Go returns `*sandbox.VolumeError` with a `Response` alongside the returned view.
Expand Down Expand Up @@ -329,7 +329,8 @@ err = sb.SetEnv(ctx, nil) // replaces the whole env; nil clears it
`WithEnv` sets the claim's own env under the [claim rules](sandboxd-api.md#post-v1claim).
An entry with a nil `Guest` is delivered into the guest; `Guest: new(false)`
keeps it host-side, where only the node's egress reads it (see
[secrets](egress.md#claim-env-and-secrets)). `Env`, `SetEnv` and `PatchEnv`
[secrets](egress.md#claim-env-and-secrets)), and `Inject: &sandbox.EnvInject{Hosts: …}`
has egress send it to those hosts. `Env`, `SetEnv` and `PatchEnv`
call [the env verbs](sandboxd-api.md#getputpatch-v1sandboxesidenv) on
the claim's owner with the client's API token, not the sandbox token.
`PatchEnv` sets each entry, removes each nil one and keeps the rest as stored,
Expand Down Expand Up @@ -547,7 +548,7 @@ the guest), `Session` (run inside a persistent session, below), `Stdin`
across Runs), `Stdout`/`Stderr` (nil discards). With `Session` set only
`Argv` applies: the session owns the cwd, environment and user, its commands
read `/dev/null`, and stderr arrives merged into stdout. A command's
environment is `PATH`, `TERM` and the lane's proxy variables plus `Env` — not
environment is `PATH`, `TERM`, the lane's proxy variables and the claim env, plus `Env` — not
the image's `ENV`; `HOME` and `USER` are set only with `User`. The guest kills
a foreground command whose connection dropped only once it next writes output,
so `Run` does not leave that to the drop: when its ctx ends after the guest
Expand Down Expand Up @@ -716,10 +717,13 @@ info, _ = client.SetPools(ctx, pools) // retune warm targets without a resta
res, _ := client.SetPoolsCluster(ctx, pools) // per-node results; retry the failures
sb = client.Attach(ownerAddr, id, token) // bind a known handle, no lookup round-trip

err = client.PutTenant(ctx, sandbox.TenantSpec{Name: "u:42", Token: tok, MaxClaims: 10})
err = client.PutTenant(ctx, sandbox.TenantSpec{Name: "u:42", Token: tok, MaxClaims: 10, EgressClass: "desk"})
err = client.DeleteTenant(ctx, "u:42")
res, _ = client.PutTenantCluster(ctx, sandbox.TenantSpec{Name: "u:42", MaxClaims: 20}) // token kept
res, _ = client.DeleteTenantCluster(ctx, "u:42")
page, _ := client.Tenants(ctx, "", 0) // page.Tenants: names, caps, live claims, removed tenants; never tokens; page.Next pages
page, _ = client.SetTenants(ctx, specs) // replace the whole set
res, _ = client.SetTenantsCluster(ctx, specs)
```

`Sandboxes` is scoped to the calling token, so a tenant sees only its own
Expand Down
2 changes: 1 addition & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ Facts to plan around, stated so the boundary is honest:
`meta_store.usage` the usage journal also lands in the
[shared usage table](deploy.md#shared-usage-table); the audit journal stays
local.
- **Rate limiting is capacity-based only** (`max_claims`, `max_running`,
- **API rate limiting is capacity-based only** (`max_claims`, `max_running`,
`disk_reserve`, `memory_limit` and per-tenant caps answer 429). The API assumes callers inside the trust boundary;
front it with your own limiter if semi-trusted automation can reach it.
- **A dead node's running sandboxes die with it** unless their pool
Expand Down
13 changes: 9 additions & 4 deletions e2e/tls_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Exercise both SDK planes through the TLS cluster fixture."""
"""Exercise both SDK planes through the TLS cluster fixture. Usage: tls_client.py ENTRY OWNER CA"""

import argparse
import socket
import ssl
import sys
Expand All @@ -8,8 +9,12 @@


def main() -> None:
entry, owner, ca = sys.argv[1:]
context = ssl.create_default_context(cafile=ca)
parser = argparse.ArgumentParser(description=__doc__)
for name in ("entry", "owner", "ca"):
parser.add_argument(name)
args = parser.parse_args()
entry, owner = args.entry, args.owner
context = ssl.create_default_context(cafile=args.ca)
client = Client(entry, api_token="node-token", ssl_context=context)
with client.new("rt:24.04") as sb:
assert sb.owner == owner, sb.owner
Expand Down Expand Up @@ -49,4 +54,4 @@ def main() -> None:


if __name__ == "__main__":
main()
sys.exit(main())
6 changes: 1 addition & 5 deletions mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,7 @@ type server struct {
}

func newServer(addr, token, template string) (*server, error) {
var opts []sandbox.ClientOption
if token != "" {
opts = append(opts, sandbox.WithAPIToken(token))
}
client, err := sandbox.Connect(addr, opts...)
client, err := sandbox.Connect(addr, sandbox.WithAPIToken(token))
if err != nil {
return nil, err
}
Expand Down
14 changes: 2 additions & 12 deletions mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,7 @@ func toolCreateSandbox(ctx context.Context, s *server, raw jsontext.Value) (stri
}
// An agent session outlives the node's 5-minute default, and nothing renews a lease
ttl := cmp.Or(time.Duration(args.TTLSeconds)*time.Second, defaultToolTTL)
opts := []sandbox.Option{sandbox.WithTimeout(ttl)}
if args.Net != "" {
opts = append(opts, sandbox.WithNetwork(sandbox.NetShape(args.Net)))
}
if args.Size != "" {
opts = append(opts, sandbox.WithSize(sandbox.Size(args.Size)))
}
sb, err := s.client.New(ctx, cmp.Or(args.Template, s.template), opts...)
sb, err := s.client.New(ctx, cmp.Or(args.Template, s.template), sandbox.WithTimeout(ttl), sandbox.WithNetwork(sandbox.NetShape(args.Net)), sandbox.WithSize(sandbox.Size(args.Size)))
if err != nil {
return "", err
}
Expand Down Expand Up @@ -472,10 +465,7 @@ func unmarshalInput(raw []byte, v any) error {
}

func jsonText(v any) string {
out, err := json.Marshal(v, jsontext.AllowInvalidUTF8(true), json.Deterministic(true))
if err != nil {
return fmt.Sprintf("%+v", v)
}
out, _ := json.Marshal(v, jsontext.AllowInvalidUTF8(true), json.Deterministic(true))
return string(out)
}

Expand Down
Loading
Loading