Status: draft v1, 2026-09-30.
cocoonbox gives each user a persistent cloud computer with an agent living in it, driven by any model through our gateway, with a native macOS app to watch, talk to, take over, and connect accounts and the local Mac. Meta’s Muse is a design reference only: no code is copied.
| Part | Choice | Why | Rejected |
|---|---|---|---|
| macOS app | Swift, SwiftUI + AppKit; live view = swift-rfb (loomhand/swift-rfb, MIT): event-driven RFB client with a Metal renderer, fed directly by Session.Screen | native idle ~30 MB vs Electron’s ~130 MB / 400 MB bundle; framebuffer path stays off a webview canvas | Electron, Tauri (WKWebView startup + canvas path) |
Mac local helper cocoonboxd |
Swift, LaunchAgent, dials out to the box | survives the app window; runs “on my Mac” requests; approvals re-checked locally | helper inside the app process |
In-box agent cocoonbox-agent |
Rust, single static binary (tokio, tonic, rusqlite) | per-VM RSS is density: ~20 MB vs a Node host’s hundreds; same language as gw and cua-driver | Node/TS AI-SDK host |
Control plane + edge cocoonbox-api |
Go, a systemd service on the bench host | sandboxd claim lifecycle behind the machines interface; routes app ↔ box |
Rust |
| Wire | gRPC over HTTP/2 everywhere (tonic / grpc-go / grpc-swift 2); bidi streams carry events, RFB bytes and local-exec | one connection per app, streaming both ways, protobuf contracts shared by three languages | HTTP+SSE JSON, WebSocket per channel |
| Screen executor | trycua cua-driver v0.30.4 (MIT, Rust), serve on its 0600 unix socket |
AT-SPI element tree + element-token clicks + pixel input, one daemon for Linux/Windows/macOS | cua computer-server (a11y stub), OSWorld server (benchmark-shaped, no auth) |
| Desktop in guest | real Xorg with xserver-xorg-video-dummy at 1280×800 + /dev/uinput, XFCE, AT-SPI on, x11vnc on loopback |
cua-driver’s focus-free pixel input needs real Xorg + uinput; TigerVNC Xvnc cannot hotplug the MPX devices (linux-mpx-recovery.md:41-42) |
Xvnc (foreground-only input) |
| Browser | Google Chrome (.deb), CDP on loopback, --force-renderer-accessibility |
structured channel ahead of pixels | snap chromium |
| Models | gw v0.2.6 on the bench host, SQLite ledger, bridge-only bind | tenant caps, one endpoint for every model provider | |
| Machines | the cbsbx sandboxd instance on the bench host (§14; K3s + vk-cocoon + CocoonSet until 2026-10-01) | a claim = persistent box with hibernate/wake, warm pools, egress proxy and per-claim secrets | eru (host-local snapshots, no migration) |
Hot-path cost: none. No change to cocoon, sandboxd or gw code; all of it is configuration plus new cocoonbox repos.
cocoonbox.app ─┐ bench host
cocoonboxd ───┼─ gRPC/h2+TLS ─▶ cocoonbox-api (edge + control) ──▶ sandboxd claim ─▶ VM
│ │ routes by box_id, never sees payload secrets │
│ └─ gRPC ─────────────────────────────────────────────▶│ cocoonbox-agent :7443
│ │ ├─ cua-driver (unix socket)
│ │ ├─ Chrome CDP (loopback)
│ │ ├─ x11vnc :5900 (loopback, via agent)
│ │ ├─ stdio MCP servers (plugins)
│ │ └─ proxy → host egress doors (§14.6, §16.3)
│ └──▶ gw 10.203.0.1:18090 ─▶ OpenRouter / vendors
The app and cocoonboxd always dial out (no inbound port on the Mac). The edge authenticates the user, resolves box_id → sandboxd claim, and proxies through the sandboxd port relay the gRPC stream; account credentials never enter the box (§16).
cocoonbox-agent)Tool tiers, cheapest and most reliable first:
/workspace.- role "name" [ref=eN], capped), act by ref.get_window_state (screenshot + AT-SPI tree) and element-token actions first, pixel click/type/press_key/hotkey/scroll/drag last. Only the computer subagent holds this tool; the main agent gets a read-only screenshot when its model has vision.Models (resolved per role in agent config, all via gw):
| Role | Default | Wire |
|---|---|---|
| main | a text model | /v1/chat/completions |
| computer subagent | a vision model | /v1/messages when the upstream is the model provider’s own API, else chat |
| summarize / memory | small cheap model | chat |
Rule: the agent speaks the upstream’s native wire; gw translation is not relied on (silent-distortion risk: cache_control, stop reasons, parallel tools). Images from tool results go into a following user image part for vision models and are replaced by text for text-only models.
Privilege split: the loop runs as cbagent; tools (shell, files, browser, cua-driver, MCP servers) run as the desktop user user in its session. The model’s shell cannot read the loop’s database or config. The loop holds no real gw key either: gw calls go through the egress proxy, which injects the per-box gw key like any other surrogate, so a key leaked by a tool is worthless and the per-box tenant cap bounds any abuse.
State: SQLite (/var/lib/cocoonbox/agent.db) for agents, transcript, approvals, installed plugins; /workspace for user files. Both survive hibernate (disk is in the snapshot).
Proto packages live in one repo (cocoonbox/proto), generated for Go, Rust and Swift. Sketch below; field lists are the contract to refine in M2.
cocoonbox.control.v1.Boxes (edge, per user)service Boxes {
rpc Ensure(EnsureRequest) returns (Box); // create or wake; idempotent per (user, flavor)
rpc Get(GetBoxRequest) returns (Box);
rpc List(ListBoxesRequest) returns (ListBoxesResponse);
rpc Watch(WatchBoxesRequest) returns (stream ListBoxesResponse); // the list at once and after every change; never wakes a box
rpc Hibernate(BoxRef) returns (Operation); // sandboxd hibernate
rpc Recreate(RecreateRequest) returns (Operation); // preserve_data: snapshot /workspace+agent.db, fresh image
rpc WatchOperation(WatchRequest) returns (stream OperationEvent); // resumable by offset_key
}
message Box { string id = 1; State state = 2; string image = 3; Flavor flavor = 4; bool image_update_available = 5; }
enum State { ABSENT = 0; STARTING = 1; RUNNING = 2; HIBERNATING = 3; HIBERNATED = 4; FAILED = 5; }
message OperationEvent { string operation_id = 1; Phase phase = 2; string detail = 3; string offset_key = 4; int64 at_ms = 5; }
enum Phase { BACKING_UP = 0; CREATING = 1; MOVING = 2; CLEANING_UP = 3; DONE = 4; FAILED_PHASE = 5; }
All box RPCs address box_id in metadata; the edge forwards cocoonbox.agent.v1.* calls to that box.
Three different things, three permission models (Muse’s split):
(a) App ↔ box — cocoonbox.agent.v1.Session
service Session {
rpc Hello(HelloRequest) returns (HelloResponse); // box_public_key (X25519), agent version, capabilities
rpc Events(EventsRequest) returns (stream Event); // channels + from_offset; transcript, agents, approvals, computer-action, box-disk
rpc Screen(stream ScreenClientFrame) returns (stream ScreenServerFrame); // RFB bytes to/from x11vnc; take-over = client input frames
}
(b) Accounts: superseded by §16. The in-VM vault sketched here kept credentials at rest in the VM.
(c) The user’s Mac — cocoonbox.agent.v1.LocalExec (cocoonboxd dials out)
service LocalExec {
rpc Attach(stream LocalFrame) returns (stream LocalRequest); // hello{computer_id, root, label}; requests: exec, read, ls, upload, download, cancel
}
ask (default) / always / never; ask prompts per (action, exact target) with allow-once | deny | always | never, TTL 10 min, retired at turn end.cocoonboxd re-checks every request against approvals written by the app (the box cannot forge one); read/ls/transfer confined to a chosen root; shell runs under a Seatbelt profile.Superseded by §17.
cocoonbox.agent.v1.Agentsservice Agents {
rpc Create(CreateAgentRequest) returns (Agent); // client_nonce dedupe
rpc List(ListAgentsRequest) returns (ListAgentsResponse);
rpc Send(SendRequest) returns (SendAccepted); // prompt + attachments + selected plugin capabilities; client_nonce
rpc Transcript(TranscriptRequest) returns (TranscriptPage);
rpc Respond(RespondRequest) returns (Empty); // approvals, widgets, "take over done, continue"
rpc Cancel(AgentRef) returns (Empty);
}
cocoonbox-desktop:24.04Cocoon OCI VM image (vmlinuz + initrd.img + rootfs) built from cocoon’s os-image/ubuntu/24.04 template:
cocoon vm exec) and systemd-networkd; no NetworkManager.xserver-xorg-video-dummy (1280×800 modeline), uinput module loaded at boot, XFCE, at-spi2-core, toolkit-accessibility=true, screensaver/power management off, x11vnc -localhost -rfbport 5900.DISPLAY, XAUTHORITY, DBUS_SESSION_BUS_ADDRESS.cocoonbox-agent as a system unit and its toolhost as shuttle’s service; users cbagent (runs the agent) and user (the desktop, owns /workspace). No credential vault in the VM (§16).cocoon vm exec.cb)The layout: the cbsbx sandboxd instance under /srv/cb/sbx (its own cocoon root, net none pools, egress proxy), gw and the MinIO state store on the bridge address 10.203.0.1 (cbc0, which holds no VMs), cocoonbox-api behind Caddy, and the guards in cb-host-net. The K3s, vk-cocoon, cocoon-operator, registry and cocoon-cb stack that ran the boxes until M4.8 was removed on 2026-10-01.
| # | Deliverable | Acceptance evidence | Size |
|---|---|---|---|
| M0 | isolated cb env, K3s, registry, vk-cocoon, operator, gw |
kubectl get node cocoon-cb Ready; curl 10.203.0.1:18090/health; a real completion through gw with the new key |
0.5 d |
| M1 | guest image v1 + cua-driver spike | VM from CocoonSet boots; cua-driver doctor clean; get_window_state on Mousepad and Chrome returns a non-empty AT-SPI tree; an element-token click and a background pixel click both land (visible in x11vnc); hibernate → registry cb/<vm>:hibernate → wake keeps a marker file |
1–1.5 d |
| M2 | cocoonbox-agent v0 (Rust): gw client (chat + messages), shell/files, browser (CDP refs), computer subagent via cua-driver, Agents/Session gRPC, SQLite |
from grpcurl: the text-model main agent runs Shell; delegates to the vision computer subagent which opens Mousepad and types text; browser task reads example.com H1 via refs |
4–6 d |
| M3 | cocoonbox-api (Go): Boxes on CocoonSet, WatchOperation, edge routing by box_id, user tokens |
Ensure from zero to RUNNING; Hibernate/Ensure round trip with a resumable operation stream; agent RPCs reach the box through the edge only |
3 d |
| M4 | macOS app v0: sign-in, box state, agent list, streaming transcript, live view + take-over | RoyalVNCKit spike first (encodings against x11vnc: ZRLE/Tight); app shows the desktop at 1280×800 and the transcript live; measured: app RSS, time to first frame, input RTT | 4–5 d |
| M4.5 | sandbox line evaluation | docs/m45-sandbox-eval.md: measured lifecycle, relay, egress, density, failure model; recommendation | done |
| M4.6 | state layer (§11): user data to object storage, SaveState, Rebase | SaveState twice on a box (second incremental); Rebase v6 → next image keeps /workspace, a logged-in Chrome cookie and agent.db; sizes and timings recorded | in progress |
| M4.7 | native agents: a plugin for the local agent CLIs (skill + commands + MCP config), a cocoonbox CLI (login, mcp-headers, box lifecycle), a control-plane MCP in the api (status, ensure, hibernate, save, rebase) |
from each CLI, with the app closed: wake the box, do a GUI task, save, hibernate; no token in any config file | next |
| M4.8 | sandboxd as the machine backend (§14), after the upstream PRs (per-claim env, dead-VMM restart) land | §14.9: M3 acceptance plus live view, MCP, Rebase, H3, identity, crash, dead-api and lost-machine runs on the sandboxd backend; default flipped; cocoonset kept as fallback | after PRs |
| M4.9 | proactive agents (§15): routines in the box, api alarms with wake-then-Tick, precheck, notices in the app, limits and kill switches, dual-meter budgets (§15.9, phases a–f) | §15.9 per phase; end to end: the HN digest routine wakes the hibernated box at 08:00, writes the digest, re-hibernates, and its notice shows in the app without a wake; per-run box-seconds and cost match sandboxd’s and gw’s own records | 8–10 d |
| M5 | connect (§16): host-side broker with OAuth through the Mac, connector tools for both modes, per-tool policy and approvals; M5.2 git through egress injection. cocoonboxd local-exec (§4.2(c)) is tracked separately |
§16.8: GitHub and Calendar connected; mode B with the box hibernated; mode A write approved in the app; no token in the guest, its hibernate image or the Mac’s configs | 5–6 d, M5.2 2 d |
| M6 | market (§17): pinned git catalog in the M4.7 plugin format, skills and secret-free stdio MCP servers in the box, visible in both modes; the catalog doubles as the local agents’ marketplace | §17.8: a public agent-CLI plugin installs unchanged and works in a box turn and through the bridge; tampered, symlinked and secret-needing plugins refused; survives Rebase | 3–4 d, M6.2 3 d |
M0–M2 are the proof that “any model + our machines” works; M3–M4.8 make it a usable product; M5–M6 (connect, marketplace) come after the current stack runs end to end.
Settled 2026-09-30:
loomhand/cocoonbox, AGPL-3.0. Layout: proto/, agent/ (Rust), api/ (Go), macos/ (Swift), image/. AGPL allows reusing gw’s protocol crates.@cocoonbox path /cocoonbox.* → reverse_proxy h2c://127.0.0.1:18200 placed before the existing default handlers; gRPC method paths (/cocoonbox.control.v1.Boxes/Ensure) never collide with the host’s other routes. caddy validate before a graceful reload.Open:
| Risk | Mitigation |
|---|---|
cua-driver is pre-release, ~1 release/day; open Linux bugs (#3774 background type_text silent drop, #3871 desktop freeze on Cinnamon) |
pin 0.30.4; M1 spike is the gate; fall back to delivery_mode: foreground for typing; keep the executor behind our own computer tool so the backend can change |
| Real Xorg + dummy + uinput in a cocoon guest unproven | M1 checks /dev/uinput, MPX device creation, x11vnc capture of the dummy screen before anything else |
| RFB client cost on the hot path (resolved: RoyalVNCKit polled at 100 Hz; replaced by swift-rfb, idle 0.03% CPU) | spike encodings early in M4; our own Metal layer; H.264 (RFB encoding 50, done before in cocoonstack/scrcpy-rfb) later if bandwidth demands |
| The main text model’s tool-calling quality under a long tool list | per-role model map; acceptance uses one Shell call first |
Decided 2026-09-30. The box is disposable across images and nodes; the user’s data is not. Hibernate and same-node checkpoints stay the fast path (memory and running programs kept); the state layer is the durable path that survives node loss and image rebase.
/home/user (Chrome profile and its logins, dotfiles), /workspace, agent.db, and later the credential vault. Caches are excluded (~/.cache, Chrome cache dirs).State.Plan walks and chunks the state (files read by the toolhost as user, agent.db via SQLite backup by serve), State.Upload writes only the chunks the api says are missing, State.Restore rebuilds the tree. It never holds storage credentials; the key lives only in memory for one request.State.Restore from the latest manifest → start the desktop. Running programs restart; files, logins and agent history carry over.proto/cocoonbox/agent/v1/state.proto. The edge does not route State; only the api calls it, over its internal dialer.State.Restore is a bidi stream. The first message carries the key and the manifest URL; each later one carries a page of presigned GETs, presigned just before it is sent. The agent starts writing with the first page and reads the next page whenever no file has all its chunk URLs yet. After the last page it checks that every chunk the manifest references got a URL before it quiesces.presign_ttl; PUTs keep presign_ttl. With 10 min GETs, a 26,188-chunk, 1.73 GiB restore that took 11m57s failed with 400 ExpiredToken (2026-10-21). Measured: a v11→v14 restore of 97k chunks and 8.9 GiB took 2m16s; the v15 agent restored a v14-saved, id-ordered 90k-file snapshot in 42.2 s (2026-10-02).State.Upload keeps its unary wire format, so a pre-v14 agent can still save: the api cuts batches at 2048 puts or 512 MiB, whichever comes first.PlanResponse.chunks is still one unary list (about 8 MB at 97k chunks, under the api dialer’s 64 MiB limit). Page it once every box runs v14 or later.known-chunks.json on the box disk. A plan without a parent, or under another key, ignores them; a new machine has none, so its first plan reads every file. A file whose ctime is within 1 s of the walk is never recorded, and SQLite files read through the online backup are never reused (WAL commits leave the main file’s stamp unchanged).-wal and -journal sidecars go in a second pass, after their database..cb-restore-* directory. Both callers, Rebase and Restore, discard the new machine on failure, so it never reaches a serving box.EDGE_TOKEN, audit H1(a); gw relay, H3)Decided 2026-09-30, edge side reviewed 2026-10-01. Constraints:
The adversary is the box’s own user (user, unprivileged), or root inside one box after a guest compromise.
| A. Edge-signed box-bound credential | B. Per-box token in tmpfs | C. vsock transport | |
|---|---|---|---|
| Mechanism | The edge signs key_id · box_id · wake_id · exp with ed25519 (header x-cocoonbox-credential). The agent verifies it with the public key from its identity file, and checks key_id, box_id and wake_id against that file and exp against its clock. |
The api delivers a random per-box token at every boot and wake into /run/cocoonbox/edge-token (tmpfs, root:cbagent 0640). The agent compares it like today. |
The agent accepts calls only on vsock from CID 2 (the host). A node-local bridge carries edge traffic onto each VM’s vsock socket. |
| Secret in the guest | none | yes, in RAM, so it lands in every hibernate image until the next wake rotates it | none |
| Misrouted call (stale pod IP now held by another box) | rejected: box_id mismatch |
rejected: token mismatch | impossible: the channel is the VM |
| Guest root compromise | can use only its own box’s credentials; forging needs the edge’s private key | leaks its own token until the next wake | none reusable |
| Host plumbing | none new; api writes one file per wake | same file write, plus per-box token storage in the api | per-node TCP↔vsock bridge with its own auth to the edge |
| Clock dependence | yes (exp); bounded by wake_id (below) |
no | no |
Decided: A (ruling 2026-09-30).
/etc/cocoonbox/edge-signing.key (root 0600) read through LoadCredential, since the api is a host unit. A K8s Secret applies only if the api moves into the cluster.Credential format. x-cocoonbox-credential: v1.<b64url(key_id|box_id|wake_id|exp_unix)>.<b64url(ed25519 signature over the first part)>: base64url without padding, |-separated fields.
key_id is the first 16 hex of sha256(public key).wake_id) and re-signs when less than 2 min of exp remain, so the hot path is a map lookup; a stream signs once at open.x-cocoonbox-credential on gRPC and MCP, as it does x-cocoonbox-edge-token. During the switch it sent both headers until no image set EDGE_TOKEN; the agent has since dropped the token.Where the agent learns its identity and the edge key. On every boot and wake, the api writes /run/cocoonbox-identity/identity, {"box_id":…,"wake_id":<random>,"key_id":…,"public_key":<ed25519>}, through sandboxd exec (silkd over vsock, running as root in the guest).
/run/cocoonbox: that directory is user:cbagent 2750 for shuttle’s desk socket, so user could replace a file in it and point the agent at its own key.key_id in that file. It re-reads the file when a credential carries a key_id or wake_id other than its cached copy, since a wake rewrites the file under a running agent.Session.Hello included, gets UNAVAILABLE “identity not provisioned”. Readiness is the TCP probe on 7443, and the api reports RUNNING only after the identity write succeeds, so no client call arrives earlier.wake_id in the machine’s meta (a claim env entry, not secret), so it survives an api restart; a wake is a new VM incarnation.Clock. A woken guest’s clock may lag until NTP syncs. wake_id changes on every wake, so it carries the real freshness: a credential from before a wake is dead whatever the clock says. exp is a coarse bound (10 min, with a small skew allowance of 2 min) against replay within one wake. Replay needs a copy of the edge’s traffic, which runs host-local through the sandboxd port relay.
Rotation. The public key arrives in the identity file, not in the image. Rotation is therefore: the api starts signing with the new key, then rewrites the identity files on the next wake. No image cut is needed.
Rebase and hibernate. A rebased machine gets its own wake_id, so the old machine’s credentials die with it. A hibernate image carries the identity file; that is harmless because it is not secret, and the wake rewrites it with a new wake_id.
MCP uses the same header, so the edge treats /mcp exactly like gRPC.
A signature can’t prove “request from box X” without a secret in X. The proof must come from the channel.
<vsock>_2049 egress door, and per-claim guest: false secrets (#268).
agent.env then belongs only to its own box and its own user, and its cap bounds any abuse.EDGE_TOKEN and add /run/cocoonbox-identity/identity and the credential header. GW_API_KEY stays per box until M4.8.ed25519-dalek, about 40 KB) with the key from the identity file, and read that file.wake_id) cache; write the identity file through the backend’s Deliver before reporting RUNNING; keep wake_id in the machine’s meta.d /run/cocoonbox-identity 0755 root root; remove EDGE_TOKEN from agent.env and the golden; per-box gw keys and caps.Proposed 2026-10-01, for review. The user’s own local coding agent drives their box with the app closed: it wakes the box, works on it through the box tools, saves and hibernates. The model subscription stays on the Mac (mode B); the box sees only MCP calls.
Constraints:
proto/ gRPC and MCP via the edge. Nothing imports another component’s code or reads its files.| Piece | Where | What |
|---|---|---|
cocoonbox CLI |
macos/ (Swift, a new product next to the app, on CocoonBoxKit) |
login and logout (Keychain), box lifecycle commands, the stdio MCP bridge, a headersHelper command, per-client setup |
| Control MCP | api service layer, https://<endpoint>/cocoonbox.mcp/control |
box lifecycle tools for the caller’s own box, over the existing Boxes semantics |
| Plugin | plugins/cocoonbox/ (no code) |
one directory with a manifest per client, a shared .mcp.json that runs cocoonbox mcp, a skill and slash commands |
The CLI is Swift on CocoonBoxKit because it needs the Keychain and the app already solved endpoint parsing, the loopback-only plaintext rule, the HTTP/2 connection and the Boxes calls. That is vertical reuse inside one component (macos/), not coupling to another. It ships inside the app bundle (CocoonBox.app/Contents/MacOS/cocoonbox), and cocoonbox install links it into ~/.local/bin; a standalone build works the same way for users who never open the app.
Every client runs the same MCP server entry, with no URL and no token in it:
{ "mcpServers": { "cocoonbox": { "command": "cocoonbox", "args": ["mcp"] } } }
cocoonbox mcp is a stdio MCP server. It reads the token from the Keychain at start and keeps it only in memory. It forwards each JSON-RPC message to the edge over MCP Streamable HTTP with Authorization: Bearer: box tools go to /cocoonbox.mcp/<box id>, and lifecycle tools go to the control MCP. It resolves the box id itself (Boxes.List), so a Rebase or a new box never breaks a client’s config.
Why a bridge rather than pointing clients at the HTTPS endpoint directly (what the app’s Connect sheet does today):
headersHelper or ${VAR} for claude, bearer_token_env_var for codex, ${env:VAR} for cursor-agent. The last two need the token in an environment variable of the shell that starts the client./mcp to answer, so a direct client marks the server failed and never lists the box tools. The bridge answers initialize and tools/list itself (below) and wakes the box on the first box-tool call.initialize through unchanged, so the box still records the session as mcp:claude-code (or codex-mcp-client, or the third CLI’s own name) and the app shows it under External.The cost is one local stdio hop per message, next to a 230 ms WAN round trip; the bridge holds one HTTP/2 connection for both endpoints.
Tool list while the box is down. The box tool names and input schemas are the contract, so the bridge can answer tools/list without the box. It caches the box’s own tools/list answer (not secret) in ~/Library/Caches/cocoonbox/tools.json whenever the box is running, falls back to a copy compiled into the CLI, and sends notifications/tools/list_changed when the live list differs. Lifecycle tools come from the control MCP, or from a compiled-in copy when the edge is unreachable.
Connector tools (§16). At initialize the bridge also opens a session to /cocoonbox.mcp/connect with the client’s own initialize, and holds that door’s GET stream. Its tools/list answer appends the door’s tools, after waiting at most 3 s for the first list. A connector tool whose name a control or box tool already has is left out, with a line on stderr. On the door’s list_changed the bridge lists again, and sends notifications/tools/list_changed when the connector tools differ from those the client last listed. A call to a listed connector tool goes to the door and wakes no box. When the door fails, the bridge serves the other tools and retries with backoff, from 1 s up to 60 s. A 401, 403, 404 or 405 from an api without the door ends the retries.
Waking on demand. When a box tool is called and the box is not RUNNING, the bridge calls the control tool box_ensure. It reports the wake as MCP progress notifications (“waking: restoring memory snapshot”), then forwards the call. A wake takes 5–17 s today. A client whose tool timeout is shorter than that, or a user who does not want implicit wakes, uses cocoonbox mcp --no-wake: the call then fails with an error that tells the model to call box_ensure.
Direct fallback. The Connect sheet recipes stay for users who don’t install the CLI. The claude recipe may use "headersHelper": "cocoonbox mcp-headers", which prints {"Authorization":"Bearer …"} to the CLI’s pipe, instead of ${COCOONBOX_TOKEN}. There is no cocoonbox env command: an exported token lives in every child of that shell.
The api serves it itself; the edge does not proxy it to a box. Auth is the user token, which the api checks the way it checks Boxes, and every tool acts only on the caller’s own box. Tools (names are the contract; add them to contracts.md):
| Tool | Arguments | Result |
|---|---|---|
box_status |
– | state, image, latest_image, last_saved_at_ms, box id |
box_ensure |
wait (default true) |
final state; progress notifications from WatchOperation while waiting |
box_hibernate |
– | operation result; progress notifications |
box_save |
– | operation result with bytes; says the browser closes during the save |
box_upgrade |
confirm: true (required) |
operation result; refused without confirm, with the text “running programs restart; files, logins and agent history are kept” |
These are thin wrappers over Ensure, Hibernate, SaveState, Rebase and WatchOperation, so the api keeps one implementation of the lifecycle. The CLI’s own cocoonbox status|ensure|hibernate|save|upgrade commands call Boxes over gRPC directly, as the app does.
| Command | Does |
|---|---|
cocoonbox login [--endpoint URL] [--browser \| --token - \| --from-app] [--invite CODE] |
on a terminal (or with --browser), signs in through the browser and gets a device token of its own (contracts “Browser sign-in and device tokens”); with --token -, over SSH, or with stdin not a terminal, reads a token from stdin or a hidden prompt. Stores it in the Keychain and checks it with Boxes.List |
cocoonbox invite create [--days N] / invite list |
with an admin’s invite-scoped token from COCOONBOX_INVITE_TOKEN or a file, makes a one-use invite link or lists the token’s invites (contracts “Invite-scoped tokens”) |
cocoonbox logout |
deletes the Keychain item, also when the api is unreachable; first revokes the token at /cocoonbox.oauth/revoke when the CLI got it through the browser. A token copied from the app (--from-app or the implicit copy) is the app’s device and is not revoked. cli.json records where each endpoint’s token came from |
cocoonbox status / ensure / hibernate / save / upgrade [--yes] |
lifecycle, with progress on stderr and --json for scripts |
cocoonbox mcp [--no-wake] |
the stdio bridge (13.2) |
cocoonbox mcp-headers |
the headers helper for the direct claude recipe |
cocoonbox rsh [--box B] [HOST] COMMAND… |
runs a command in the box with its stdin, stdout and stderr; rsync’s and git’s remote shell (13.7) |
cocoonbox setup claude\|codex\|cursor [--print] |
installs the plugin, or prints the manual config for that client |
cocoonbox install |
links the CLI into ~/.local/bin |
Keychain sharing with the app. Both use service io.github.loomhand.cocoonbox with the endpoint URL as the account. With a Developer ID signature, app and CLI share one keychain access group, so neither prompts. Until then, builds are ad hoc signed and every rebuild has a new code hash. So cocoonbox login writes its own item, and cocoonbox login --from-app copies the app’s token once, after a single “Always Allow”.
plugins/cocoonbox/:
.claude-plugin/plugin.json, .codex-plugin/plugin.json and a third manifest for cursor-agent, which also reads .claude-plugin/plugin.json; its own manifest carries its interface fields. Each points at the shared .mcp.json above and at skills/.skills/cocoonbox/SKILL.md: the computer is the user’s cocoonbox cloud desktop, not this Mac. Check box_status first. Use screenshot to confirm GUI actions. Call box_save before long idle.claude commands /cocoonbox:status, /cocoonbox:wake, /cocoonbox:save and /cocoonbox:sleep, which tell the model to call the matching control tool.None of these files contains a URL or a token: the endpoint comes from cocoonbox login and the box from Boxes.List. Installation, per the installed CLIs’ own help (claude 2.1.285, codex 0.159.2, cursor-agent 2026.09.28):
claude: claude plugin marketplace add <path> takes a local path, then claude plugin install cocoonbox@<marketplace>. cocoonbox setup claude runs both against the plugin directory it writes, then claude plugin update cocoonbox@cocoonbox: install leaves an installed plugin at its recorded version. A second run changes nothing and succeeds (claude 2.1.288).codex: codex plugin marketplace add takes a local or Git marketplace, then codex plugin add cocoonbox@<marketplace>. cocoonbox setup codex runs both the same way.cursor-agent: agent plugin marketplace add takes only a git repository URL, and the CLI has no plugin install command. Until the repository is public, cocoonbox setup cursor falls back to the MCP config: it merges the stdio entry above into ~/.cursor/mcp.json, with no token or URL in it. agent --plugin-dir <dir> loads the full plugin (skill included) for one run.CI checks the directory with claude plugin validate.
Acceptance, from each of the three agent CLIs with the app closed and the box hibernated:
cocoonbox status agrees.ps -E).Split:
/cocoonbox.mcp/control.Order: the api contract first (contracts.md on main), then api and macos in parallel.
Decided 2026-10-01:
--no-wake turns that off.box_operations tool.cocoonbox rshDecided 2026-10-03: offload transport is rsync/git over cocoonbox rsh; sshd deferred. The box has no sshd since v12, and a per-box sshd would need host keys, user keys and a port path through sandboxd. rsh reuses what every client already has: the user token, the HTTPS edge and the agent’s gRPC.
Session.Exec, a bidi stream (contracts.md “Remote command”). The first frame names the command and, for -t, a terminal. Then come stdin chunks, an explicit eof, signals and terminal sizes. eof is a frame, not only the half-close, so a Ctrl-C still reaches a command whose stdin ended. stdout and stderr chunks come back, and the exit code is the last frame.cocoonbox.agent.v1 method through the frame-by-frame proxy, authenticated like every gRPC call: user tokens only, with the owner’s v1 credential.user. The toolhost spawns a login bash -lc in /workspace with the shell tool’s environment, so ~/.local/bin and tool shims are on PATH. It runs in its own process group on pipes, or for -t as a session leader on a new pseudo-terminal. The connection carries tagged frames both ways: stdin, eof, signal and resize in; stdout, stderr and exit out. Each side moves one chunk at a time, so backpressure reaches the command. Closing the connection kills the group. The toolhost also notices the close while a write to the command’s stdin is blocked: it polls the socket’s read-closed state every 200 ms, only while that write waits.cocoonbox rsh [--box B] [-t] [-l USER] [HOST] COMMAND…, ssh-style. It reads the token and endpoint as mcp does.
--box wins and drops a first word that is box or names one of the user’s boxes. Without --box, the word names the box, as docker exec and kubectl exec do as rsync shells.-t puts the local terminal in raw mode and forwards window sizes.GIT_SSH_COMMAND="cocoonbox rsh" GIT_SSH_VARIANT=simple.xterm-kitty, xterm-ghostty) is often unknown to the box, whose image has only ncurses-base, and curses programs then refuse to start. As kitty’s ssh kitten does, -t sends the local infocmp -x "$TERM" output with the tty, and the toolhost compiles it into ~/.terminfo as user when the box lacks the entry; ~/.terminfo lives in the home, so the state store keeps it across Rebases. Without a usable source the command gets xterm-256color and a one-line notice. tic reads client text, so it runs as user, never root, with a 256 KiB cap and a 5 s timeout.build-essential and lists ncurses-bin (tic, infocmp) explicitly.Hot path: none on existing calls. The edge adds no code. The agent adds one owner check per Exec stream and nothing to the other methods. The window change only grants more credit. A -t stream also runs one infocmp in the box (and tic once per new terminal type) and one local infocmp in the CLI.
Acceptance (2026-10-03, dev’s box on v19 with test agent 4228d59, from a Mac over the public endpoint, RTT 250 ms):
| Check | Result |
|---|---|
rsh --box main uname -a; false; exit 42 |
Linux …; exit 1; exit 42; runs as user in /workspace, stderr kept apart |
50 MiB random through cat |
sha256 equal both ways; 15.2 s round trip |
| Throughput, 50 MiB | upload 3.3 MiB/s, download 8.0 MiB/s with the call’s startup included. ssh to the bench host on the same path moved 5.9 and 6.5 MiB/s |
Startup of rsh true |
1.22–1.26 s (10 runs): TCP and TLS to the endpoint, Boxes.List, the Exec stream; the TLS handshake alone took 0.50 s |
rsync of the cocoonbox checkout, .git included (1025 entries, 775 files, 8.0 MB) |
first run 4.8 s, 4.6 MB sent; no change 2.8 s, 64 KB sent; one file changed (docs/design.md) 3.0 s, 65 KB sent and 260 B of unmatched data; trees equal by sha256 |
GIT_SSH_COMMAND="cocoonbox rsh --box main" git clone box:/workspace/rsh-test/main |
3.1 s; git probed -G, got 255 and used its simple variant; a push back worked |
Ctrl-C of a running sleep, idle and with a full stdin |
the remote process ended both times; the CLI exited 130 |
| A second call while an Exec held a full stdin | 1.27 s with 4228d59; it waited until the stalled command ended with 7e14603 |
Round 2 (agent de97cf9, the revised plan, same setup):
| Check | Result |
|---|---|
| Login shell | login_shell on; with ~/.local/bin present it leads PATH (/home/user/.local/bin:/usr/local/sbin:…) |
-t from a pty of 33×111 |
/dev/pts/3, TERM=xterm-256color, 33 111; after a local resize and SIGWINCH 44 132; typed input echoed and read; exit 5 passed through. tmux runs in it |
SIGINT to the CLI, remote trap … INT |
got-int, exit 9, 0.28 s after the signal; SIGTERM to a remote sleep gives 143 and the process is gone |
GIT_SSH_VARIANT=simple |
no -G probe; clone 3.2 s; a commit made in the box arrived by git fetch in 1.9 s |
| A second call while an Exec held a full stdin | 1.29 s |
50 MiB through cat, upload, download; startup |
equal; 15.2, 14.8 and 6.7 s; 1.22–1.27 s |
macOS rsync is openrsync (protocol 29); the box runs rsync 3.2.7. Pushing small/ both ways through cocoonbox rsh:
-a -z --delete --exclude --exclude-from --include -v -n -c --partial -P --progress --inplace -u --ignore-existing --existing --size-only --stats --timeout -H --numeric-ids --compress-level -b --suffix --backup-dir --rsync-path --no-perms --no-owner --no-group.-a -z --delete --exclude -c --stats -n -P --inplace -u --size-only.--info=progress2, --mkpath, --checksum-choice, -X and -A.--delete-excluded hangs. It hangs the same way over plain ssh to the bench host’s rsync 3.2.7, so the transport is not the cause.--mkpath, so run cocoonbox rsh B mkdir -p DIR first.-b --backup-dir (openrsync as the receiver) backs up local files the box changed, but --delete removes local-only files without a backup. The way back must not rely on -b for files that exist only on the Mac.Upload stays under ssh’s rate. One 1 MiB window in flight over a 250 ms RTT allows about 4 MiB/s, and Go’s HTTP/2 server, which Caddy uses, buffers 1 MiB per stream by default. That fits the numbers but is not proven.
Design, 2026-10-01. It builds on the M4.5 measurements (m45-sandbox-eval.md) and on three upstream PRs: cocoonstack/sandbox#268 (per-claim env), cocoonstack/sandbox#269 (dead-VMM restart) and cocoonstack/cocoon#274 (vm list verifies the VMM PID). Only api/ and image/ change. The agent and the app do not, except for one optional proto field (§14.8).
metadata {cb_machine: <machine id>, cb_owner: <owner>} on the claim. Claim metadata is immutable, and nothing else needs to be mutable.claims.json is the only record. There is no api-side database:machines.Machine |
from the sandboxd row |
|---|---|
ID, Owner |
metadata.cb_machine, metadata.cb_owner |
Image |
key.template |
Flavor |
key.size through the tier table (4 vCPU / 8 GiB = xlarge); a flavor that is not a tier is refused at config load |
CreatedAt |
claimed_at |
State |
see 14.3 |
Get / List = GET /v1/sandboxes?metadata=cb_machine=… / ?metadata=cb_owner=….GET /v1/sandboxes every 2 s refreshes it (the instance holds only cocoonbox claims), and each of the backend’s own lifecycle calls updates it. So Resolve on the edge path stays a map lookup, as the CocoonSet informer cache is today.cocoon image import into the sandboxd node, cbdesk:vN. image/cut.sh builds it from a VM booted from the previous template (docs/bench.md, New image cut).http(s)_proxy=127.0.0.1:3128 drop-ins on the agent, toolhost and session units;systemd-networkd-wait-online masked;agent.env whose gw key is a placeholder (14.5).CONFIG_INPUT_UINPUT is in the sandbox kernel.{template: cbdesk:vN, net: none, size: xlarge, warm: K, warmup: [wait for Xorg, session, toolhost, :5900, :7443]}. A warm clone is then a finished desktop with the agent up.rebase_images entry is a pool with warm: 0. The key is registered and the golden is built, so a rebase claim clones from the golden instead of cold-booting, and nothing idles warm.Spec.Image names the template. Boxes on an older image keep their claim; the pool only matters for new claims.Machines |
sandboxd | Latency (M4.5, nested) |
|---|---|---|
Ensure, no claim |
POST /v1/claim {template, size, ttl_seconds: 3600, on_expire: archive, metadata, env} |
warm 1 ms + env delivery |
Ensure, hibernated or archived |
POST /v1/sandboxes/{id}/wake; the new incarnation is then provisioned (14.5) |
0.26 s / 1.7 s + about 0.3 s to ready |
Ensure, failed |
POST …/wake (#269: a cold boot of the claim’s disk) |
boot time |
Hibernate |
POST …/hibernate |
23–50 s (writes the 8 GiB memory file) |
Delete |
POST …/release |
— |
Watch |
poll the row every 500 ms while the watch is open; the index refresh between watches | — |
Ensure and Hibernate return at once, as the interface requires. The sandboxd call runs in a backend goroutine, and the backend reports the in-flight state meanwhile.Revision is a per-machine counter in the backend. It increases on each lifecycle call and resets with the api, the same way operations do.States:
| Condition | State |
|---|---|
| a claim or wake call in flight; restarts moved and the agent has not answered on 7443 since | Starting |
| a hibernate call in flight | Hibernating |
hibernated or archived |
Hibernated |
failed |
Failed, with the row’s failed as Reason |
| otherwise | Running |
| row gone | ErrNotFound; StateGone on a watch that saw it |
Leases and tiers:
{ttl_seconds: 3600, on_expire: archive}, skipping archived ones, which sandboxd refuses to renew. A dead api therefore degrades to “archived at lease end”, never “destroyed”.idle_hibernate_seconds stays 0. When to hibernate is the service’s decision, as it is today.archive_after_seconds stays off at first. The archive tier only frees local disk once checkpoint_store: s3 exists on the node.Address returns a dialerThe agent is reachable only through GET /v1/sandboxes/{id}/ports/7443 with Upgrade: tcp. There is no IP. The edge serves Session.Screen itself through GET /v1/sandboxes/{id}/ports/5900, the box’s RFB server; no other port is relayed.
The interface change, backend-neutral:
// Endpoint is how to reach a machine's agent; Key changes when the machine moves.
type Endpoint struct {
Key string
Dial func(ctx context.Context) (net.Conn, error)
}
Address(ctx context.Context, id string) (Endpoint, error)
Key = sandboxd:<sandbox id>. Dial opens the relay with the api’s root token, which gives the passive relay.
Unavailable, the same as NotRunningError today).dialer all use it:
grpc.NewClient("passthrough:///"+key, grpc.WithContextDialer(…)), with the connection cache keyed by (box, Key).http.Transport per Key with DialContext = Dial.LoadCredential (root 0600). The edge authorizes user → box before it dials, exactly as today. No sandbox token is stored or requested.machines interface (Deliver, SetMeta, Incarnation; 14.5), so the api service layer and the agent do not depend on the backend.Hot-path cost against today’s direct h2c to the pod IP:
Resolve stays a map lookup (14.1).Deliver, gw key, identity, metaThe service layer computes each incarnation’s identity and hands it to the backend with Deliver(ctx, id, machines.Identity, keys), where keys are the gw key and the door key as named host-only secrets. It never gets a root exec into the guest and never knows guest paths or scripts. The sandboxd backend implements the same interface:
machines |
sandboxd |
|---|---|
Deliver: gw key |
a guest: false env entry GW_KEY (below) |
Deliver: identity |
written by the backend to the §12 path, either through POST /v1/sandboxes/{id}/exec (runs as root in the guest through silkd; exec takes only the claim’s own token, which the backend reads with the root by-id GET /v1/sandboxes/{id} at each call and never stores; the buffered exec has no stdin, so the file travels in the request env as CB_STDIN) or as guest env. How it is written is the backend’s choice |
Meta / SetMeta |
guest: true env entries CB_META_<key> (#268): readable with GET …/env and durable in claims.json. Claim metadata is immutable, and the values (wake id, flags) are not secret |
Incarnation |
a backend concern. It changes on claim, wake, unarchive and cold restart, and needs a per-claim boot counter in the sandbox row (upstream, 14.7). Until then, the backend derives it from claimed_at, restarts and its own wake count, kept in meta |
Deliver.
GW_KEY = Bearer <per-machine key>. It feeds the node secret {name: gw, header: Authorization, value_env: GW_KEY} on the pool rule {host 10.203.0.1, ports [18090], secret gw}, with egress_internal_allow: [10.203.0.1/32:18090,9000].agent.env, and sandboxd swaps in the machine’s key on the host side of the vsock door.guest: false values never enter the guest (#268).GW_KEY, so a claim without one fails closed (gw answers 401). A key can be set or rotated even while the box is paused.UNAVAILABLE “identity not provisioned” (§12). sandboxd itself re-delivers the claim’s guest env after a cold restart (#269).127.0.0.1:3128, which M4.5 measured with pages, updates and safe browsing. The egress lane is ruled out for a persistent box:
socks5: true door.* on ports 22, 80 and 443 only, so mail ports and every other port are closed. The SSRF guard keeps other cb VMs and host ports unreachable, which M4.5 measured as 502./workspace and agent.db.restarts move, reports Starting until 7443 answers, and then Running.Failed. Ensure then wakes it once more, since #269’s wake resets the budget.run_dir is on persistent disk. On tmpfs, the copy-on-write disks are lost with the host (#269’s finding), so cocoonbox nodes keep run_dir on the NVMe.Failed after the wake):
State.Restore from the latest manifest, then RUNNING.boxes, never in a backend.| Upstream (cocoonstack, mechanism only) | State |
|---|---|
per-claim env, guest: false secrets |
sandbox#268, in review |
dead-VMM cold restart, failed claims, vmm_exit / vmm_restart events |
sandbox#269, in review |
vm list verifies the VMM PID |
cocoon#274, in review |
| re-deliver the claim env after a cold restart; VMM exits from cocoon daemon’s event stream | in #269, stacked on #268 |
golden disk size per pool (storage_gib → cocoon vm run --storage) |
branch ready, not yet a PR |
| a per-claim boot counter in the sandbox row (claim, wake, unarchive, cold restart) | to open: one counter field; Incarnation derives from it |
CONFIG_INPUT_UINPUT in the sandbox kernel |
optional; the image kernel works meanwhile |
binary port_forward framing |
only if many full-rate viewers per node |
| sparse or compressed memory snapshots, sparse s3 export | backlog (§18) |
Stays in cocoonbox:
api/machines/sandboxd (the index, renew loop, state mapping and the relay dialer);Endpoint change through the edge, MCP and dialer;Deliver (gw key as guest: false env, identity to the §12 path);boxes;egress_internal_allow);sandboxd learns nothing about users, tiers or cocoonbox.
agent.env (placeholder gw key, no EDGE_TOKEN);Endpoint in machines, the edge, MCP and dialer, landing first with cocoonset unchanged in behaviour;Deliver, SetMeta and Incarnation in machines/sandboxd;machines/sandboxd;backend: sandboxd | cocoonset;cocoon image import, and the additions in 14.2 and 14.5.run_dir on the NVMe, fenced cores);Box.restarted_at_ms, so the app can say “your box restarted, memory was lost”. Without it, the app only sees a reconnect.Ensure on sandboxd, which restores through restore-on-missing. cocoonset stays configured as the fallback until the acceptance run passes. Then the default flips.Every step runs through the public edge, with the app or grpcurl, on the sandboxd backend. Each step records its timings.
Ensure from zero to RUNNING;Hibernate / Ensure round trip on a resumable operation stream;Session.Screen over the relay. Record fps, time to first frame, input RTT, and sandboxd CPU per viewer./workspace, a logged-in Chrome cookie and agent.db are kept./proc/*/environ, /etc, /run and disk, and from the box’s hibernate memory file on the host;GET …/env blanks it.box_id or an old wake_id is rejected;wake_id before RUNNING.kill -9 of the box’s CH brings the box back to RUNNING with the marker kept, and the app reconnects by itself;Failed, and Ensure recovers it.Ensure wakes it from the archive, state kept.Ensure restores from the state layer, and the loss window is reported.backend: sandboxd is the default, and backend: cocoonset still serves an existing CocoonSet box.The flip ran on 2026-09-30. On 2026-10-01 the cocoonset backend, the backend key and the K3s stack were removed; sandboxd is the only backend.
Proposed 2026-10-01, for review. Status 2026-10-10: only the alarm half is built, as scheduled wake (15.13); routines, Tick, the lease, notices and budgets are not. Agents act without a prompt from the user:
/workspace, a page changes, a connector event, a box wake);The result reaches the user in the app. A risky action waits for the user, as take-over does today.
Constraints:
Ensure, Hibernate, wake). No sandboxd timer carries product timing. idle_hibernate_seconds and the lease stay as §14.3 sets them.agent.db and travels with the state layer (§11).| A hosted assistant’s scheduled tasks (public docs, not verified here) | |
|---|---|
| Definition | a one-off or recurring prompt, about 10 active per user |
| Where it lives | the vendor’s cloud |
| Machine off | not applicable (no user machine) |
| Run | a new chat turn |
| Result | push and email |
| Safety | the vendor’s policy |
| Self-scheduling | yes, from chat |
The box keeps the routines; the api keeps an alarm clock.
routines table in agent.db holds id, owner_agent_id, name, prompt, trigger, precheck, grant, state (active | paused | pending_approval), created_by (user | agent), next_due_ms, last_run, plus a routine_runs table.{next_wake_at_ms, paused, spend_today}. It holds no prompt, no routine name and no trigger text.State): RoutineControl.Tick(TickRequest{now_ms, policy, ack_through}) returns a stream of run events and ends with {next_due_ms, active_runs}.
next_wake_at after Ensure wakes the box. It also calls it every 5 min on each running box as a heartbeat, and once before every Hibernate to learn the next alarm.now_ms comes from the api. A woken guest’s clock may lag until NTP syncs (§12), so due times are judged against the api’s clock.agent.db. The api acknowledges them with ack_through on the next Tick, and the agent re-sends what is not acknowledged, so a broken stream or an api restart loses no report.{lease_until_ms (now + 15 min), wake_id, paused, cancel_running, runs_left_today, min_interval_s, max_run_s}. The api folds every budget into runs_left_today (15.9f), so the agent never handles money.
wake_id it names (§12). After a wake, nothing fires until the first Tick, whatever the guest clock says.paused: true.missed. It never replays a backlog.Event triggers:
| Trigger | Source | While hibernated |
|---|---|---|
time (at, cron + IANA zone) |
agent scheduler, api alarm | wakes the box |
file under /workspace |
toolhost inotify, reported to serve |
none: files arrive only through a running box |
| page change | a cron routine with a precheck (15.4); no separate watcher | as time |
| connector event (mail, GitHub, Slack; M5) | a webhook to the api, which wakes the box and calls Routines.Deliver{routine_id, event} |
wakes the box |
| box wake | the Tick after a wake carries reason: resume; routines with on: resume run |
— |
Hot path: none. The alarm index is a min-heap in the api’s service layer, off the edge path. The heartbeat costs one RPC per running box per 5 min.
origin: routine:<id> (new TranscriptEntry field). The app shows it as a trigger, not as something the user typed.schedule tool takes create | update | pause | delete | list. Its fields are name, prompt, one trigger, an optional precheck, expires_at, and grant (15.5).
expires_at, and the tool refuses a watch without it.active only inside the default grant and the frequency floor. Otherwise they start pending_approval (15.5).cocoonbox.agent.v1.Routines through the edge offers List, Update (pause, resume, grant, delete), RunNow and Runs. The app and MCP clients manage routines through it.
Tick and Deliver sit on a separate service, RoutineControl, which the edge refuses by prefix as it refuses State.agent/src/identity.rs:56). The edge’s prefix rule (api/edge/proxy.go:35, :81-83) is the only gate.routine_list, routine_create and routine_pause.The api enforces every limit through the policy lease. The per-box gw key cap (§12.2) remains the hard backstop for model spend. Every default below is api config, not code; the wake floor and runs per day get tuned from the cost of a run (15.8).
| Limit | First-cut default | Why |
|---|---|---|
| routines per user | 20 active | bounds the alarm index and the prompt |
| wake floor | 1 h between alarms that wake a hibernated box | a wake plus a re-hibernate costs about 1 s up and 24 s writing 8 GiB of memory (15.8), and the write uses the node’s NVMe bandwidth; minute-level routines run only on a box that is already up |
| runs per day | 48 | bounds machine time |
| run wall time | 10 min, then the turn is cancelled | a stuck GUI step must not hold the box up |
| proactive budget per day | box-seconds and model cost, per user (15.9f) | spend control separate from interactive use; the dual meter of the cost model |
| failures | backoff 1, 2, 4 … 24 h; auto-pause after 5 in a row; pause at once on an auth failure | stops loops that burn tokens |
| away guard | no app visit for 3 days and 15 unread notices → one notice “keep or pause”; no answer in 3 more days → pause all | spend while the user is gone |
| kill switch | per routine, per user (“pause all”), global (api config) | operations |
Precheck: a routine may carry a shell command that runs as user without a model, with a 30 s limit. The model turn starts only if the command’s output differs from the last run’s, or on exit code 0 with non-empty output. A page watch is then curl -s … | sha256sum or a small script, and it costs no tokens while nothing changes.
observe is the default. It allows read-only tools (read_file, list_dir, browser_navigate, browser_snapshot, browser_read, screenshot) plus write_file and edit_file under /workspace for reports and drafts.observe has no shell (except the precheck), no browser_click or browser_type, no delegate_computer or computer_act, and no connector tools.act allows the full tool set and exists only after the user grants it for that routine in the app. The app shows the routine’s prompt and trigger at that moment.Caller carries a routine origin. The model also gets only the granted tool list.request_take_over.request_take_over. Or, when Link is connected, the agent pays with a one-time card from a spend request the user approved: link-cli writes the card to a file (--output-file), and computer_act type_from_file types each field from that file, so the card never reaches the model.waiting_for_user, with a needs_user notice, and the box may hibernate.active only with the observe grant, at or above the wake floor, and with an expiry for watches.pending_approval and shows a notice.routine_runs records the trigger, times, status, usage and grant of each run.agent.db.Session.Events streams it live when the app is connected.{id, box_id, agent_id, routine_id, kind (done | needs_user | failed | paused | pending_approval), at_ms, summary}.
summary (≤ 140 characters). It returns in the Tick stream, and the api stores it encrypted with the user’s state key and keeps it 7 days.Boxes.ListNotices and Boxes.WatchNotices serve the list, and the app shows it as a badge and a list without waking the box.agent.db, and with it the routines and their history, is restored by the state layer. After the restore, the api’s first Tick on the new machine re-derives the alarm. Nothing moves in the api.wake_id (§12), so the old machine’s credentials and leases die with it.Measured on the bench host on 2026-09-30 and 2026-10-01 unless marked. Sources:
usage.jsonl.cost_micros at the OpenRouter list prices in deploy/host/gateway.yaml: deepseek/deepseek-v4.1-flash $0.30 / $1.20 per M input / output, with cache reads at 0.02×; anthropic/claude-sonnet-5 $2 / $10.c3-highcpu-4, which is the box’s exact shape (4 vCPU / 8 GB). C3 is the nested-virt family the bench host runs on (c3-standard-22-lssd). Prices come from gcloud-compute.com’s copy of Google’s price list, dated 2026-09-27:
Spot prices move daily, so re-check them before reuse. For comparison, c3d-highcpu-4 (AMD, which needs PVM) is $0.1499/h list and $0.0366/h Spot in us-central1.
Pricing rule: a box holds its slot (4 vCPU, 8 GiB) from the wake call until its hibernate finishes. Machine cost = box-seconds × the slot price. A freed slot saves money only if another box uses it. The slot price is the planning line, as in the free-tier cost model.
| Step | Time | Source |
|---|---|---|
wake: hibernated → RUNNING (memory restore, identity written) |
about 1 s | wake op DONE in 1.0 s; 2026-10-01 sandboxd wake at 10:03:39.95, api “provisioned … as wake” at 10:03:40 |
| run, precheck with unchanged output | about 2 s (limit 30 s) | estimate: curl plus sha256sum |
| run, text turn (text-model main agent) | model calls span a median of 4.5 s and at most 197 s; with tool time, planned at 45 s | gw ledger, 12 text turns |
| run, GUI turn (vision computer subagent) | model calls span a median of 27.5 s and at most 92 s; planned at 150 s | gw ledger, 10 turns |
| hold: final Tick, then an incremental live save | about 1 s plus 1–3 s | api journal: Rebase saves “saved 1s”, “2s”, “3s” |
| re-hibernate: write 8 GiB of memory to the local NVMe | 24 s (23–50 s) | api “hibernating box” at 10:03:09, sandboxd hibernate at 10:03:32.98 |
vCPU-seconds. These are not measured. The guest is mostly idle while it waits on the model, and Chrome page loads are the busy part. M4.9f records them per run from the guest’s /proc/stat. A host-side count needs per-claim CPU-seconds in sandboxd. sandboxd records wake and hibernate events in usage.jsonl but no CPU counter, so this goes upstream as mechanism (15.9f).
Per run:
| Run | Box-seconds | Machine, list | Machine, Spot | Tokens through gw | Total, list | Total, Spot |
|---|---|---|---|---|---|---|
| precheck, output unchanged (no save) | 28 | $0.0013 | $0.0003–0.0005 | $0 | $0.0013 | $0.0003–0.0005 |
| text | 73 | $0.0034 | $0.0009–0.0013 | $0.0035 mean (median 0.0014, max 0.012) | $0.0069 | $0.0044–0.0048 |
| GUI | 179 | $0.0085 | $0.0022–0.0032 | $0.051 mean (median 0.020, max 0.28) | $0.059 | $0.053–0.054 |
| any run held to the 10 min wall | 628 | $0.030 | $0.0076–0.011 | bounded by the budget (15.9f) |
Per user-month (30 days; “text” and “GUI” as above):
| Usage | Alarm wakes a day | Box-hours a month | Machine, list | Machine, Spot | Tokens | Total, list | Total, Spot |
|---|---|---|---|---|---|---|---|
| 3 text runs a day, each its own wake | 3 | 1.8 | $0.31 | $0.08–0.12 | $0.32 | $0.63 | $0.40–0.43 |
| 3 GUI runs a day | 3 | 4.5 | $0.76 | $0.19–0.29 | $4.58 | $5.34 | $4.77–4.87 |
| §15.4 caps: 48 text runs a day, 1 h floor, so at most 24 wakes | 24 | 23.6 | $4.01 | $1.02–1.51 | $5.07 | $9.08 | $6.09–6.58 |
| the same caps, GUI runs | 24 | 65.6 | $11.16 | $2.84–4.20 | $73.30 | $84.46 | $76.14–77.50 |
| the same caps, every run held to the 10 min wall | 24 | 245.6 | $41.78 | $10.63–15.74 | up to the gw key cap ($10 a day) | ||
| box up all day, for comparison | — | 730 | $124.17 | $31.61–46.79 | the same per run |
The contract lands on main first (boundaries.md); then the phases follow in order. Every phase is gated by build, tests and lint. Bench host acceptance runs at the end of b, c, d, e and f. Routines stay off for every user not in routines.users (the same pattern as state.scheduled_users) until 9e and 9f are done.
Contract. proto/cocoonbox/agent/v1/routines.proto, sketched here; the fields are the contract to refine:
service Routines { // clients, through the edge
rpc List(ListRoutinesRequest) returns (ListRoutinesResponse);
rpc Update(UpdateRoutineRequest) returns (Routine); // pause, resume, grant, delete
rpc RunNow(RoutineRef) returns (RunAccepted);
rpc Runs(RunsRequest) returns (RunsPage); // history, newest first
}
service RoutineControl { // api only; the edge refuses it like State
rpc Tick(TickRequest) returns (stream TickEvent);
}
message Policy { int64 lease_until_ms = 1; string wake_id = 2; bool paused = 3; bool cancel_running = 4;
int32 runs_left_today = 5; int32 min_interval_s = 6; int32 max_run_s = 7; }
message TickRequest { int64 now_ms = 1; Policy policy = 2; TickReason reason = 3; uint64 ack_through = 4; }
enum TickReason { ALARM = 0; HEARTBEAT = 1; PRE_HIBERNATE = 2; RESUME = 3; CONTROL = 4; }
message TickEvent { uint64 seq = 1; oneof kind { RunStarted started = 10; RunEnded ended = 11; Notice notice = 12; TickEnd end = 13; } }
message RunEnded { string run_id = 1; string routine_id = 2; string agent_id = 3; RunStatus status = 4;
int64 started_ms = 5; int64 ended_ms = 6; repeated ModelUsage usage = 7; int64 guest_cpu_ms = 8; }
message TickEnd { int64 next_due_ms = 1; int32 active_runs = 2; }
The rest of the contract:
TranscriptEntry.origin = 6, a string (routine:<id>). The body is a blob, so old rows decode as "".routine event (oneof field 16) on Session.Events.Boxes: ListNotices, WatchNotices, DismissNotice, SetRoutinesPaused and ProactiveUsage.RoutineControl is api-only.An alarm Tick streams until every run it started has ended. A heartbeat Tick returns once it has started what is due, and those runs report on a later Tick.
Scope:
agent/src/store.rs:
routines and routine_runs go into SCHEMA (:12-42). CREATE TABLE IF NOT EXISTS covers new tables; there is no migration mechanism, and none is needed for them.restore_from (:276) re-applies SCHEMA. Today it swaps in the restored database without it, so a restored pre-routines agent.db would lack the tables until the next process start.delete_rows (:416) also deletes the agent’s routines.queue_turn, which builds the user entry at :201-210, takes an origin.agent/src/runner.rs:
queue_routine next to send (:115). It enqueues on the same per-agent slot (slot, :373), skipping attachments and auto-title. send is the only enqueue path today.run_turn (:399): a max_run_s sleep arm in the select! (:425-429) for routine turns, which ends them as timeout. Today no turn has a wall time; the only limits are steps (40 main, 30 computer) and the model client’s 180 s read timeout.take_over (:555): a routine turn waits at most 10 min, then ends as waiting_for_user. Today rx.await (:571) has no timeout; the turn lets go of the turns read lock while it waits.computer.rs:92) goes into the run row. TurnEnd keeps one sum, as now.main_tool (:533-546): the grant check for delegate_computer and request_take_over, which never reach the dispatcher. The tool list (:473-477) and the system prompt (prompts.rs:3-18) show only granted tools.agent/src/dispatch.rs:
Caller (:64) gains grant: Option<&Grant>.execute (:122) refuses any tool outside the grant’s allowlist before self.tools.run (:138). It returns ToolOutput::error through record_result, so the refusal lands in the transcript./workspace, because files::resolve accepts absolute paths.credential_fill and active_origin (§20), which only Session.FillCredential and Session.ActiveOrigin call.agent/src/routines/:
at, 5-field cron with an IANA zone, @every);RoutineControl.Tick server, with the clock offset now_ms − guest now taken at each Tick.agent/src/tools.rs: the schedule tool in main_tools (:10), with the observe grant only.agent/src/server.rs:
serve_on (:319) registers Routines and RoutineControl behind EdgeAuth;recover after a restore (:287) also closes running runs as interrupted.edge/proxy.go refuses /cocoonbox.agent.v1.RoutineControl/ next to stateServicePrefix (:35, :81-83).Acceptance:
cargo test cases:
at in the past;wake_id;computer_act, shell, delegate_computer and a write to /etc under observe, each in the transcript;waiting_for_user;SCHEMA.go test ./edge with a RoutineControl case answering PermissionDenied.Routines.List through the edge, and grpcurl RoutineControl/Tick through the edge is refused.Left out: the api side, the precheck, notices.
Scope:
api/boxes/routines.go: RunAlarms(ctx, cfg), a min-heap of {box, next_wake_at_ms} in the service layer. main.go starts it next to RunScheduler (:198).RoutineAgents is an interface beside StateAgents. The dialer implements it as a sibling of Dialer.state, on the shared dialer.Pool.state.bucket is set (enableState in main.go), and the notices of 9d live in the store, so validate refuses routines without the state layer.<box>/proactive.json in the state store, written only by the api through putJSON and getJSON (statelayer.go:359-376):
next_wake_at_ms, paused, the day counters, last_seen_ms and the away state;machines.List("") and these objects;SetMeta) is rejected for this record: a Rebase makes a new machine and drops its meta (rebase.go:55), and every meta write re-emits Watch.auth.WithUser, because the loop has no caller (as in controlmcp.go:84).Ensure (boxes.go:46), then waitRunning (rebase.go:103).Tick{reason: ALARM}, read to its end.If a hibernate is still in flight, the path waits for HIBERNATED first. Otherwise sandboxd Ensure returns ActionNone (machines/sandboxd/sandboxd.go:114-115), and the CREATING operation would wait 10 min.
ops.activeFor), the api runs:
startSave), waiting for it to finish (open decision 4);PRE_HIBERNATE Tick;Hibernate.Hibernate does not check activeFor and would supersede a save (operation.go:162-164), so the alarm path waits. For the same reason a Tick is never an operation.
ready, provision.go:153) of routines.users, in the shape of the saveStale loop (save.go:57-77).Hibernate (boxes.go:146-149, before s.machines.Hibernate), with a 2 s timeout. If it fails, the box hibernates anyway and keeps its last alarm.accesslog.go logs only Boxes calls.
Session.Events and Session.Screen streams per box in pump (edge/proxy.go:102-111), and stamps MCP requests (edge/mcp.go:77-83).Presence interface declared in boxes and wired in main, so the edge still imports no service code.user (who has sudo) keep the box up at our cost.Routines block in config.Config (config.go:101-114): users, heartbeat, lease, catch-up, wake floor, max run, and the 15.4 limits. Defaults go in applyDefaults and checks in validate. The load is strict, so the block ships with the binary that reads it.Acceptance, on the bench host with the dev box hibernated and the app closed:
journalctl -u cocoonbox-api shows alarm → RUNNING → Tick end → save → hibernate, with each step’s time. The cbsbx usage.jsonl shows exactly one wake and one hibernate, and Routines.Runs shows the run done with its usage.Left out: notices (the result is only in the transcript), budgets beyond the lease.
Scope:
routines/ runs the precheck through the toolhost shell as user, with a 30 s limit (the shell’s default is 120 s, toolhost/shell.rs:16).skipped_precheck, with its time and no tokens.Acceptance: a routine with the precheck curl -s https://example.com | sha256sum fires on three alarms. All three runs are skipped_precheck, and the gw ledger has no row for the machine in those windows. Changing the command’s output gives one model turn.
Left out: any precheck isolation beyond user’s shell.
Scope:
Notice events in the Tick stream, of kind done (the run produced a message), needs_user, failed, paused or pending_approval.
<user>/boxes/<box>/notices.json, encrypted with AES-256-GCM under the user’s state key (keyring.Keyring.StateKey, §22.5). They are sealed with keyring.Sealer under an Info string of their own.ListNotices, WatchNotices and DismissNotice serve them.macos/):
BoxConnection.swift gains the notice and routine calls.AppModel.swift keeps the notice list and the badge, from Boxes only, so it works while the box is hibernated.RootView.swift adds a routines list per agent: pause, resume, run now, delete.ConversationView.swift shows routine:<id> entries as a trigger line, not as user text.UNUserNotificationCenter while the app runs.Services.swift, MockWorld.swift) serves both services for the app’s tests.Acceptance, on the bench host:
observe grant. At 08:00 the box wakes, the run writes the digest and the box hibernates again.ListNotices shows the summary while the box is hibernated, and the app shows the badge.usage.jsonl has no extra wake.Left out: APNs and email.
Scope:
pending_approval for agent-created routines outside observe or below the wake floor.SetRoutinesPaused (“pause all”) writes proactive.json.routines.enabled: false plus a restart, since the api has no config reload. At start, the api sends a CONTROL Tick with paused: true to every running box.last_seen_ms, the last client attachment (9b): it applies the 15.4 rule with one notice, then a pause.Acceptance:
go test ./boxes, with a test clock.GW_KEY (PATCH env) makes the next run fail with 401 and pause its routine.Left out: tiers.
Scope:
guest_cpu_ms (the guest’s /proc/stat delta, reported by the guest).HIBERNATED, split into wake, run, hold and hibernate.GET /admin/usage/users?since=<day start> with the tenant token, once per heartbeat for all boxes. It returns rows by (user, model), with tokens and cost_micros.attributed_user). So the guest cannot move spend to another box, and the rows are authoritative per machine.proactive.json, box_seconds_per_day and cost_micros_per_day (open decision 1).
runs_left_today: 0, and the api wakes no box for an alarm.missed: budget.Boxes.ProactiveUsage{days} returns per day: alarm wakes, box-seconds, runs, and cost both as the agent reported it and from gw.Routines.Runs.cpu.stat. This is the cost model’s third meter. Until then, CPU-seconds are only what the guest reports.Acceptance, on the bench host, five alarm runs:
usage.jsonl. This is the second signal for machine time.missed: budget.Left out: billing, tiers.
| Failure | What happens |
|---|---|
| api down at an alarm | No wake. At start, the api rebuilds the heap from proactive.json, and an alarm in the past wakes its box at once, within the node’s wake limits. The agent runs each missed routine once inside its catch-up window and records the rest as missed. It never replays a backlog. |
| api down while a box is running | In-box fires stop when the lease ends, within 15 min. |
box fails to wake (Ensure error, Failed) |
Retries at 1, 2 and 4 min. Then an api notice failed (“your box did not wake for a scheduled run”), and the alarm moves one wake floor ahead. A machine that is gone goes through restore-on-missing (§14.6), which brings back agent.db and its routines from the last save. |
| run past the wall | The agent cancels the turn at max_run_s (status timeout, counted as a failure for backoff). The api has its own guard: if an alarm Tick stream is still open at max_run_s + 2 min, it sends cancel_running: true, waits 30 s, and then hibernates anyway. A turn caught in a hibernate image probably fails on the next wake, since its model stream is gone; this is unverified, and 9b measures it. |
| approval nobody answers | After 10 min the turn ends as waiting_for_user, with a needs_user notice, and the box may hibernate. The user replies in the conversation; a continuation turn comes later (15.5). An M5 connector approval takes the same path (§16.5). |
| Rebase mid-run | A Rebase saves QUIESCED, which waits 30 s for running turns and then fails (state/mod.rs:539-542). So runRebase (rebase.go:42) first sends a CONTROL Tick with paused: true, cancel_running: true. Cancelled runs end as interrupted, which is not a failure, and fire once on the new machine if they are still inside their catch-up window. |
| restore-on-missing mid-run | The run is lost with the machine. The restored agent.db may predate the run’s start, so the run can repeat inside its catch-up window. Under observe, a repeat only rewrites files in /workspace. act will need a per-run idempotency key (open decision 6). |
| VMM crash (cold restart, #269) | Memory is lost. recover ends open turns as failed and marks the run interrupted, and the next Tick fires it again inside its catch-up window. |
| clock | The api sees only UTC next_wake_at_ms and never parses a trigger. The agent judges due times by the api’s now_ms plus the offset measured at each Tick. After a wake, nothing fires before the first Tick (the lease’s wake_id). |
| time zones and DST | Cron is evaluated in the routine’s IANA zone with the image’s tzdata (/usr/share/zoneinfo). A local time that does not exist (spring forward) fires once, at the first instant after the gap. A local time that occurs twice (fall back) fires once, at its first occurrence. @every is an absolute interval and ignores DST. The app shows the next due time in the routine’s zone and in the Mac’s. tzdata changes arrive with image cuts, so a zone whose rules changed keeps the old rule until the next cut. |
| duplicate reports | Run events are rows with a sequence number, acknowledged through ack_through. The api dedupes by run_id. |
Per run (agent, routine_runs; it travels with agent.db; the newest 100 per routine are kept):
run_id, routine_id, agent_id, turn_id;alarm, heartbeat, run_now, resume or catch_up;due_ms, started_ms, ended_ms;done, silent, skipped_precheck, timeout, failed, waiting_for_user, interrupted or missed (with a reason: budget, lease, window);guest_cpu_ms.Per alarm wake (api journal, structured, never content):
RUNNING, Tick end, save end, hibernate start and hibernate end;Per policy change (api journal): pause all, resume, the kill switch, an away-guard pause.
Per day and user (api, proactive.json): alarm wakes, box-seconds, runs, cost as reported by the agent and from gw, and the budget left.
Second signals:
wake and hibernate times in sandboxd’s usage.jsonl.meter_mismatch.How the operator sees it:
journalctl -u cocoonbox-api | grep routines shows the wakes; Boxes.ProactiveUsage through grpcurl gives the numbers; and gw’s /admin/usage/users gives the spend per machine.Decided 2026-10-01: all eight items follow the recommendation below, with one addition to item 5. M4.9 as a whole is in the backlog (§18) and is not scheduled.
at one-shots keep the exact time. This costs nothing./workspace or agent.db, and none after a precheck-only run. A plain hibernate does not save today, so without this a node loss loses the run’s output.act. Recommended: settle it before act ships. Each run gets an idempotency key that write tools and M5 connectors receive, so a run repeated after a restore cannot file the same receipt twice. observe does not need it.A user asked for a routine at 09:00 while the Mac is closed. This is the alarm of 15.2 cut down to “wake, stay up for a while, sleep again”: the api owns the time and the box owns the content, a systemd user timer that runs the routine (contracts.md, Scheduled wake). No Routines service, no Tick, no lease and no notice exist.
Boxes.SetWakeSchedule{box_id, cron, tz, awake_for_s}, ListWakeSchedules and ClearWakeSchedule{box_id}, one schedule per box. CLI cocoonbox box schedule set|list|clear; Kit BoxConnection.setWakeSchedule, listWakeSchedules and clearWakeSchedule.wake_schedules in the users database (migration 0007), not the <box>/proactive.json of 9b. The per-user cap needs the owner’s row lock that boxes already takes, a box delete removes the row by cascade, and the alarm needs no state layer. A row holds cron, tz and awake_for_s and the alarm state next_at, last_wake_at and sleep_at, and no prompt or command.api/boxes/wake.go, RunAlarms every 15 s, off the edge path): each pass reads every schedule in one SELECT, with no heap, since 3 per user bounds the count. From a minute before a time, a box without memory (hibernated, failed, no machine) gets Ensure as its owner. The exception is a box this schedule woke less than wake.floor ago. A box that is hibernating gets the wake once its hibernate ends, so a client may put the box to sleep just before the time. A box that is up gets nothing, and the time passes. A time missed while the api was down gets one wake at once, with a full awake_for from that wake. The pass stores the wake before its Ensure, so no failure leaves a woken box that the api will not hibernate. A pass that read a schedule the user replaced meanwhile writes no alarm state over it and wakes nothing for it. The schedules of disabled users are skipped.awake_for, the api runs Hibernate when all of these hold:
sleep_at is set);A reach is any call the edge routes to the box once Resolve succeeds (agent gRPC, MCP, the screen, a share’s viewer; Resolver.Reached), or the owner’s Ensure. It makes the box user-driven, and the box stays up until someone hibernates it. The api’s own calls are no reach: provisioning and saves skip the edge, the alarm’s Ensure carries a context mark, and ending a share’s sessions calls Resolve without Reached.
Ensure takes one mutex and one map write. The claim path is untouched.next_at, last_wake_at and sleep_at persist, so a restart keeps the next wake and a pending hibernate. The pass after a reach clears sleep_at, so a hand-over survives a restart too. Only a reach in the last 15 s before a restart is lost; the restart cuts every edge stream, and clients that reconnect count again.tz. Alone, the library skips a spring-forward time and fires a fall-back time twice (2026-03-08 02:30 and 2026-11-01 01:30 in America/New_York). On the wall clock, a skipped time fires once at the end of the gap, and a repeated time fires once at its first occurrence, as 15.10 asks. time.Date picks the second occurrence east of UTC (Europe/Berlin, Australia/Sydney), so the code looks for the earlier instant itself; the tests cover New York, Berlin and Sydney. SetWakeSchedule refuses a cron whose next 512 times come closer than the floor.wake): max_schedules is 3 per user, floor 1 h, and max_awake_for 1 h. awake_for defaults to 15 min and is at least 1 min.Ensure, once the box was RUNNING: −13 to +1 ms off the host clock, inside read brackets of ±28 to ±46 ms. 10 s later it was −15 to −6 ms off.KVM_CLOCK_REALTIME (hypervisor/src/lib.rs, with_realtime_filled). The guest clock moves on by the host’s wall time spent hibernated, so a longer sleep than the 120 s measured gives the same result.System clock synchronized: no), because the box has no NTP egress.awake_for: there is no box-side keep-awake signal. A file the routine touches would need a new backend read into the guest. It would also let user, who has passwordless sudo, keep the box up at our cost, the reason 9b rejects a box-reported presence flag. awake_for is the hard cap instead, and the unit’s TimeoutStartSec= keeps the routine inside it. A routine still running at the hibernate freezes with the box and goes on at the next wake, its connections gone.wakee2e, ran with its own Postgres, its own port and the real Boxes, edge.Proxy and RunAlarms. A second api on the shared cbsbx instance is not safe: the backend renews every claim’s lease and provisions every unprovisioned machine. So its backend drove one box of a throwaway user through the live api’s public Boxes calls, as any client does. The box was cbdesk:v25, with a Persistent=true user timer at four times. awake_for was 2 min, and the floor 1 min for the run.
AccuracySec defaults to 1 min); hibernated at T+2 min.Session.Hello through the instance’s edge at T−20 s: the next pass logged “a client reached the box” and stored the hand-over; the box was still RUNNING at T+2.5 min.The run of the build before the review fixes gave the same four outcomes, except that in phase 4 the box got only the rest of awake_for after its late wake.
Proposed 2026-10-01, for review. It replaces §4.2(b), whose in-VM vault kept credentials at rest in the VM. The user links an external account (GitHub, Google, Notion, …) once in the app. After that, both kinds of agent can use it:
Local exec on the Mac (§4.2(c)) is not part of this section.
Constraints:
References:
The boundaries.md order is Mac Keychain first, then host side, then VM memory for one request. How the options compare:
| Mac Keychain | Host-side broker | |
|---|---|---|
| Mode B (local agents) | works | works |
| Mode A while the Mac sleeps (proactive runs, §15) | fails: every refresh and every call needs the Mac online | works |
| Implementations | a second set of connector clients in Swift, next to the host’s | one |
| Blast radius | one Mac | the api host; its master key opens every grant |
Decided: host custody, stored in PostgreSQL (16.1.1).
The broker is a service-layer package of the api (api/connect), not a new component. It holds:
Records are sealed with AES-256-GCM under a key derived from the user’s state key, which the master key seals and the database never sees in the clear (16.1.1). The keyring’s plain JSON files are not used.
Connect uses the api’s PostgreSQL database, next to the users tables, with its own migrations (api/connect/migrations, tracked in connect_schema_migrations).
Why PostgreSQL:
Where it runs:
Encryption (envelope):
HKDF-SHA256(state key, "cocoonbox connect grant"). The state key is the 256-bit per-user key of the state layer, which users.state_key_sealed holds under the master key, so Connect adds no keyring and no key table.LoadCredential (root 0600), like the edge signing key;Encrypt and Decrypt with ADC.table|row id|user id|connector, so a row copied onto another user’s or another connector’s row fails to open.Tables (migration 0001; connect_door_keys came with the box door; since migration 0004 every user_id references users ON DELETE CASCADE):
| Table | Columns | Notes |
|---|---|---|
connect_grants |
id PK, user_id, connector, kind, mcp_url, account_label, scopes text[], status (active / needs_reconnect), policy jsonb, sealed bytea (access token, refresh token, an API key and its header, a registered client), token_expires_at, created_at, updated_at |
unique (user_id, connector); policy and the expiry stay plain, so listing and the refresh check need no decrypt |
connect_flows |
id PK, user_id, connector, kind, mcp_url, state_sha256, session_sha256, sealed bytea (PKCE verifier, the registered client), expires_at |
10 min TTL; deleted when the callback takes it |
connect_approvals |
id PK, user_id, grant_id, tool, args_sha256, summary, decision (pending, once, always, deny), expires_at, used_at |
used once, 10 min (16.5) |
connect_door_keys |
key_sha256 PK, machine_id, user_id, incarnation, created_at |
the box door key (16.3, migration 0003); the key itself is never stored |
connect_audit |
id bigserial PK, at, user_id, connector, tool, door, caller, decision, outcome, upstream_status, latency_ms, req_bytes, resp_bytes, args_sha256 |
index (user_id, connector, at); a daily job deletes rows older than 90 days |
How the broker uses it:
SELECT … FROM connect_grants WHERE id = $1 FOR UPDATE;A rotated refresh token is therefore stored before the new access token is used. If the commit fails after the vendor rotated, the grant becomes needs_reconnect.
connect_door_keys. Provisioning an incarnation inserts its row and deletes the machine’s older rows in the same transaction, before Deliver installs the key. Deleting a machine (box delete, user delete, park, Rebase) deletes its row; a hibernated machine keeps it. At start the api deletes the rows of machines the backend no longer holds.pg_advisory_lock, so concurrent replicas run each one once.pgx/v5 with its pool. There is no ORM.Hot-path cost: none on the claim, screen or edge path. A connector call adds one primary-key read and one audit insert, about 1 ms against a local PostgreSQL, next to an upstream call of 100 ms or more. A refresh adds one row lock, held only for the vendor round trip.
gw’s MCP gateway does not fit as the broker:
Adding those to gw would put cocoonbox’s user policy into infra.
The flow runs in the browser, from the console’s Connect page (/cb/connect), and the code comes back to the api, which does the exchange. Decided 2026-10-05: the console page replaces the app’s loopback listener, and the app’s Connections… item opens that page.
POST /cb/api/connect/{connector}/begin. The api creates a flow:
state, bound to the user, to the hash of the console session secret and to the hash of a random 32-byte nonce, with a 10 min TTL, used once;It sets the cookie cb_connect to the nonce, for /cocoonbox.connect/ only, and returns the vendor’s authorization URL. Its redirect_uri is the api’s https://<endpoint>/cocoonbox.connect/callback.
cb_connect cookie equals the flow’s nonce hash, the state is unused and unexpired, and the console session that began the flow is still live for the flow’s user./cb/connect?connected=<connector>, which shows the account as connected.The cookies:
cb_session is SameSite=Strict with Path=/cb/, so it never reaches the callback: the vendor’s redirect is a cross-site navigation to another path.cb_connect carries the flow’s nonce for 10 min, SameSite=Lax, which a top-level GET navigation sends. The callback clears it. The session secret never leaves /cb/.Why the flow is bound to the console session:
client_secret, so the exchange must happen where the secret is.OAuth clients:
https://<endpoint>/cocoonbox.connect/client.json where the server supports one, and by dynamic registration otherwise.resource parameter.This is what brings Notion and Linear without any per-vendor code.
begin, over TLS.Connector tools are MCP tools, served by the broker.
api.githubcopilot.com/mcp/;mcp.notion.com/mcp;mcp.linear.app/mcp;*mcp.googleapis.com (developer preview).<connector>_<tool>, within ^[a-zA-Z0-9_-]{1,64}$. The broker keeps the upstream’s readOnlyHint and destructiveHint annotations.Doors:
| Door | Caller | Auth | Serves |
|---|---|---|---|
Connectors gRPC (proto/cocoonbox/control/v1/connectors.proto) |
the app | user token | catalog, connect, policy, approvals, activity, disconnect |
https://<endpoint>/cocoonbox.mcp/connect |
the bridge (mode B) | user token | the user’s connector tools |
http://10.203.0.1:18091/mcp, host-internal |
the box (mode A) | a per-machine key that the box never holds | the box owner’s connector tools |
The box door follows §12.2 (the proof comes from the channel) and the gw pattern of §14.5:
Deliver call: a guest: false entry CB_CONNECT_KEY = Bearer <key>. The broker keeps the key’s SHA-256 with the machine and its owner (connect_door_keys); the next incarnation’s key replaces it. A failure to issue a key is logged and does not stop the box from starting; its connector calls then get 401.{host 10.203.0.1, ports [18091], secret CB_CONNECT_KEY}, ahead of {host *, intercept inject}, because a plain request takes the first rule that matches;{name CB_CONNECT_KEY, header Authorization} with no value_env, so only the claim’s entry feeds it, and a claim without one fails closed.egress_internal_allow becomes 10.203.0.1/32:18090,18091,9000, and the host firewall (cb-host-net, CB-OUTPUT) lets the proxy reach 18091. The api listens on connect.box_listen (10.203.0.1:18091); CB-INPUT drops the port on the uplink. The proxy overwrites Authorization on every request to the door. Older pools reach the port through their * rule, but without a key, so they get 401.
application/json. The agent’s GET event stream, which carries list_changed, streams through the forward proxy: since sandboxd b778c66 the proxy passes an event stream through as each chunk arrives.agent.env would be a credential at rest in the VM, one that opens the user’s accounts. agent.env carries only the door’s URL, CONNECT_URL, which is the same for every box.Egress injection, for programs that don’t speak MCP (M5.2, in the first cut). Some work needs the credential on the wire of an ordinary program, above all git and gh against GitHub. For that case only:
guest: false env.intercept: true in the node config overwrite Authorization:
github.com with Basic x-access-token:<token>;api.github.com with Bearer.How it works:
sandboxd/egress/proxy.go:378-389), so the broker precomputes Basic base64(x-access-token:<token>).PATCH /v1/sandboxes/{id}/env:
sandboxd accepts a guest: false change even on a paused or hibernated claim.
machines.SetCredentials(id, Credentials{Secrets, Flags}). The sandboxd backend writes only entries that changed. A pool that refuses them (400, a pool that does not intercept) is tried again only when the values change.Limits, from sandboxd’s code:
sandboxd/egress/intercept.go:32)./etc/ssl/certs accept the proxy’s certificate: git, curl and gh do.Every other connector stays broker-only.
Per-claim credential rules (available since sandboxd e0e27fc, cocoonstack/sandbox#302). Today every injected host and secret is node config, so each new vendor would be a config change on every node, which puts cocoonbox’s catalog into infra. The upstream primitive is a per-claim credential list ({hosts, header, value}), set through the API, validated as a subset of the pool’s intercepted hosts, persisted with the claim, redacted on read and never delivered to the guest (connect-research.md §4). With it, a vendor without MCP becomes a catalog entry, and its logic runs in the box as a CLI, SDK or script. Such grants get vendor-scope approval only, and the app says so at connect time. Filed as cocoonstack/sandbox#301 (2026-10-03).
With #302, M5.2 uses claim env entries with inject instead of pool secret rules: the node config keeps only egress_ca and the intercept: true rules for the GitHub hosts, and the api sets and rotates the values per claim (rotation works on a hibernated claim). egress_ca loads only at a sandboxd start and only when a pool intercepts. cbsbx-conf writes it whenever /etc/cocoonbox/egress-ca/root.crt exists, so an image cut keeps it. Placeholder gating (sandbox #307, 858a80e) keeps the injection off the box Chrome’s github.com traffic. The recipe:
GH_GIT: value Basic <base64(x-access-token:<token>)>, inject {hosts: [github.com], header: Authorization, placeholder: "Basic @GH_GIT@"}. Only git requests carry the placeholder that the proxy replaces.Authorization: Basic @GH_GIT@ to github.com itself, and GitHub answers 400 even for a public repository. The switch:
COCOONBOX_GH_GIT = 1 (guest: true) in the same claim env as GH_GIT, and removes both on disconnect./run/silkd.env (root 0600). The path unit cocoonbox-github-git.path follows that file, and its service writes /run/cocoonbox-github.gitconfig with the extraHeader while the flag is set, and removes the file otherwise. The service also runs at boot./etc/gitconfig includes that file, and git ignores an include that does not exist.guest: true change on a paused or hibernated claim (409 sandbox is paused). The api therefore sets the flag on a running claim: after the wake in Ensure, or at Connect while the box runs. The /run file is in the box’s memory, so it survives a hibernate.GH_API: gh sends Authorization: token <GH_TOKEN>, so the box sets GH_TOKEN=@GH_API@ and the claim entry is value token <token>, inject {hosts: [api.github.com], header: Authorization, placeholder: "token @GH_API@"}; the browser’s api.github.com calls pass untouched. From v24 the image carries gh and the wrapper /usr/local/bin/gh, which sets GH_TOKEN=@GH_API@ only while the same switch is on (/run/cocoonbox-github.gitconfig exists) and the caller set no GH_TOKEN of its own.GOOGLE_CALENDAR: value Bearer <token>, inject {hosts: [www.googleapis.com], header: Authorization, placeholder: "Bearer @GOOGLE_CALENDAR@"}. The units that carry the proxy drop-in also set GOOGLE_OAUTH_ACCESS_TOKEN=@GOOGLE_CALENDAR@. It needs no switch: without a grant, Google answers the placeholder with 401, and the placeholder gate keeps Chrome’s googleapis traffic untouched.egress_ca in NSS, because the github.com intercept rule terminates all of the box’s github.com TLS. sandboxd writes the root to /usr/local/share/ca-certificates/sandbox-egress.crt and appends it to /etc/ssl/certs/ca-certificates.crt, on a golden or cold boot of a pool that intercepts. Chrome on Linux reads ~/.pki/nssdb instead. egress-ca-nss imports the root there as sandbox egress, trusted C,,, and deletes an import whose certificate differs or whose file is gone:
cocoonbox-egress-ca.path runs it when sandboxd writes the file, which can happen after the session has started;cocoonbox-session runs it before each start, which covers a state restore that brings back an older ~/.pki.It costs 9 ms per session start when the import matches, and 25–50 ms when it imports.
requests uses certifi, and Node uses its bundled roots and ignores the proxy env. export-rootfs.sh sets REQUESTS_CA_BUNDLE, SSL_CERT_FILE and NODE_EXTRA_CA_CERTS to /etc/ssl/certs/ca-certificates.crt, plus NODE_USE_ENV_PROXY=1, on every unit that carries the proxy drop-in. silkd clears the env of its execs, so a silkd exec does not get them. The bundle always exists, so a pool that does not intercept gets no warning.Accepted on cbsbx 2026-10-03: #302 (9 steps) and the placeholder gate with real git 2.43.0. The NSS import and the git switch were accepted the same day on a throwaway claim of a test pool that intercepts postman-echo.com and github.com, with the branch’s image files installed by hand: Chrome loaded the intercepted host with the egress intermediate as issuer, and failed with ERR_CERT_AUTHORITY_INVALID before the import; git sent no Authorization to github.com without the flag, and the placeholder with it.
Egress-injected grants (M5.2) also refresh on a timer, but only while their box runs.
invalid_grant, or on a vendor 401 after a refresh, the grant becomes needs_reconnect. Its tools then answer “Reconnect GitHub in the CocoonBox app”, and the app shows a badge.oauth2/revoke, GitHub DELETE /applications/{client_id}/grant, Slack auth.revoke (Notion documents no revoke call);tools/list_changed on the open sessions;It does not sign the box’s browser out of the vendor’s website; that session is the box’s own state.
account argument on every tool, so they come later.include_granted_scopes).allow, ask or off.
readOnlyHint are allow and all others are ask. This is Muse’s default: writes need approval.ask, because the box is not trusted:
approval_id and a summary that the broker renders from the actual arguments (“Create issue ‘x’ in loomhand/cocoonbox”), never from model text.ApprovalRequest{kind: "connector"} with that summary and waits, as it does for take_over.Connectors.Approve{approval_id, decision: once | always | deny} on the api with the user token, then Agents.Respond to resume the turn. When the broker no longer holds the approval (it expired, or the console answered it), the app still resumes the turn with the user’s answer and says so in one line; the broker decides the retry. always sets the tool’s policy to allow. Every answer, the console’s too, also reaches the waiting agent from the broker as notifications/cocoonbox/approval_answered on its box door stream, so a console answer resumes the turn without the app.approval_id in the call’s _meta. The broker accepts it once, only for the same arguments (compared by hash), within 10 min.The box cannot forge an approval, because only the user token can create one.
needs_user.allow, set in the app or through Always allow. That scope is the only knob, and proactive runs get no bypass of their own. allow covers every caller of the user’s connector (mode A and mode B alike), and the routine’s grant narrows which runs see the tool. Example: “every morning, file my receipts” finishes unattended only after the user sets the filing tool to allow.ask tools are served with destructiveHint: true;outcome_unknown and never retries a write itself. The agent checks the result with a read tool before it calls the write again, so one approval never sends two emails.box:<agent id> or mcp:<client name>);allow, approved, denied or off;Records are kept 90 days. Connectors.Activity shows them per connector in the app.
| Connector | Auth | Why | Gate |
|---|---|---|---|
| GitHub | our GitHub App | the most asked-for by coding work; no vendor review | none |
| Google Calendar | our Google client | Calendar scopes are sensitive, not restricted (to confirm at registration) | Google brand verification |
| Notion, Linear | the vendor’s MCP server, with client metadata or dynamic registration | no per-vendor code | none |
| Custom MCP server (URL, plus OAuth or a key) | MCP authorization | covers the long tail | – |
| Gmail, Drive (read) | our Google client | high value | see below |
| Slack | – | not in the first cut: no adapter | waits for a directory listing (Slack’s MCP server serves only listed apps) or for per-claim credential rules (16.3) |
Gmail and Drive:
gmail.readonly and drive.readonly are restricted scopes.drive.file is not restricted.Google’s Workspace MCP servers are in developer preview, and our account is not enrolled (2026-10-05). Google Calendar is therefore a grant-only connector: Connect, refresh, revoke and Activity work, it serves no tools, and programs in the box will use it through per-claim credential rules (16.3, an inject entry for www.googleapis.com with Authorization: Bearer <token>). The broker gets no adapter.
Connector events for §15.2’s triggers (GitHub webhooks, Gmail push) arrive at the broker. They are not in the first cut.
Split:
control/v1/connectors.proto;ApprovalRequest.kind = "connector", with an approval_id that the api issued;api/connect: the PostgreSQL store with its migrations and envelope encryption (16.1.1), the OAuth and MCP-authorization client, the upstream clients, refresh, policy, approvals and audit;Connectors service and /cocoonbox.mcp/connect;CB_CONNECT in the sandboxd Deliver;machines.SetSecret, and the token push on refresh and before wake.agent.env, reached through the proxy;/mcp, because mode B gets them from the api;ApprovalRequest and the retry./cb/connect: the catalog, Connect, per-tool policy, activity, pending approvals, disconnect and reconnect./cb/connect in the default browser;Connectors.List, read when the app connects and each time it becomes active. The item then names the accounts to reconnect, and a Reconnect toolbar button opens the same page;/cocoonbox.mcp/connect and passes their list_changed on.CONNECT_URL in the static agent.env.LoadCredential file on the bench host, a Cloud KMS key in production;https://<endpoint>/cocoonbox.connect/callback;egress_ca and the intercept rules for GitHub.Order:
Hot-path cost: none on the claim, screen or edge path. A connector call adds one host hop, and the bridge opens one more MCP session at start. M5.2 adds one host-local sandboxd env call before the wake of a box with a GitHub grant, plus a vendor refresh only when the token is about to expire (16.4).
Acceptance, on the bench host through the public edge:
Connectors.List output has no token field.state) is refused.cocoonbox status still says hibernated./proc/*/environ, /etc, /run, /home, /workspace and agent.db;pg_dump of the database.No match. A positive control matches.
list_changed).git clone and git push of a private repository in the selection work, with no credential helper and no token in the guest (searched as in 4).git fetch after the wake works.Decided: host custody of every grant, with no Mac-only option, stored in PostgreSQL (16.1, 16.1.1).
Decided:
ask is the client’s own permission prompt (16.5).Decided 2026-10-03, from connect-research.md §5:
Proposed 2026-10-01; in the backlog since 2026-10-01 until its two open questions are decided (§18). It replaces §4.3. Users install capabilities (skills, MCP servers, agent presets, desktop apps) for the box and for their local agents, from one catalog.
What goes where:
| Capability | Box agent (mode A) | Local agents (mode B) |
|---|---|---|
skill (skills/*/SKILL.md), command, agent preset (agents/*.md) |
loaded by the box agent from the box’s user state | installed by the client’s own plugin manager from the same catalog |
| stdio MCP server | runs in the box as user. Its tools join the dispatcher, so the box’s /mcp serves them too |
uses the box-hosted tools through the bridge, or installs the server locally through its own plugin manager |
| remote MCP server that needs OAuth or a key | a connector (§16): the broker is its client, and nothing lands in the box | the same connector, through /cocoonbox.mcp/connect |
| desktop app | installed per user in the box, with Flatpak | – |
A plugin uses the agent-plugins layout that the three clients share (§13.5):
.claude-plugin/plugin.json, plus .codex-plugin/ and the cursor-agent manifest;skills/, commands/, agents/, hooks/;.mcp.json.Existing public plugins install unchanged. What only the box needs goes into one extra manifest, .cocoonbox-plugin/plugin.json, which the clients ignore:
{ "requires": { "connectors": ["github"], "egress": [{"host": "api.example.com", "ports": [443]}], "apps": [{"flatpak": "org.gimp.GIMP"}] } }
A plugin without that manifest counts as requires: {}.
Variables:
variables) are asked for at install and put into the plugin’s env.writeOnly, has format: password, or has TOKEN, KEY, SECRET or PASSWORD in its name.
loomhand/cocoonbox-plugins (open item below).
.claude-plugin/marketplace.json and .agents/plugins/marketplace.json. So claude and codex use it as a marketplace unchanged, and cursor-agent can too once the repository is public..mcp.json;A reviewed entry carries reviewed: true. User-added catalogs come later, and install with “not reviewed” shown.
There are no floating refs: an update is a new pinned entry. There are no signatures in the first cut. The pinned index, fetched over TLS, is the root of trust.
user under the toolhost, never as cbagent or root (§3).--user installs from Flathub, inside Flatpak’s sandbox.* (§14.6), and sandboxd rules come only from the node config. Enforcing egress per plugin needs a per-claim egress policy upstream (a mechanism, §14.7), and waits for it.plugins table in agent.db: plugin, catalog, SHA, variables, enabled, installed_at./home/user/.local/share/cocoonbox/plugins/<name>/<sha>.
flatpak --user into ~/.local/share/flatpak.
claude plugin marketplace add loomhand/cocoonbox-plugins.cocoonbox setup can run that command.load_skill(name), returns the body (the claude CLI’s pattern).agents/*.md: prompt, tool allow-list, model role) are offered in the new-agent sheet and passed as Agents.Create{preset}.user, with the plugin’s env plus the proxy variables and the plugin-root variable expanded. It relays the JSON-RPC over the toolhost socket.tools/list of a turn or an MCP session that needs it;Per-VM memory is density: one Node server takes 50–100 MB.
npx and uvx servers fetch their packages at first start, through the proxy, and cache them in the user’s home. The catalog requires pinned package versions.<plugin>_<tool>. A clash with a built-in tool or another plugin’s tool refuses the install.listChanged: true. It sends notifications/tools/list_changed on install, uninstall, enable and disable. The bridge already refreshes its cache on that notification (§13.2).flatpak uninstall --user;list_changed.cocoonbox.agent.v1.Plugins is served by the agent. The edge routes it like Agents.
service Plugins {
rpc Install(InstallRequest) returns (stream InstallProgress); // catalog URL, plugin, SHA, non-secret variables
rpc Update(InstallRequest) returns (stream InstallProgress);
rpc SetEnabled(SetEnabledRequest) returns (InstalledPlugin);
rpc Uninstall(PluginRef) returns (Empty);
rpc ListInstalled(Empty) returns (ListInstalledResponse);
}
M6.1, the first cut:
Plugins service;load_skill;M6.2:
Split:
load_skill and listChanged;Plugins.cocoonbox.agent.v1.Plugins like Agents. Nothing else changes; remote plugins that need auth go through §16.flatpak and the Flathub remote, and excludes ~/.local/share/flatpak from the state layer.agent/v1/plugins.proto;.cocoonbox-plugin/plugin.json;listChanged on the box MCP.Hot-path cost: none on the claim or screen path.
tools/list starts at most the servers that the turn lists.Acceptance, on the bench host:
list_changed, and runs it in the box.claude and in codex, from the catalog used as a marketplace.Open (deferred to the backlog):
loomhand/cocoonbox-plugins. It must be public for cursor-agent./srv/cb/state) sits on the same GCP local NVMe as the boxes. That disk survives a host restart but not an instance stop or termination, so a lost node takes the state layer with it, and §14.6’s restore-on-missing only covers a lost machine on a live node./srv/cb/sbx/build), the imported templates and the sandboxd goldens live on the same local NVMe as the boxes, so an instance stop leaves no template to claim from. Keep the current and previous template tarball off the node (next to the off-node state store) and document the re-seed: mkfs, deploy/cbsbx/install, cocoon-cbsbx image import, cbsbx-conf.egress_internal_allow [10.203.0.1/32:18090,9000], so the proxy itself refuses any other port on 10.203.0.1; cb-host-net’s CB-OUTPUT stays as the second layer.~/Library/Application Support/CocoonBox/local-agents.json lists each open local agent: kind, box id, the CLI’s session id, workspace, start time, the automatic and the user’s name. The app rewrites it on every change (start, End, rename, a new terminal title), so a killed app loses nothing.claude gets one from the app (--session-id). codex and cursor-agent choose their own; the app claims the earliest session in the shared workspace that began after the agent started and before the next agent of the same kind started (codex: the rollout’s session_meta cwd and timestamp; cursor-agent: ~/.cursor/chats/<md5 of the workspace>/<chat id>/meta.json with a conversation). Two agents of one kind started within the CLI’s start-up time can still swap.claude --resume <sid> (a new --session-id <sid> when no conversation was written), codex resume <sid>, cursor-agent --resume <chat id>; an agent with no conversation yet starts fresh. End removes it from the file.claude and codex title the conversation; cursor-agent shows its chat title); a double-click renames it, and the user’s name wins and is kept across restarts.state_*, browser_close, desktop_save) can invoke it. Since v10 the model’s shell has sudo, so this adds no privilege inside the box, but the dispatcher should still forward only the tools it advertised. Found during the v10 acceptance (2026-10-01).SecAccessControl .userPresence). Until then the prompt stays.GET /admin/keys?owner= before paging and stores and reports keys by ak_id only. The api lists a machine’s keys by owner, lets gw generate each key and revokes by ak_id.Proposed 2026-10-01. Direction: an agent in a box drives only that box, and a local coding agent may drive any of the user’s boxes, told apart on the MCP side.
agent.db. Driving another box would need a credential in box A that opens box B, which breaks boundaries.md invariant 1, and a prompt injection in A would reach B.The local bridge (cocoonbox mcp, §13) already merges the control MCP and one box’s MCP into one stdio server. It becomes the multiplexer:
box argument (a box name or id). It defaults to the bridge’s default box (--box, else the newest running box), so a single-box user sees no change.box_list, box_create and box_delete, and the existing ones take box.tools/call by its box. (Superseded by §23: the owner’s MCP sessions are not tracked, so no box records an mcp:<client> agent; the CLI keeps its own history, 19.6.)tools/list is the box tool contract once, plus box. Every box serves the same tool set; per-box plugins (§17) come later as a union annotated with their box.Not chosen:
cocoonbox-dev, cocoonbox-test): it multiplies the tool list by the number of boxes in the model’s context, and a new box needs a config edit and a client restart.The app’s + → Local agent starts the CLI through the bridge (stdio) instead of the direct HTTP config, with --box set to the box selected in the app.
Boxes.Create{name, image, flavor} and Boxes.Delete{box_id}. Box.name is unique per user. Ensure{box_id} wakes or creates that box; an empty box_id keeps today’s meaning, the user’s first box, for old clients.max_running_boxes, default 1. Waking or creating a box beyond the running limit fails with FailedPrecondition naming the running box; the caller hibernates it first. The api never hibernates a box on its own to make room.state.gc.deleted_retention, 19.6).mcp:<client> agent there. (Superseded: owner sessions are not tracked, §23, and local agents show their boxes themselves, below.)Decided 2026-10-03: an agent is an agent, a box is a box.
Order:
Hot path: none on the screen or edge path; the bridge adds one map lookup per call.
Acceptance, on the bench host:
test next to the existing box. Running a second box at once is refused, and works after hibernating the first.test;uname -n in both boxes with box set;test: its machine and gw key are gone, and its last snapshot is still in the store.box is refused by the edge.Reconciled with §22–§26 before building:
boxes table (users migration 0004: id, owner → users ON DELETE CASCADE, name, created_at, UNIQUE (owner, name)), kept in memory next to the token index, so ownership on the edge path is one map lookup and a scan of at most 16 boxes. The migration gives every existing user its box cb-<12 hex of sha256(user id)>, named main; users.Create inserts the same first box for every new user. Every other box id is cb- and 12 random hex, so a new box never inherits the state prefix of a deleted one. A name is 1 to 32 of a-z 0-9 - _, starting with a letter or digit and never with cb-, so a box argument is never ambiguous between a name and an id.-r (Rebase and restore machines are <box>-r<base36>). The serving machine of a box is still its oldest machine. The identity file, the credential, scheduled saves and the restore lock all follow the machine’s box.users.max_boxes, at most the admin’s cap); AddBox counts under a FOR UPDATE lock on the user’s row, so parallel creates cannot pass it together. Lowering max boxes below the user’s box count is refused. The tenant’s max_claims stays max boxes + 1, which a hibernated box’s claim counts against. The running limit is the user’s own max_running when an admin set one (users migration 0005, nullable, 1 to max boxes; the console admin table sets it, empty returns the user to the default, the user’s page shows it, and PATCH /cb/api/me with it answers 403), else api config max_running_boxes (default 1). Lowering max boxes below the user’s own running limit is refused; lower the limit first. A start (an Ensure that creates or wakes a machine, every Create) is refused with FAILED_PRECONDITION, box <name> (<id>) is running, and 1 may run at a time; hibernate it first, while that many other boxes are starting, running or hibernating. A per-user lock spans the check and the claim. The api never hibernates a box on its own.Ensure{box_id}. An empty id is the user’s first (oldest) box, and adds a box named main when the user has deleted all of them, so old clients keep working. A box without a machine is listed with BOX_STATE_UNSPECIFIED (absent) instead of being left out; Get returns it the same way instead of NOT_FOUND.PHASE_DELETING): a quiesced save when the box is running and ready (a failed save keeps the box), then every machine of the box is removed, then the row, and the box’s live share is revoked in the same transaction, which cuts its sessions (§23). A hibernated or failed box is deleted without a wake, so what stays in the store until the GC purges it is its last save. A delete is refused while another operation runs on the box, and Ensure/Hibernate are refused during it.GW_KEY env of that claim; the key itself stays, even for the user’s last box, since the next box’s machine needs it.mcp:<client> agent is dropped: the bridge routes by box, the box records nothing for the owner, and the CLI keeps its own history.Shares.Create checks the box against the caller’s boxes (no longer BoxID(user)); the partial unique index shares_live_box is per box id and needs no change.<user>/boxes/<box>/ for a retention window (built 2026-10-13): the GC lists boxes from the store, then asks the in-memory box index, so a box listed in the store but not in the table is deleted (a box saves only while its row exists, and no box id is reused). The first pass that finds it writes deleted.json with the time; until state.gc.deleted_retention (default 7 days) has passed since, the pass prunes it by the rule like any box. The first pass after that deletes every object under the prefix under the box’s lock, deleted.json last, before the sweep, so the sweep marks none of its chunks and deletes those no other box of the user references. A dry run counts the purge and writes nothing. Until then a deleted box’s head was kept forever and its chunks stayed marked.box_list, box_create (name, wait) and box_delete (box, confirm); every other tool takes an optional box (name or id) and defaults to the first box. A box that is not the caller’s is refused as You have no box "<box>"; box_delete needs box and confirm: true. box_status gains name.cocoonbox mcp [--box NAME|ID]. A box tool’s box argument is stripped and routes the call; without it the call goes to the default box: --box, else the newest running box when the bridge first needs one (it stays until that box is deleted). The bridge fills the default box into box_status, box_ensure, box_hibernate, box_save and box_upgrade when the call names none, never into box_delete. Names map to ids from box_list, cached and refreshed on a miss or after a create or delete; a cb- id passes as is, so the edge decides (404 for a box id the user does not own). One MCP session per box, opened on first use; a box is woken on its first call unless --no-wake. tools/list is the control tools plus the box tools once, each with box. Hot path: one map lookup per call.--box picks the box of status, ensure, hibernate, save and upgrade (default the first box). COCOONBOX_TOKEN, when set, takes the place of the Keychain item: the app sets it for the agents it starts, which pass it on to the bridge.boxID, now the agent’s default box, and all records reopen once per launch at sign-in, a record whose box is gone with the selected box as its default. + → Local agent now runs the agent CLI with the bridge as its stdio MCP server (cocoonbox mcp --box <id> --endpoint <url>, the CLI from Contents/Helpers/cocoonbox, or cocoonbox-cli next to a development build) instead of the direct HTTP config; the token stays in the child’s environment as before (COCOONBOX_TOKEN), and claude (env), codex (env_vars) and cursor-agent (${env:…}) pass it to the bridge. The MCP server is still named cocoonbox, so the tool names a reopened conversation knows are unchanged. The Connect sheet’s direct recipes stay.~/Library/Application Support/CocoonBox/agents.sock (mode 0600; the test HOME redirects it). It hands the bridge the socket path and the agent’s id the way it hands it the token, as COCOONBOX_APP_SOCKET and COCOONBOX_AGENT in the CLI’s environment, named in the MCP config (claude env, codex env_vars, cursor-agent ${env:…}) and never in argv. The bridge connects at start and sends newline-delimited JSON: {"agent"} once, then {"agent","box","at"} for every call it routes to a box’s MCP (the box id; at in Unix ms). Control tools (box_status, box_list, box_create and the rest) are not reported, so the row shows only real box work. The app answers the first line with the agent’s default box, {"box"}, and sends {"box"} again whenever the user changes it; the bridge uses the latest one for calls with no box, in place of --box. So a bridge that restarts with an old --box still gets the current default. The agent’s terminal pane has a Default box picker; when the box switcher selects another box, the pane says “Screen shows POST /cb/api/boxes, PATCH|DELETE /cb/api/boxes/{id}); the share form picks the box.on <name>), so tests can tell where a call ran.Not built: per-box agents in MockBox, a CLI list command, per-box cursor-agent workspaces (two cursor-agent agents for different boxes share one .cursor/mcp.json, written just before each starts). A rename made elsewhere reaches a running bridge only on its next name miss, create or delete, so until then a box tool may still find the box under its old name.
Acceptance: deploy/accept/multibox.sh on the bench host with a scratch user (19.5 steps 1, 3, 4, 5 scripted, and step 2’s routing checked over the edge by each box’s identity file, since clones share a hostname; the agent CLI session itself stays manual and the script prints it. Step 2’s per-box transcripts no longer apply, since the owner’s sessions are not tracked).
Proposed 2026-10-01. The user has a site’s password in the Mac’s Passwords (iCloud Keychain). The box’s Chrome should sign in with it, and the password should never be written in the VM or seen by any model.
boundaries.md invariant 2 allows exactly this shape: “inside the VM only in memory for one request”. The session cookie that results is the existing exception, browser sessions, encrypted in the state layer.
NSTextField and NSSecureTextField). The user pastes the values from Passwords or 1Password, or types them, and the app holds them in memory only. The fields keep contentType .username and .password, which cost nothing and may get AutoFill once the app has Developer ID signing.Session.FillCredential{origin, username, password, submit}, through the edge with the user token. It is app-only:
origin (scheme, host and port) and fills the page’s sign-in form through CDP (the focused field, else the first username/password pair), then submits if asked.
PasswordManagerEnabled=false (managed policy), so Chrome never offers to save the password. The resulting cookies follow the browser-session rule.ApprovalRequest{kind: "take_over"}), the approval card adds the same button. After the fill, the user answers the approval and the agent continues.navigator.credentials.get through CDP and hands the challenge and rpId to the app;com.apple.developer.web-browser.public-key-credential (Developer ID plus Apple’s approval, what Chrome and Firefox hold). A remote browser is the case that entitlement exists for, but Apple decides. The hybrid QR flow (scan with an iPhone) needs Bluetooth next to the browser, which a cloud VM does not have. Backlog: apply for the entitlement once the Developer account exists, then build the relay..oneTimeCode, where macOS AutoFill offers a verification code saved in Passwords and, when the iPhone forwards SMS, a code from Messages. To measure. The TOTP secret never goes into the box.
autocomplete="one-time-code" or a 6-digit numeric field), and the sheet opens in code mode. Agent change, so it ships with an image cut.NSSecureTextField, .password) brings up “Passwords is locked” (Touch ID), then the system’s “Choose a password” picker over every saved entry, with a search box. Picking one fills the user name and the password.face) into its search.op item list --url <origin>); not built.Work split:
Session.FillCredential; the rule that it is app-only and never logged.PasswordManagerEnabled=false.Hot path: none.
Acceptance, on the dev box:
/proc/*/environ, /home, /workspace, /tmp, agent.db and the Chrome profile;Proposed 2026-10-01. Some boxes should reach the internet through a residential exit, for sites that block or throttle datacenter addresses; the rest keep the node’s datacenter exit. The choice exists only at the sandboxd layer. Inside every box the proxy is still 127.0.0.1, and nothing in the guest says which exit it has.
guest: false claim env entry (EGRESS_UPSTREAM on cbsbx, under egress_upstream.claim_env) or a pool default. A dead upstream fails closed (502), never direct.cbres0, 10.203.1.2:8823). The address is on egress_upstream.allow and never on egress_internal_allow: sandboxd dials an upstream with its plain dialer, and an internal-allow entry would let any box CONNECT to the forwarder itself and bypass the switch.Proxy-Authorization for the provider and relays CONNECT and absolute-URI requests.http://10.203.1.2:8823), so no claim env, no API response and no box ever holds the provider credential.users table (§22); until that lands, every user has it off.PATCH /v1/sandboxes/{id}/env when it provisions a machine (§14.5 Deliver), and the provisioner’s 10 s pass brings every machine whose entry disagrees with its owner’s switch in line, hibernated ones included. A change takes effect for new connections.Proxy-Authorization. SOCKS5 and per-country hosts are unverified.Order:
0.0.0.0 or the host network.Hot path: none on claim, screen or edge. Residential latency applies only to the boxes that chose it.
Acceptance on the bench host, with dev on residential and a throwaway claim on datacenter:
curl https://ifconfig.me inside each box shows a residential address for dev and the node’s address for the other. Both boxes’ proxy env says 127.0.0.1.Built 2026-10-02 (22.4 records what was built and the decisions made that night). Proposed 2026-10-01. gw and sandboxd are both multi-tenant. cocoonbox-api keeps a per-user record that links a user to exactly one gw key and one sandboxd tenant, and it hides both from the user: clients hold only their cocoonbox user token. gw then shows each user’s total model spend, and sandboxd shows each user’s boxes and caps them.
| Today | Proposed | |
|---|---|---|
| User record | users in api.yaml (name and token hash), edited by hand, api restart |
A users table in the api’s PostgreSQL (the instance Connect uses): user id, token hash, gw ak_id, sealed gw key, sandboxd tenant name, sealed tenant token, state |
| gw key | One per machine (owner = machine id), issued on every new machine and revoked on Rebase | One per user (owner = user id), issued once at sign-up and revoked when the user is deleted. Every machine of the user gets the same key in its claim’s guest: false env |
| Model budget | Per key = per machine; the tenant pool is shared | Per key = per user: /admin/usage by key is the user’s total; the user’s day budget is a per-user override in the one tenant (§22.2, gateway#144) |
| sandboxd | One tenant-less root token; boxes found by cb_owner metadata |
One sandboxd tenant per user (name = user id, its own token, max_claims = the user’s box quota, §19.3). The api creates and lists a user’s claims with that user’s tenant token, so a bug in the api cannot list or claim across users. Root-only surfaces (per-id reads, GET /v1/info) keep the root token |
| Onboarding | Hand edit plus restart | An admin call on the api (later the web console) creates the user, issues the gw key and adds the tenant; deleting reverses all three |
The two invariants hold:
Sealed values (as built, 22.4): AES-256-GCM directly under the master key (a 32-byte file on the bench host), additional data users|<id>|<column>. There is no per-user data key, no envelope and no master key rotation path yet; the §16.1.1 envelope replaces this when Connect lands.
config.json and are re-read at boot (sandbox docs/cluster.md), so adding a user means a config edit and a restart, and every restart sweeps the warm pool. Self-serve sign-up needs a root-only API to add, update (token rotation, max_claims) and remove a tenant, persisted by sandboxd and applied without a restart. The cluster form has to keep every node’s tenant set equal. This is mechanism; cocoonbox only calls it.owner is the user; what it lacks is a per-user cap value (at 3ab7a722 key_daily_cost_quota_micros is one tenant-wide number). #144 adds PUT|GET|DELETE /admin/tenants/{tenant}/users/{user}/budget (daily cost, monthly cost, daily tokens; a number, "unlimited", or absent to inherit), callable with the tenant’s own admin token, plus a tenant option require_key_owner. Admission checks every configured scope independently (tenant pool, per key, per user), so the tightest cap wins; an override replaces only the per-user scope, and "unlimited" leaves the key and pool caps in force. Caps are soft: a request is checked before it runs and charged after. Built 2026-10-02: the bench host runs gw db2e82e (#145). gw is authoritative for the per-user default; a tenant token cannot read the tenant config, so api.yaml’s accounts.default_daily_cap_micros only mirrors it for the console and nothing checks that the two agree.
"unlimited" for admins, and an admin console override; require_key_owner: true on cocoonbox-boxes; drop the tenant key cap (today $10/day, which would otherwise stop an unlimited admin at $10); keep the pool (today $50/day) as the backstop that admins share. No tenant per user and no global token.user) would record it.Order:
users table, the admin create/delete calls, the per-user gw key (the current gwkeys client issues by user instead of by machine), and Deliver injecting the user’s key.users list from api.yaml.Hot path: none on claim, screen or edge. Deliver reads one sealed key per provision.
Acceptance on the bench host:
ak_id in gw’s usage), and the user’s max_claims refuses one box more than the quota.Decided and built:
/cb/ on the api’s HTTP listener (the MCP one), reached through a Caddy route. Static HTML and JS embedded in the binary, no build toolchain.
idp column is there for Google later; no Google code yet.sessions. Mutating requests must send application/json, so a cross-site form cannot post one.<user id>/, removes the tenant, revokes the gw key and drops the row.expires_at_epoch_secs: 0); the api’s start does the same for the gw key of every disabled user. Enabling clears the expiry; the boxes wake on the next Ensure.cocoonbox-api user add|passwd|token-sha256, secrets on stdin); and, while an admin has signup on, a person’s first sign-in with GitHub, with a seat cap, a queue and invites (contracts “Sign-in with GitHub and signup”). Signup is off by default.cb_owner), the box id input (cb-<12 hex of sha256(id)>), the gw key owner and the sandboxd tenant name. New users get u-<12 hex>, never reused, since a reused tenant name inherits the old tenant’s claims and templates. The migrated users kept their old name as id, so their box ids and state keys did not change. Their token hashes were imported from api.yaml, so neither app had to sign in again.LoadCredential), with additional data users|<id>|<column>, so a value copied onto another row does not open. There is no envelope and no rotation path yet: rotating the master key means resealing both columns of every row. The 16.1.1 per-user data key envelope replaces it when Connect lands.cocoonbox-boxes), revoked at deletion, installed as the claim’s guest: false GW_KEY on each new machine’s first boot. A machine made before the switch keeps its per-machine key until it is replaced; the api no longer revokes those, so they are revoked by hand.PUT /v1/tenants/{id} with the user’s token, max_claims = max boxes + 1 (the +1 is the old machine a Rebase keeps until the new one serves; the api’s own count is the one box per user that the box id enforces), and egress_class = sandboxd.tenant_egress_class. A tenant without an egress class reaches nothing on a pool with a policy, and sandboxd before cocoonstack/sandbox#292 cannot give an API tenant one; so machines claim under the user’s tenant only while that setting is non-empty, and before #292 they stay root claims stamped with cb_owner. The api reconciles every tenant at start.tenant stamp, which outranks the api’s own metadata; root claims made before the switch keep being found by cb_owner and adopted until replaced, so the switch never created or restored a second machine.cocoonbox-boxes sets user_daily_cost_quota_micros: 5000000 as every user’s default, keeps the $50/day pool, drops the per-key cap and sets require_key_owner. The api gives admins an "unlimited" override at creation and at the startup reconcile when they have none, drops a user’s override at deletion, and lets the admin page set a user’s cap (dollars, unlimited, or inherit); the user page shows the cap and today’s spend (the ledger since UTC midnight). Hot path: none in the api; gw does one in-process override lookup per request with an attributed user.PATCH /cb/api/me with residential answers 403; ApplyExit runs after the row is committed) and max boxes (the admin sets the cap, default 1, at most 16; the user picks up to it).state.scheduled_users is gone with the users list (no back-compat).cocoonbox on the bench host’s PostgreSQL, role cocoonbox-api, peer authentication over the unix socket; migrations are embedded and run at start under pg_advisory_lock.Hot path: none. Token checks read an in-memory index, updated by every write; the root CLI sends NOTIFY cocoonbox_users after its writes, and the api, which LISTENs, reloads the index on each notification and after each (re)connect of its listening connection. Ensure unseals one tenant token (microseconds); provisioning unseals the gw key once per new machine.
§11 says per-user keys and one prefix per user; the code drifted to one key and one prefix per box (keyring.Key(ctx, box) reads /var/lib/cocoonbox-api/keys/<box>.json, and api/boxes/statelayer.go:404-407 keys every object by box). With one box per user the two are the same. With several boxes per user (§19) they are not, so the per-user form was decided:
users.state_key column like the gw key and the tenant token (§22.4 sealing, additional data users|<id>|state_key). The keyring files go away.cocoonbox-state/<user>/chunks/<id> and <user>/index.json (the chunk index);cocoonbox-state/<user>/boxes/<box>/{manifests/, snapshots/, head.json} (each box keeps its own snapshot chain).<user>/index.json. The state layer’s lock moves from per box to per user for the index write (plan, upload and restore of different boxes still run in parallel).<box>/... to the new layout server-side (S3 CopyObject), switch the api, verify a restore of each box from the new layout, then delete the old prefix. Do not run it during an open save.Hot path: none. Save and restore read one sealed key per request instead of one keyring file.
As built (api 185e1bf, b8de45e; switched 2026-10-02):
users.state_key_id (plain) and users.state_key_sealed (AES-256-GCM under the master key, additional data users|<id>|state_key), migration 0002. The key id is stored because the agent ignores its known-chunks.json stamps under another key id and the api ignores an index under another key id; keeping the box key’s id kept both. Create draws a key (id users:<id>:<unix>); a user without a key cannot save or restore (no key is created on first use, so a user that missed the migration fails loudly instead of starting a second key). Deleting the user drops the row and with it the key; the prefix stays, as before.<user>/index.json, so two boxes of one user save in parallel and neither drops the other’s chunks.cocoonbox-api state migrate [-keyring DIR], safe to rerun at any time. Per user it imports <keyring>/<box>.json into the row (a no-op when the row holds that key, an error when it holds another; a user without a file and without a key gets a new one), copies every object of <box>/ missing under the new layout with CopyObject, writes the union of both indexes, and moves the new head only forward (by saved_at_ms). It reads the old head and index before listing, so everything they name is copied. Unknown objects or indexes under different key ids stop it.plan again without a parent). The api then plans once more without a parent, which spools every chunk. The heal keeps no state, so it survives an api restart; it costs one full read of the tree, once.Acceptance on the bench host:
<user>/boxes/<box>/head.json, with the same snapshot id and file count as before.state_key; its snapshots can no longer be opened, and its prefix goes with the retention sweep.Decided 2026-10-02, revised 2026-10-03. A box owner shares one box through a share link. Whoever holds the link watches the screen in a web page and can connect their own TUI coding agents to the box over MCP. Nobody takes over the screen through a share. The same change stops tracking the owner’s own MCP sessions as agents.
https://<endpoint>/cb/s/#<secret>, a 128-bit random secret. The secret sits in the fragment, so it reaches neither the server’s access logs nor a Referer. The page reads it and sends it on its WebSocket. The api looks it up by its SHA-256 and keeps it sealed so the owner can see the link again (23.5).computer_state, file read and list, the browser page snapshot). File read and list reach only /workspace (23.6).shell, so it cannot be confined: the app and the console warn next to the switch that it hands the guest every login in the box.Session.FillCredential, which stays app-only (§20).mcp:<client>@<share> agent. The owner can end a session from the app.mcp:* agent, no transcript, nothing to prune. The CLI keeps its own history, and the app shows its terminal. The pane under a local agent that mirrored its box-side calls goes away, and so does the special case that hid those sessions from External.
shell or file write. A counter, nothing on disk./workspace, and with write on, their agent acts on it. Box egress injects the owner’s gw key. A guest’s own CLI spends the guest’s own model quota.shares table in PostgreSQL (id, box, secret_sha256, allowed user ids or none, write, expires_at, created_at, revoked_at);x-cocoonbox-credential (§12.1) gains the caller, principal = owner | share:<share id>:<read|write>[:<user id>]. The agent trusts only that field to decide tracking and the tool set.tools/list and tools/call by the principal’s mode, tracks agents only for share sessions, and keeps the in-flight count for every session.Hot path: the edge adds one share lookup (in memory, like the token index) per call that does not come from the box’s owner. Owner calls are unchanged.
shell call before it quiesces.Decisions made while building, on top of 23.1 and 23.2:
cbs_ and 32 hex digits (128 bits). The edge looks a bearer up as a user token first, and only when no user holds it and it starts with cbs_, as a share secret. A user token that happens to start with cbs_ (one imported from api.yaml could) stays its user’s. Both are in-memory lookups by SHA-256, so no secret is ever compared directly. The api also keeps the share secret sealed for its owner (23.5); the edge never opens it.shares (users migration 0003): id (sh- and 12 hex), box, owner (references users, ON DELETE CASCADE), secret_sha256 (unique), user_ids text[] (empty for anyone with the link), write, expires_at, created_at, revoked_at. A partial unique index allows one row per box with revoked_at IS NULL. Create revokes an expired row of the box in the same transaction, so a box has one live share. Regenerate rewrites secret_sha256 and keeps the id. Expiry writes nothing; the row only stops being loaded. (Superseded by 23.5: migration 0006 adds secret_sealed, and expiry clears it.)users.Service keeps the live shares in memory next to the token index, loads them at start, and updates them on every write. Each live share has a Cut channel, closed when its streams must end, and a Moded channel, closed when write switches. A timer at expires_at drops the share. Disabling the owner cuts the share’s streams. Deleting the owner drops the share from the index, and the cascade deletes the row.cocoonbox.control.v1.Shares service: Create{box_id, ttl_seconds, user_names, write}, List, SetWrite{share_id, write}, Regenerate{share_id} and Revoke{share_id}. Create and Regenerate return the secret; since 23.5, every Share the owner gets carries it too. ttl_seconds 0 means 24 h; the range is 1 min to 30 days. Users are named by user name, and an unknown name is INVALID_ARGUMENT. The console calls the same server at /cb/api/shares[/{id}[/regenerate]] (GET, POST, PATCH, POST, DELETE). The agent gains Agents.End{agent_id | share_id}.https://<endpoint>/cocoonbox.mcp/<box id> with Authorization: Bearer <share secret>. A named share also needs X-Cocoonbox-Guest-Token: <the guest's own user token>; the app’s snippets read it from ${COCOONBOX_TOKEN}. The edge strips both headers before proxying. It answers:
A share secret is never a gRPC token: gRPC and the control MCP authenticate user tokens only, so a share secret gets UNAUTHENTICATED there, and no share reaches Agents, State, Session.FillCredential or the lifecycle tools.
v2.<b64url(key_id|box_id|wake_id|exp|principal)>.<sig>, with principal = share:<id>:<read|write>[:<user id>]. A v17 agent refuses v2 as malformed (401), so sharing needs a v18 box, and an old box fails closed.PERMISSION_DENIED, and so does every Session method except Screen.tools/list and tools/call follow the mode of each request’s credential. A read share gets screenshot, computer_state, read_file, list_dir, browser_snapshot and browser_read, with files only under /workspace (23.6); a write share gets all 13 remote tools. A refused call gets JSON-RPC -32602.listChanged: true. When a request carries a mode other than the one the session last listed tools in, the session’s GET stream sends notifications/tools/list_changed.Moded). The client reconnects at once with the new mode and gets the notification, so a client that listed read-only tools learns about shell without a restart. POST exchanges are not cut, so a running tool call survives a switch.Cut. The edge cancels every open MCP exchange of the share, and every share page bridge, at once. The api then calls Agents.End{share_id} on the box, best effort: a box that is not running has nothing to close. That call closes the share’s sessions, and their agents go idle. The app’s End button calls Agents.End{agent_id}. A guest whose session the owner ended can open a new one while the share lives; revoke to keep them out.mcp:<client>@<share id>, with :<user id> added for a named share. The owner’s sessions create no agent, no transcript and no events, except ComputerAction for the live-view marker. Their toolhost shell is keyed by the MCP session id and is closed when the session closes. Before this change it was closed only by the 24 h prune of the agent.rt.turns shared, like a loop turn, so a quiesced save or Rebase waits for it, and the call waits out a running save.UNAVAILABLE (“a turn is still running”); the save failed and a Rebase kept the old machine. A shell call may run 600 s, so a guest could have failed every Rebase. Now, past the 30 s wait, the agent interrupts the share sessions’ calls: each returns interrupted: … in its transcript and its shell session is ended, so a sleep 100000 dies. Then the quiesce waits up to 5 s more. The owner’s turns and MCP calls are not interrupted; if one still holds the gate, the quiesce fails as before. Hibernate takes no quiesce (sandboxd snapshots the VM), and scheduled saves are live, so neither waits on a guest./cb/s/ is static: noVNC v1.7.0 core/ and vendor/pako/, unmodified, under console/static/s/novnc/, with LICENSE.txt and the license texts.wss://<host>/cb/s/ws. The api checks that its Origin host is the page’s host. The page sends the secret from the fragment as the first text message; nothing travels in the URL.{"ok":true} or {"error":…, "code":…}, then bridges binary RFB to the box’s RFB server through the sandboxd port relay to 5900, view-only both ways.cb_session, Path=/cb/, SameSite=Strict, sent on the same-origin WebSocket). Without one, the page asks the viewer to sign in at /cb/./cb/s/: default-src 'self'; img-src 'self' data:; connect-src 'self' wss://<host>; frame-ancestors 'none'. data: images are noVNC’s JPEG rects and cursor.core/decoders/zrle.js) and asks for Cursor at 24-bit depth, which is its default.user=share:<id>:<mode>[:<user>]), and the page logs the box, the principal and the client address. Caddy writes no access log (no log directive), and the secret never sits in a path or query.Hot path:
Deploy:
/cb/* already proxies HTTP/1.1, WebSocket upgrades included, to 18201, and /cocoonbox.mcp/* and /cocoonbox.* are unchanged.cocoonbox.control.v1.Shares on 18200; the share page at /cb/s/.Shares; with an older api, the Share sheet reports the UNIMPLEMENTED error of Shares.List.deploy/accept/share.sh on the bench host, with scratch users qa1 (with a v18 box), qa2 and qa3 and their console passwords in QA1_PASSWORD, QA2_PASSWORD and QA3_PASSWORD. It issues the three users fresh API tokens and never prints a secret. The browser view, the agent CLI and the app’s External list stay manual steps; the script prints them.Decided 2026-10-03. Before this, the api kept only the SHA-256 of a share secret and the app kept the secret in memory. After an app restart, the Share sheet found the live share but could not show its link. The owner’s only way to get a link was Regenerate, which ended the link that guests already used. Now the api keeps the secret sealed and returns it to the box’s owner. Nothing goes in the Mac keychain.
shares.secret_sealed (bytea, nullable). The secret is sealed like the users’ keys (§22.5): AES-256-GCM under the api’s master key, with additional data shares|<share id>|<box id>|secret, so a sealed secret copied to another row does not open. No new key or config. secret_sha256 stays, and the edge looks a bearer up by it as before; the edge never opens the sealed column.revoked_at. Expiry sets it to NULL when the share’s timer fires. When the api starts, it sets it to NULL on every revoked or expired row, which covers a share that expired while no api ran.Share.secret (field 7) carries the secret in every Share that Shares returns: Create, List, SetWrite and Regenerate. Shares serves only the caller’s own shares and authenticates user tokens only, so a guest, and a share secret used as a bearer, never gets it. The console’s owner endpoints (/cb/api/shares) return it as secret, from the signed-in user’s session. The link is <endpoint>/cb/s/#<secret>, and the MCP snippets need the endpoint, the box id, the secret and whether the share names users; the app and the console build them from List. No log line carries a secret: the api logs share ids, and an open failure logs the additional data, which holds ids only.List returns it with an empty secret, the app shows “Regenerate to get a link”, and the console shows no Show link button. Its link keeps working until it ends; the api does not try to recover it.List returns the secret, as it does right after Create. The in-memory secret is gone; AppModel.shareSecret reads share.secret. Regenerate stays and says that it replaces the link and ends the old link’s sessions.Hot path: none. The edge lookup is unchanged; List reads the secret from the in-memory index, opened once when the api loads the shares.
Deploy: migration 0006 runs at start. The api before this change (7a3c012) runs on a database with 0006 applied: it names its columns and ignores secret_sealed. Rolling back leaves sealed secrets that the old api does not update on Regenerate, so the rollback sets secret_sealed to NULL on every row; those shares then show “Regenerate to get a link” after the next deploy.
Decided 2026-10-03. A read share’s read_file and list_dir reached every file the desktop user can read: the Chrome profile (cookies, Login Data) and the box agent CLIs’ logins (~/.claude, ~/.codex and the third CLI’s config directory). A link holder could take the owner’s web sessions and agent subscriptions, far more than seeing the screen.
/workspace, with .. and symlinks resolved when the file is opened. Every other path fails the call with <path> is refused: a read-only share reads files only under /workspace. A symlink in /workspace that leads out is refused, and so is any absolute symlink, even one that leads back in.screenshot, computer_state and the browser page tools work as before. The owner and a write share read any path, as before.shell, so it cannot be confined. The Share sheet and the console say next to “Allow write” that it hands guests every login in the box: browser sessions and agent CLI logins.workspace_only: true in the call’s arguments, over any value the guest sent; the transcript shows it. The toolhost then opens the path with openat2(RESOLVE_BENEATH) relative to a descriptor of the workspace. The kernel resolves every component once, at open, and fails an escape with EXDEV, which becomes the refusal. The open descriptor is what is read, and a directory is listed through /proc/self/fd/<fd>, so a path swapped after the open changes nothing. A kernel without openat2 (before 5.6) fails the call; the box runs the image’s Ubuntu 24.04 kernel (§14).tools::READ_ONLY):
read_file, list_dir: confined as above.screenshot: pixels only.computer_state: the screenshot, the titles of the windows on screen, and the accessibility tree of one window, the frontmost or any pid the guest names. A title can carry a file path, and a covered window’s tree can carry text the screen does not show, such as an editor’s whole document. It reads only what the owner has open, and it opens no file. Kept by this decision.browser_snapshot, browser_read: they read only the Chrome window that the same session opened while it had write. A read share opens no window and starts no Chrome, so it cannot close another agent’s window past the window cap; without such a window the call fails with a read-only share opens no Chrome window: …. A read share cannot navigate, click or type, so it cannot load a file:// page. Its window shows a page only when the session had write before (with shell already) or the owner navigated that window by hand. No file:// block is added./workspace to a file elsewhere is read as a file in /workspace; only the owner or a write session can make one.Hot path: owner and write calls add one match on the principal. A read share’s file call adds one open of /workspace.
Deploy: ships in the agent of cbdesk:v20; the app and the console wording ship with their next build.
Decided 2026-10-02. An upgrade today saves the user’s files to the store and restores them into a new machine. That is fast now for the save, but the restore moves every byte: a box of 96,949 chunks (9.3 GB) restores in about 87 s. Instead, the user’s data lives on a persistent data disk that the box carries from machine to machine; an upgrade replaces only the system disk.
cbdesk:vN), replaced by every upgrade. Packages installed with apt still come back through the package replay (§ v10)./dev/vdb, one per box): /home/user (Chrome profile, dotfiles, agent.db) and /workspace. Encrypted in the guest with dm-crypt; the key is the user’s state key (§22.5), delivered by the api at every boot and wake and kept in memory only.vm disk attach/detach is enough, and sandboxd detaches the volume (after the guest unmounts it) before a hibernate or snapshot and re-attaches and re-mounts it after the wake (cocoonstack/cocoon#280 closed as control-plane logic). The state layer stays as the off-node backup and for node loss and moves; it saves from the mounted disk, unchanged. A box is bound to its disk’s node./home: Chrome profiles, SQLite and git need locking, fsync, atomic rename and mmap that FUSE-on-S3 does not give. At this phase the state layer becomes snapshots of the volume.Hot path: none on the screen or edge path. Wake gains one disk attach and mount; upgrade loses the restore.
Decided 2026-10-03. The user picks a point in time and gets back the files they had then. The state layer (§11) already keeps every save, so the history is in the store; what is missing is retention, garbage collection and a way to restore part of a snapshot into the running box.
api/store/store.go), nothing in api/boxes removes a record, a manifest or a chunk, and the bucket has no lifecycle rule and no versioning. The scheduler saves every running box every 15 min, whether or not anything changed (saveStale in api/boxes/save.go).mcli on the bench host:
History therefore costs 1.1 GiB of chunks and 1.3 GiB of metadata. Each record repeats the whole chunk list (5.3 MiB at this size), and each manifest the whole file list (3.2 MiB), even for a save with no change. 11 stored chunks are referenced by no record (commits that failed after Upload).
parent from the head reaches all 158 records and ends at the one without a parent; no record has two children. Each manifest is complete (every file with its chunks), so a snapshot restores without its parents, and dropping one from the middle breaks no other.head.json, restart the api, then Ensure (bench.md). stateLayer.restore takes a snapshot id, but every caller passes the head.agent/src/state/mod.rs:268-347).~/Restored/<timestamp>/<path> in the running box. The machine is not replaced (25.4).Metadata:
timeline.json per box (<user>/boxes/<box>/timeline.json): one small entry per kept snapshot (id, saved and taken times, consistency, files, logical bytes, changed files, new chunks, uploaded bytes). Listing the timeline from the records instead would read 840 MiB for that box.Both steps below run in one background pass in the api, per user: prune every box of the user, then sweep the user’s chunks. The pass runs once at start and then every state.gc.interval.
Prune (per box, under the box’s lock):
A crash between 2 and 3 leaves orphan records; the next prune finds them by listing snapshots/. Every reader (restore, browse, the mark) takes the kept set from the timeline, never from the listing.
Sweep (per user, after the prune):
<user>/chunks/.DeleteObjects, state.gc.delete_batch per call, at most 1000), then release.
Why it is safe against the agent’s stamp reuse: the agent’s known chunks are those of its last upload, which is the box’s head, and the head is always kept. If they are ever ahead of the index, the existing heal applies (Upload refused, plan again without a parent, §22.5).
Cost: the mark reads every kept record, about 5 MiB each at that box’s size, so it runs once a day and never under the lock. The lock covers the new records, one index write and the deletes; that user’s saves wait for it. The locks are in-process, like today’s save locks; several api replicas need them in PostgreSQL first.
The store interface gains Delete (S3 DeleteObjects), and List, which the migration already used, moves into it and returns each object’s size.
As built (2026-10-03):
RWMutex: saves take it shared after their box lock (lock order: box, user, index), the sweep takes it exclusively and takes no box lock. Restores take only the box lock: they read a kept snapshot, whose chunks the sweep never deletes, and a prune of the same box waits for them.dry_run, the allowlist) then covers every delete, and a save pays nothing for it. Between passes a box keeps at most one interval of extra snapshots.parent; pruning leaves it pointing at a removed record, and nothing follows it. The api plans each save on the head, which is always kept. The agent reads parent only to decide whether its stamps apply (agent/src/state/mod.rs:573) and never looks it up; rec.Parent is written and never read; the manifest’s sealed parent is informational. If the sweep removes a chunk the agent still counts as known (only after a commit that failed), the 185e1bf heal plans again without a parent (test TestASaveAfterPruningPlansOnTheHead).timeline.json reads every record of the box before taking the box lock, then reads under the lock only the records committed meanwhile. A dry run builds it in memory and does not write it.state.gc.interval (absent or 0 turns the pass off), state.gc.dry_run (same reads and locks, no write and no delete), every user in the users table but those whose policy sets -no-gc (cocoonbox-api user set), state.gc.delete_batch (default 1000), state.gc.deleted_retention (default 168h, 19.6).state prune command (a pass outside the api would not hold its locks; the pass at start with dry_run serves the rollout). changed_files, ListSnapshots, BrowseSnapshot and RestorePaths were built on 2026-10-03 (25.4).ListSnapshots). The user picks a snapshot and browses its tree (BrowseSnapshot, one directory per call). A hibernated box is woken first.Restore with a scope: the paths, and a target folder name.user into ~/Restored/.cb-restore-<id>/ and renames that to ~/Restored/<timestamp>/ at the end, so a failed restore leaves only a leftover that snapshots already exclude.
<timestamp> is the snapshot’s save time; a second restore from the same snapshot gets a -2 suffix.<path> is the box path without its leading slash, for example ~/Restored/2026-10-02T1608Z/workspace/app/main.go.What a scoped restore does not do: quiesce, stop the desktop, close Chrome, touch agent.db, or write outside ~/Restored. A path must lie under one of the agent’s roots (/home/user, /workspace) and contain no .., and the target is one folder name. Anything else is refused.
The next save uploads no chunk for the restored files: their chunk ids are keyed hashes under the same user key, so they are already stored.
The agent keeps the decrypted manifest of the last Browse in memory for 10 min, so browsing a tree does not fetch it once per directory.
Decided and built 2026-10-03. Where it differs from the plan above:
api/boxes/manifest.go: AES-256-GCM bound to the id, zstd, keyed hash equal to the id, the agent’s format) and keeps one directory index per box for 10 min, without chunk lists. Browsing therefore works on a hibernated box, and there is no State.Browse.RestorePaths it selects the manifest’s entries under the paths, checks that the record lists every chunk they name, and presigns only those, in pages of 2048 (boxes.stateLayer.restorePaths logs the counts). No TransferProgress.needed round trip.State.RestorePaths, carries the scope (RestoreRequest.scope). An agent without it answers UNIMPLEMENTED, so an old agent never sees a scope it would ignore and run as a full restore; the operation then fails with “this box’s agent cannot restore files; upgrade the box first”. The agent ships with cbdesk:v20.PHASE_RESTORING_FILES, so the edge still serves the box) pins the snapshot from its start to its end. The box lock is held only for the check that the timeline keeps it, which orders the check after a prune already running; a prune that starts later sees the pin. A save of the box waits for the operation, as for any other operation.agent/src/state/mod.rs restore_paths, select, scope_path; toolhost state_place): the target is one folder name that does not start with .; each path is absolute, under a root, without ., .. or empty parts, and must name something in the manifest, else INVALID_ARGUMENT before anything is written. Entries are staged as user in ~/Restored/.cb-restore-<uuid>/<box path without its leading slash>, with modes and mtimes; directory modes are set last, deepest first. state_place then creates ~/Restored/<target> (or -2, -3, … up to 100) with mkdir, which fails when the name exists, and renames the staging directory onto that empty directory, so it never replaces an entry. The final message carries TransferProgress.target. No quiesce, no desktop or Chrome restart, no agent.db write, no recover.PlanResponse.changed_files): files whose chunk list differs from the last upload’s for the same path, plus new and removed paths. Unchanged stamps count as unchanged without a read. Files read through an SQLite backup or changed within 1 s of the walk are now recorded in known-chunks.json with compare_only, so they are compared but still never reused. Absent when the agent has no stamps for the key (the first plan on a machine, a plan without a parent); the timeline then shows only bytes added.ListSnapshots reads timeline.json (built by the GC; a box without it is listed from its records). changed_files is a new optional timeline field; an older api drops it from entries it rewrites.Hot path: none on claim, screen or edge. A save pays one map entry per re-read file for changed_files. Browse and RestorePaths are their own calls.
timeline.json in the commit; the prune and sweep pass (built, 25.3); Delete in the store; ListSnapshots, BrowseSnapshot and RestorePaths on Boxes; a cocoonbox-api state prune [-user ID] [-now T] [-dry-run] command for acceptance and for operators.PlanResponse.changed_files; State.Browse; the scoped Restore.Sketch; the fields are the contract to refine (as built: no State.Browse and no TransferProgress.needed; State.RestorePaths takes the scope; TransferProgress.target = 7; EntryKind values ENTRY_KIND_FILE|DIR|SYMLINK; BrowseSnapshotResponse.truncated past 5000 entries; see 25.4):
service Boxes { // clients, through the edge
rpc ListSnapshots(BoxRef) returns (ListSnapshotsResponse); // kept snapshots, newest first
rpc BrowseSnapshot(BrowseSnapshotRequest) returns (BrowseSnapshotResponse);
rpc RestorePaths(RestorePathsRequest) returns (Operation);
}
message Snapshot { string id = 1; int64 saved_at_ms = 2; bool quiesced = 3; uint64 files = 4;
uint64 logical_bytes = 5; optional uint64 changed_files = 6; uint64 added_bytes = 7; }
message BrowseSnapshotRequest { string box_id = 1; string snapshot_id = 2; string dir = 3; } // a box path; "" lists the roots
message BrowseSnapshotResponse { repeated SnapshotEntry entries = 1; }
message SnapshotEntry { string path = 1; EntryKind kind = 2; uint64 size = 3; int64 mtime_ms = 4; }
message RestorePathsRequest { string box_id = 1; string snapshot_id = 2; repeated string paths = 3; }
service State { // api only
rpc Browse(BrowseRequest) returns (BrowseResponse);
}
message BrowseRequest { StateKey key = 1; PresignedGet manifest = 2; string dir = 3; }
message RestoreScope { repeated string paths = 1; string target = 2; } // target: one folder name under ~/Restored
// RestoreRequest.scope = 4: unset restores the whole state, as today.
// TransferProgress.needed = 7 (repeated string): a scoped restore's chunk ids, in pages, before any data.
// PlanResponse.changed_files = 6.
On a scratch user’s box, never a real user’s:
ListSnapshots lists five, each with 1 changed file and added bytes near that file’s size.RestorePaths of /workspace/t/file-2 from the second snapshot writes ~/Restored/<timestamp>/workspace/t/file-2 with the sha256 written before that save.
~/Restored./home/user/Documents works the same way. A path under /etc, a path with .. and a target with / are refused.state prune -now <40 days ahead> keeps exactly the rule’s set. The sweep deletes exactly the chunks referenced only by dropped snapshots (checked against a mark computed offline from the records). A file restore from every kept snapshot still works.state.gc.deleted_retention after the GC finds it (19.6).index.json is still rewritten by every save that adds a chunk.Hot path: none on the claim, screen or edge path. A commit writes one more small object (timeline.json). Prune and the sweep run outside every request; while the sweep holds a user’s lock, that user’s saves and restores wait. The new RPCs are their own calls.
Decided and built 2026-10-03. Each record repeated the full chunk list (5.3 MiB on the box of 25.1) and each manifest the full file list (3.2 MiB), so a save wrote about 8.5 MiB of metadata and every kept snapshot stored it again.
cocoonbox-api user set NAME -delta) writes its record against a base: the newest full snapshot of the box saved on the same UTC day. The record keeps its timeline fields and parent, names the base in base, and its chunks is {"added": [...], "removed": [...]} instead of the list; an api without delta records fails to decode that field instead of reading an empty list. A full record is written for the first save of a UTC day, for a change (chunks added plus removed) larger than 1/4 of the base’s chunks, which keeps a delta under about half a full record, and for a box without a timeline; it becomes the next base. The api keeps the base record in memory per box, so a save reads nothing more.manifests/<id>.d: the base’s file records as copy runs plus the records the base lacks, verbatim, sealed under the user key with <id>.d as associated data. It deletes the full manifest only after the delta rebuilds it byte for byte. A reader rebuilds the exact plaintext and checks its keyed hash against the snapshot id, so integrity is as before. A restore (Rebase, a missing machine, RestorePaths) first writes the full manifest back (materialize); the next pass compacts it again. The GC therefore now reads the user’s state key, in memory for the pass.COCOONBOX_MEASURE=1 go test -run TestMetadataBytesPerSave ./boxes, 100,000 files with one chunk each, 40 changed per save; the fake’s chunk sizes make a full record 8.9 MB here against 5.3 MiB on the box of 25.1). Written per save: record 8,900,327 B full against 579 and 735 B as deltas; the manifest (3.9 MB) and index.json (6.7 MB) are unchanged at the save. After one GC pass the snapshot before the head keeps 735 B of record and a 2,100 B manifest delta instead of 8.9 + 3.9 MB. Commit CPU in process with 104,000 chunks: 22 ms for the delta with the base in memory against 19.5 ms for marshalling the full record, and the store write falls from 9.2 MB to 6.4 KB.index.json (6.7 MB at 100,000 chunks) whenever the save adds a chunk, which a live save nearly always does. The index is now the largest metadata write; an append-only index compacted by the sweep is the next step.cocoonbox-api state expand [-user ID], which rewrites every delta record and manifest delta as full objects and clears the bases in the timeline, then install the older binary. Checked with the ac0de79 code on layouts written by this code: on the expanded layout it restores the head, saves, and runs a GC pass that deletes exactly the garbage chunks; on an unexpanded one its restore and its sweep fail on the delta record before any chunk delete, but its prune deletes the dropped base, which is why expand comes first.Hot path: none on claim, screen or edge. A delta save does less store I/O than before and a few ms more CPU; a restore of a compacted snapshot rebuilds and writes one manifest first. Compaction runs in the GC pass.
Decided 2026-10-03. Several agents (mode-A loops and MCP sessions) run in one box at once, and today they all drive one desktop. A user saw two agents typing into each other’s Chrome tab. Stage 1 gives each agent its own Chrome window on :0. Stage 2, private displays per agent, is designed here and built when several agents need non-browser GUI apps at the same time.
browser_* call attaches to first_page(targets), the first tab of the one Chrome (agent/src/toolhost/browser.rs:369-399, :637). Two agents’ browser_type calls land in the same tab.Browser.run takes one lock for every browser call (browser.rs:227), and the cua client takes one input mutex for every action (agent/src/toolhost/cua.rs:29, :135, :197, :253). Calls never interleave, but they take turns on the same tab and the same focus.:0 (image/rootfs/etc/systemd/system/cocoonbox-xorg.service:8), one VNC port (cocoonbox-xorg.conf:34), one cua-driver, one Chrome, one CDP port. computer_act on Chrome picks the window by pid, and a window has one active tab.:0 Chrome (Target.createTarget with newWindow), keyed by the agent_id that every toolhost call already carries (agent/src/toolhost/rpc.rs:19-24). browser_* calls attach to that window’s target, never to first_page.Hot path: one map lookup per browser call; no new process, no memory beyond the window’s tab.
:0 policy:0 is the user’s desktop.:0, each agent in its own window (26.2).computer_act on :0 works as today. Every agent may use it; the global input mutex keeps one action from interleaving with another, and agents share the focus.computer_act) on :0 runs only for an agent the user has handed :0 to in the app, one agent at a time. The user takes it back in the app. Without the hand-over, computer_act on :0 is refused with a message that names the hand-over; screenshot and computer_state only read and stay open. The other agents act on their own displays (26.4).:0.Display N (N ≥ 1):
cocoonbox-xorg@N.service: Xorg :N -seat seatN -novtswitch with a config per display: the same dummy 1280x800 and the TigerVNC module with rfbport 5900+N (localhost, no auth, as on :0). Its own MIT cookie, written like xauth-init.
-seat seatN is required. Xorg then takes no VT and ignores input devices without a seat tag. cua-driver’s uinput devices have none, so they reach only :0. A second Xorg on seat0 would take every cua-driver device, and an agent typing on :0 would type there too.xfwm4@N (--sm-client-disable --compositor=off). No XFCE session: one org.xfce.SessionManager per session bus.cua-driver@N with its own socket, foreground delivery only. Its background route (an XI2 master pair over uinput) cannot reach a seat≠0 server; foreground is the right route anyway, since the agent owns the focus there.image/rootfs/etc/polkit-1/rules.d/60-cocoonbox-cbagent.rules) widens to let cbagent start and stop cocoonbox-xorg@*, xfwm4@* and cua-driver@*.Allocation and release:
ag-…, and the MCP session id for MCP calls (agent/src/mcp.rs:177-196), which stays the key once owner sessions are no longer tracked as agents (§23.1).computer_* call, not when the agent is created: agents that never use the desktop cost nothing.mcp.rs:219-235), when the agent is deleted (agent/src/runner.rs:206-240), and after 30 min with no computer_* call and no viewer attached. Release stops the display’s units; what ran on it is gone.serve owns the agent-to-display table and passes the display on each toolhost call. The toolhost keeps a cua client, an input mutex and the shells’ DISPLAY per display.agent/src/dispatch.rs:132-137); no tool takes a display argument. :0 is reached only through the hand-over (26.3).Caps and fallback:
api/machines/sandboxd/state.go:34), and no new display while MemAvailable is below 1.5 GiB.computer_* fails with “no private display free” and the agents holding one. It never falls back to :0, which would bring the collision back. The app can end another agent’s display.App view and take-over:
ScreenOpen (proto/cocoonbox/agent/v1/session.proto:119) gains display; serve dials 127.0.0.1:5900+display. The edge forwards frames as it does today.:0 and each held display. ComputerAction markers are drawn only on their agent’s display.computer_act on N returns “the user has control” (the take-over holds N’s input mutex). Clipboard is per X server and per RFB connection already.What changes, file by file:
cocoonbox-xorg.service:8 and cocoonbox-xorg.conf:34: :0 and 5900 fixed;xauth-init: a :0 cookie only;cua-driver.service (user): DISPLAY=:0, one socket;cocoonbox-toolhost.service:13-15: DISPLAY=:0 for every child;cocoonbox-session.service only.agent/src/config.rs:12,83: one vnc_addr, used at agent/src/server.rs:257 and agent/src/screen.rs:35;session.proto:119: an empty ScreenOpen;server.rs:35,197: one screen size in Hello;runner.rs:37: one screen mutex for delegated computer tasks;dispatch.rs:132-137: injects the cua session label and would inject the display.toolhost/mod.rs:38-39,50-55: one Browser and one Cua;config.rs:116-123: one profile, CDP 9222, one cua socket;cua.rs:29: one input mutex;cua.rs:331: display_id: "primary";toolhost/shell.rs:31-37: no DISPLAY per shell.macos/Sources/CocoonBoxKit/ScreenSession.swift:26: an empty ScreenOpen();macos/Sources/CocoonBox/AppModel.swift:67: one ScreenController;ScreenController.swift:28-29: one takeOver;CocoonBoxApp.swift:41: one Live Screen window.config.rs:17, a fixed desktop unit for Restore. Private displays are not restored; their agents get a new one on the next computer_* call.Prototype (dev’s box, 2026-10-03, nothing installed). The image already has Xorg, the dummy driver and the TigerVNC module.
:1 (-seat seat1, VNC 5901): Xorg in about 200 ms, xfwm4, then Chrome on its own profile with CDP 9223, answering in about 1.2 s. RFB on 5901: framebuffer=1280x800. No VT taken; the active VT stayed tty1.:1 and three Shift taps from a kernel uinput keyboard: :0 RawKeyPress=3 (all from the uinput keyboard), :1 RawKeyPress=6 (XTEST only); the :1 page read hello1; the uinput keyboard was listed on :0 and absent on :1.:1: its list_windows showed only the :1 Chrome and the :0 driver’s only :0 windows. Foreground typing reached the :1 page (hello1fg3) with :0 RawKeyPress=0. Background typing failed: timed out waiting for X input slave device 'CUA v1… uinput pointer'.:0 Chrome with three tabs: 420–432 MB).Acceptance on the bench host (dev’s box, never a real user’s):
computer_act. Each gets its own display (:1, :2); XI2 listeners on :0, :1 and :2 count each session’s keys only on its own display.:1 refuses that agent’s next computer_act and leaves the agent on :2 running.computer_* gets “no private display free” with the holders listed; nothing runs on :0.computer_act on :0 is refused; after it, the chosen agent acts on :0 and a second agent is still refused.Hot path: one map lookup per tool call in serve and in the toolhost; nothing per frame, the screen relay dials another port. Allocation runs once, at an agent’s first computer_* call (about 1 s: Xorg 0.2 s, cua-driver 0.75 s). Off the hot path, each held display keeps about 90 MiB resident, which also grows the hibernate image.
--user-data-dir, from another display, exits 0 in 190 ms (“Opening in existing browser session”), opens its tab inside the first browser on the first browser’s display, and never opens its CDP port. The main profile holds the same lock (SingletonLock → hostname and pid).:0’s live logins. Browser work stays on :0 (26.2, 26.3).v10 (39 of 39, 12 hosts): --password-store=basic, a fixed key, so a copy decrypts in any Chrome. A copy would be a full credential copy that then diverges, and the state layer would save it.9222+N), and starts signed out.Decided 2026-10-03. The contract is contracts.md “Offload”.
The user is about to leave the Mac. The task a local coding agent session works on moves into the user’s box, where the same kind of CLI, signed in inside the box, keeps working; on return the box’s commits and hand-over come home, and the local session continues. This is mode C, beside mode A (the in-box loop) and mode B (a local CLI over MCP).
handover.md from a fixed template; the box CLI starts a fresh session with it as its first prompt. No transcript moves and no session resumes across machines, so CLI versions, accounts and path rewriting do not matter, and the box agent may be another vendor (--kind). The local transcript stays on the Mac, and the box’s final hand-over is the local session’s next message./home/user, saved by the state layer under the user’s key, like browser sessions (boundaries.md invariant 2 gains this exception when offload ships).cocoonbox offload / back on the Mac and cocoonbox-agent offload in the box do the preflight, the snapshot, the transfer, the delivery and the merge, and enforce the template’s headings and the preflight’s blocks.cocoonbox rsh (§13.7). Offload is three Execs: begin (box checks, the guard, the directory), rsync of the work tree and .git without objects/, and send, whose stream carries the hand-over, env.json, the history as a bundle --not the box copy’s refs, and opted-in secrets. Back is a wait on the outbox and a git fetch over rsh, then a merge against the recorded base S.cocoonbox-agent offload serve, a user unit) owns the box CLI’s tmux session: it starts it, pastes or queues messages, follows the CLI’s hooks, keeps the outbox (progress, question, back, and blocked when a usage limit or API error stopped the agent), and restarts a lost session from the newest progress hand-over. The agent turns its changes into offload events on Session.Events, so the app notifies without polling.| # | Decision |
|---|---|
| D3 | The box CLI runs in bypass-permissions mode by default (--dangerously-skip-permissions, --dangerously-bypass-approvals-and-sandbox); the offload sheet says so |
| D5, D14 | The work tree minus git-ignored files, and .git without objects/, by rsync; history by bundle; ignored files are rebuilt in the box |
| D6 | Merge back: fast-forward, else a clean three-way merge applied uncommitted, else a branch |
| D7 | Share guests: covered by §23.6 (a read share reads only /workspace); a write share has shell, and the UI warns next to “Allow write”; box_agent_* tools, if they ship, are owner only |
| D9 | The app shows the box CLI through Session.Exec with a PTY running tmux attach, in a Ghostty pane |
| D12 | One box CLI session per (repo, kind), named by both; at most one runs per repo (the guard) and 3 per box |
| D13 | Preflight outcomes as in contracts.md: blocks for a box older than v22, missing box tools, a signed-out box CLI, unreturned work, a full box, a non-git directory and macOS-only commands; warnings for local services, VPN-only hosts, secrets, LFS and submodules |
| D15 | No push from the box until M5.2; commits come home with back |
| D18 | The copy’s git-ignored directories are left out of state saves; the box packer lists them at each offload |
| D20 | Hibernate and upgrade stay allowed; the app and the CLI warn first while a box agent works |
| D21, D22 | The box CLI’s own login; the hand-over as a compaction |
| D23 | The inbox reached through rsh is the agent-to-agent channel; MCP box_agent_send / box_agent_read wait until after v1 |
| D24 | Opted-in secrets live on tmpfs and never reach the state store; a node-local hibernate image carries them, accepted like the box login, and end wipes them |
| D25 | --kind may pick another vendor for the box; the default is the local kind |
| D26 | The image carries no box CLI. When the preflight finds the CLI missing, it offers “Install in box”, which runs the vendor’s official installer over rsh into /home/user, so the install persists through the state layer; the user then signs in and updates it like any program |
git gc on the Mac made rsync resend 91% of a repo; hence history by bundle.build-essential in v21, and exact Mac versions through mise.blocked entry.xterm-kitty and xterm-ghostty; v21 uploads the terminal’s terminfo with Exec.Decided 2026-10-03. The app’s own local agents (the agent CLIs it launches) drive boxes over MCP; their work target is already the box, so their pane has no “Move to box” or “Bring back”. Offload is for the user’s own terminal sessions: a claude or codex TUI in any repo.
claude: /cocoonbox:offload and /cocoonbox:back from the cocoonbox plugin. The skill runs the CLI at the path the plugin was generated with (~/.local/bin/cocoonbox when it links to this binary, else the app’s Contents/Helpers/cocoonbox) in the user’s working directory, and assumes no app session. One-time install: cocoonbox setup claude, or the app’s File menu plugin install item (also in the Offloaded section’s menu), which runs it with the login shell’s environment and shows its output. It is idempotent and also updates the plugin.codex and others: cocoonbox offload --handover-prompt [--kind K] prints the request to paste: write the hand-over to this repo’s hand-over path with the template, then run cocoonbox offload --kind K (both outside the CLI’s sandbox, so it asks for approval), and report blocks and warnings. cocoonbox offload and cocoonbox back also work by hand./cocoonbox:back or cocoonbox back), and the app has no Bring back. A row stays until back or end removes its record, as the CLI keeps records; the pane shows one line of help, “Bring this back from your terminal: /cocoonbox:back (or cocoonbox back) in Hot path: none. The edge and the api do not change; the inbox is idle between messages; the event is one bus publish per outbox entry or status change. The Offloaded section asks each running box with a record for offload status (one Exec) when the app connects, comes to the front or selects a row, never on a timer.
Order: O2 agent and image (the box packer, the inbox and its unit, the events, the tmpfs mount, the state excludes), O3 macOS (the packer, the skill, the app), O4 end to end on the bench.
Decided 2026-10-09. A flow saved in one cbcore box replays in every cbcore box of the same user. The user can export flows to a file and import them again, so a client app can carry a working flow to another box, and later to another computer. Today a flow lives only in the box that saved it: gui_plan writes /workspace/flows/<name>.json and agents write scripts as /workspace/flows/<name>.mts.
box (cbcore) api Mac
toolhost: /workspace/flows ◄──► serve: Syncer ──box door──► flows.Library ◄── cocoonbox.control.v1.Flows ◄── cocoonbox flows
(files, INDEX) agent.db flow_sync (state store, <uid>/flows/) (user token)
<uid>/flows/. It holds an index and one object per flow, each sealed with AES-256-GCM under HKDF-SHA256(user state key, "cocoonbox flows"). The api reads and writes them; boxes never get store URLs for them. Deleting a user removes them with the rest of <uid>/, and the state GC sweeps only boxes/ and chunks/.connect.box_listen), which already names the machine and its owner by the door key that the egress proxy injects. The door gains /flows beside Connect’s MCP, so no pool rule, key or port changes. The agent finds it at CONNECT_URL with the path /flows.cocoonbox.control.v1.Flows, for the owner’s user token. A share secret is no gRPC token at the edge.serve owns the sync (network and agent.db). The toolhost owns the files: flows_scan, flows_read, flows_write, flows_remove, flows_mark and flows_index. These are toolhost calls, not tools. Only a cbcore box with CONNECT_URL syncs; a cbdesk box runs none of it.gui_plan {flows: true} and the guide resource’s “Saved flows” section already list /workspace/flows, which now holds the library. The cbdesk surface stays byte-identical.flow_sync(name, rev, sha256) in agent.db records, per flow, the rev and content hash that the box and the library last agreed on. agent.db and /workspace are saved and restored together, so after a restore or a Rebase the rows still describe the files on disk.gui_plan with flow, flows or find, before gui_flows (the guide resource), before script_run of a script under flows/, and once when serve starts. It gets the listing and, for each library flow newer than the box’s row:
For a flow the library dropped, it removes the box’s unchanged copy and forgets the row. When a .json file changed, it rebuilds INDEX with gui_plan {reindex: true}, which reuses the executor’s own index format and commits to the flows git. A pull is bounded at 3 s; on a failure the box keeps its files.
gui_plan, script_run, write_file, edit_file, shell or upload, and 500 ms after any other write of a flow file, which the toolhost sees through inotify on /workspace/flows (a desktop app’s save, rsh), following the folder when a restore replaces it. It scans the folder and PUTs each file whose hash differs from its row. Content that the library refuses (a bad name, too large, a full library) is not sent again until it changes. The same watch sees a flow deleted or moved out, and the toolhost then drops it from INDEX in place, so gui_plan {flows: true} lists what is on disk./workspace/flows named [A-Za-z0-9][A-Za-z0-9._-]* (at most 96 characters) and ending in .json or .mts, at most 128 KiB each, UTF-8. INDEX, .runs.jsonl, .pending.json and the git folder stay local.| Question | Decision |
|---|---|
| Conflicts | Last write wins, in the order the api receives writes. Every write takes the library’s next rev, and a write of identical content changes nothing. A box never overwrites a local edit with a pull; its push then wins. The losing version stays in the git history of the box that had it (gui_plan commits every save, the pull commits after it writes). Per-flow merge or CAS buys nothing for a single-author GUI script. |
| Limits | 128 KiB per flow, 200 flows and 3 MiB per user, so one Export answer fits gRPC’s 4 MiB default. A flow is typically 2–10 KiB. Over a limit: HTTP 413 at the door, RESOURCE_EXHAUSTED on gRPC. |
| Another app version | Each library entry records the box and the image that wrote it last (box_id, image), and cocoonbox flows list shows them. Replay already resolves each step by its saved fingerprint, then by name, then by a decision; a step that still fails escalates with escalate.reason, the step and the screen it saw, as today. A replay that passes with re-resolved steps saves the flow, and the push carries the new fingerprints and image to the library. A flow therefore follows the image it last passed on. |
| Deletion | cocoonbox flows rm NAME (gRPC Delete) deletes from the library; every box removes its unchanged copy at its next pull. Deleting the file in a box stays local: the box does not pull it again until the library has a newer version. This way an agent that clears /workspace cannot wipe the library. Removing the whole folder is a lost folder, not a delete of each flow: the rows hold only for the folder they were agreed in (.sync-id), so a new folder gets every library flow it lacks, as a new box does. |
| Privacy | Flows hold typed values and params, so the library is owner-only: the door acts only for the machine’s owner, Flows only for the user token, and objects are sealed with the user’s key. A read share (§23.6) is refused /workspace/flows on a cbcore box (read_file, list_dir, symlinks included) and gets no “Saved flows” section in the guide. A write share has shell, which §23.1 already treats as handing over the box, so it can read the flows on that box like any other file. The share sheet’s disclosure should name saved flows (app follow-up). |
flows_mark read.Notify.cocoonbox flows export [NAME...] [-o FILE] writes JSON:
{"version": 1, "exported_at_ms": 0, "flows": [{"name": "mousepad-save-fruit.json", "content": "<text>", "sha256": "<hex>", "rev": 3, "saved_at_ms": 0, "box_id": "cb-…", "image": "cbcore:v1"}]}
cocoonbox flows import FILE (- for stdin) sends name, content and image for each flow. The rest of each entry is informational.
/flows (an old api) logs a failed pull and keeps its local flows.gui_plan.mts (for reindex); a cbcore cut carries both.