cocoonbox

Integration contracts (M0–M4)

The fixed points between the four components. Change one only by editing this file on main first.

Guest (image) ↔ agent

Item Value
Desktop user user (uid 1000), XFCE session on real Xorg display :0, xserver-xorg-video-dummy, 1280×800. Passwordless sudo since v10 (/etc/sudoers.d/90-cocoonbox-user, which keeps the proxy variables)
Agent user cbagent (system user, no login shell, not in user’s groups)
VNC TigerVNC 1.13 Xorg module (libvnc, Load "vnc") inside the desktop’s Xorg (cocoonbox-xorg.service) on 127.0.0.1:5900, SecurityTypes None, AlwaysShared, loopback only. It offers ContinuousUpdates (-313), Fence (-312) and ExtendedClipboard (UTF-8). With the defaults SendPrimary and SetPrimary it sends each PRIMARY and CLIPBOARD change to clients (unsolicited, to a client whose caps take text) and takes both selections when a client announces its clipboard, so Shift+Insert in a terminal pastes the client’s text. A client that advertises Fence must answer the server’s fence requests, or updates stop
Screen executor cua-driver (from loomhand/cua, in the shuttle archive image/desk/shuttle.sh pins) runs only as the child of shuttle’s daemon (shuttle serve /run/cocoonbox/desk.sock, the user unit shuttle.service, enabled for every user) on /run/cocoonbox/driver.sock with --no-overlay. The cua-driver unit is masked for every user (/etc/systemd/user/cua-driver.service links to /dev/null), so a wants link in a restored home cannot start it, and the desk provision removes a template’s own driver, unit, autostart entry and session script. At every session start the autostart entry cocoonbox-shuttle.desktop imports the session’s SESSION_MANAGER without its host parts into the user manager, adds ICE auth entries for that form, and restarts shuttle, so the apps its driver launches register with xfce4-session also after cocoonbox-hostname renames the host, are saved by desktop_save and come back with the session; LibreOffice’s launcher drops it (/usr/lib/libreoffice/program/ooenv), since its gen VCL plugin crashes at start with it set. The agent has no driver client of its own: its computer tools run on shuttle’s computer verbs
Browser Google Chrome launched by the toolhost for user, CDP on 127.0.0.1:9222, profile /home/user/.config/cocoonbox-chrome, with --restore-last-session: it reopens the tabs of its last clean exit, which the quiesced close (Browser.close) is. That close records in the profile (cocoonbox-was-running) whether Chrome was running, so the state layer carries it, and after a restore the agent starts Chrome only when the restored profile has the record. The managed policy /etc/opt/chrome/policies/managed/cocoonbox.json sets PasswordManagerEnabled=false, so Chrome never offers to save a password, and MetricsReportingEnabled=false and PromotionsEnabled=false, which drop the Welcome dialog and the Sign in to Chrome window of a new profile. The recommended policy /etc/opt/chrome/policies/recommended/cocoonbox.json sets RestoreOnStartup=1, so a new profile has no Continue where you left off infobar
Desktop session save The toolhost call desktop_save runs /usr/local/lib/cocoonbox/session-save as user. It checkpoints xfce4-session over DBus (org.xfce.Session.Manager.Checkpoint), waits for the session file, and records whether Mousepad is open. A quiesce calls it before closing Chrome; a failure is logged and the save goes on. xfce4-session keeps its saves in ~/.local/state/xfce4-session (~/.cache/sessions links there, so the state layer carries them). On each session start, desktop-session adopts the newest saved session under the current hostname, xfce4-session restarts the saved clients (xfce4-terminal with its tabs and directories, Thunar windows), and an autostart entry reopens Mousepad when it was open (dconf default session-restore='always', so it reopens its documents)
Packages ~/.config/cocoonbox/packages: one package per line, the packages marked manual beyond /etc/cocoonbox/packages.baseline (the image’s apt-mark showmanual, written at provision). An apt DPkg::Post-Invoke hook rewrites it as user after every apt run, so a removed package drops out. cocoonbox-packages.service (root oneshot, pulled in by cocoonbox-session.service, so it runs on every desktop start including after a restore) installs the listed packages this machine lacks, one at a time after a failed bulk install, and logs to the journal. It never orders the desktop behind it (Wants= only), checks with dpkg-query alone and runs apt-get update only when something is missing. A dpkg lock held past 120 s exits 75, and the unit retries every 60 s; a package that fails to install fails the unit without a retry. Packages the user removed from the image baseline come back with a new image, and packages from apt sources the user added fail, because /etc is not carried
Office apps The image carries LibreOffice Calc, Writer and Impress with libreoffice-gtk3, since the gen VCL plugin a plain apt install libreoffice-calc runs has no accessibility tree; LibreOffice’s launcher drops SESSION_MANAGER (/usr/lib/libreoffice/program/ooenv). Office files open in LibreOffice, and /etc/xdg/mimeapps.list keeps text/plain with Mousepad
Agent binary /usr/local/bin/cocoonbox-agent (one binary, two subcommands)
Loop cocoonbox-agent serve, unit cocoonbox-agent.service, User=cbagent, EnvironmentFile=/etc/cocoonbox/agent.env (root:cbagent 0640), state /var/lib/cocoonbox (cbagent 0700)
Tool host cocoonbox-agent toolhost-service, shuttle’s service plugin (spec /etc/cocoonbox/tools.d/cocoonbox.json), a child of shuttle serve as user with the session env (DISPLAY=:0, XAUTHORITY=/run/cocoonbox-x/Xauthority, DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus), on /run/cocoonbox/services/cocoonbox.sock (0700 directory); runs the shell, file, browser and state tools and relays the desktop tools to shuttle at SHUTTLE_SOCKET. Every toolhost call reaches it through shuttle’s /run/cocoonbox/desk.sock (user:cbagent 0660) in /run/cocoonbox (user:cbagent 2750; the setgid bit gives the socket group cbagent)
Desktop restart cbagent may start, stop and restart exactly cocoonbox-session.service (the XFCE session of user on :0) and nothing else, through polkit: /etc/polkit-1/rules.d/60-cocoonbox-cbagent.rules allows org.freedesktop.systemd1.manage-units for that unit and those verbs to subject user cbagent. serve calls plain systemctl stop\|start cocoonbox-session.service. Xorg and VNC (cocoonbox-xorg.service) stay up. State.Restore closes Chrome through the toolhost (CDP), stops the session, swaps /home/user, starts the session and relaunches Chrome. When the session stops, logind ends whatever user launched inside it (KillUserProcesses=yes for user), so a desktop restart kills user-launched programs in the XFCE session; the agent’s tools and Chrome run in toolhost and user@1000 and are unaffected
Box identity /run/cocoonbox-identity/identity (tmpfs; directory root 0755, file root 0644), written atomically (temp file and rename in that directory) by the api on every boot and wake: {"box_id":"cb-…","wake_id":"<random>","key_id":"<first 16 hex of sha256(public key)>","public_key":"<ed25519 key, base64url without padding>","name":"<box name>"}. cocoonbox-hostname.path sets the hostname to the name with _ as - (to the box id without a name); the X cookie holds for any hostname. The agent refuses the file unless the file and its directory belong to root and are not group- or world-writable
Workspace /workspace, owned by user
Box API keys The api sets the guest-visible claim env entries COCOONBOX_VAULT (the vault’s keys) and COCOONBOX_CONNECT (Connect’s injected grants) to records separated by spaces: NAME=placeholder;host,host;how. NAME and placeholder are A-Za-z0-9_-. The hosts are empty for a record that only sets a variable (LINK_NO_REFRESH=1;;). how is Header: template, with {} where the placeholder goes (Authorization: Bearer {}, Authorization: Basic {}: for a key sent as Basic), or ?param={} for a query key, with %, space and ; written %25, %20 and %3B. On every change of /run/silkd.env, cocoonbox-vault.path runs /usr/local/lib/cocoonbox/vault, which renames into /run/cocoonbox-vault (root, 0755; files 0644): vault.sh (export NAME='placeholder' per record, then unset NAME for each name in names that no record carries now), names (every name exported since boot) and keys (the records with hosts, one per line; absent without one). Login shells source vault.sh through /etc/profile.d/cocoonbox-vault.sh; the toolhost’s shell sources it before every command; the box agent reads keys at the start of each turn and lists each key, its hosts and how it is sent in its system prompt
Journal persistent under /var/log/journal, one directory per machine id. Every clone and every wake gets a new /etc/machine-id; cocoonbox-machine-id.path restarts journald so it writes under the current one. journalctl shows the current machine id only; the history across wakes needs journalctl -m
Agent listen 127.0.0.1:7443, plaintext HTTP/2 (h2c) gRPC, reached only through the sandboxd port relay
Desk runtime (cbcore) /opt/desk/AGENTS.md (the guide; the agent’s gate), /opt/desk/lib/desk.mts (the desk API: shuttle’s endpoint for the desktop and for models), /opt/desk/lib/gui_plan.mts and /opt/desk/lib/png.mts on Node 24, installed from the shuttle release archive that image/desk/shuttle.sh pins by tag and sha256; /usr/local/bin/desk runs cocoonbox-agent desk VERB [key=value ...] as a shell front to the same desk calls; the desk API and desk reach shuttle at SHUTTLE_SOCKET, /run/cocoonbox/desk.sock, which shuttle hands to its runs, plugins and services (so to the toolhost, and through it to the agent’s shell, exec and scripts) and the image sets for the XFCE session by the systemd drop-in 20-desk.conf and for user’s systemd manager (shuttle, so the apps its driver launches, and D-Bus-activated apps) by /etc/environment.d/20-desk.conf; serve reaches the toolhost at /run/cocoonbox/desk.sock (TOOLHOST_SOCKET, which defaults to SHUTTLE_SOCKET), where shuttle splices the toolhost’s calls to its service; /etc/cocoonbox/desk.env (root 0644) holds GW_BASE_URL, copied from agent.env at provision. With SHUTTLE_SOCKET from shuttle, the toolhost relays desk verbs, the desk tool and model calls to shuttle as they come, and runs gui_plan, script_run and plugin tools on shuttle with its own result shape; shuttle’s runs reach shuttle at SHUTTLE_SOCKET and answer model calls with /etc/cocoonbox/shuttle.toml (gw from desk.env, the key unprovisioned, dated jev-1.14 builds only, safety indicator, confirm and hotkey off, plugins from /etc/cocoonbox/tools.d, no driver overlay, the endpoint 0660 to group cbagent), which shuttle-config copies to ~/.config/loomhand/shuttle/config.toml (0600) before each start, with ~/.config/loomhand/shuttle/flows a link to /workspace/flows; serve’s drop-in sets SHUTTLE_SOCKET too, so serve sends desk calls and runs there itself and the toolhost takes and keeps their screenshots (run_screenshot). cbagent gains no right by it: the toolhost socket it already opens runs any shell command as user. With the service spec /etc/cocoonbox/tools.d/cocoonbox.json, the toolhost runs as shuttle’s service plugin: shuttle starts cocoonbox-agent toolhost-service in its own session environment (the spec goes in place after the agent step, and an agent without that subcommand exits, so it never serves there) (so Chrome starts there too), hands it SHUTTLE_SERVICE_SOCKET to bind and SHUTTLE_SOCKET (its own endpoint) for desk calls, restarts it with backoff, and splices every call that names one of the spec’s tools; serve and the XFCE session use /run/cocoonbox/desk.sock for both, the scaled screenshot is computer_screenshot and the saved-flows listing gui_flows, and the toolhost runs without the system unit’s OOM protection; computer_state, computer_act, computer_end, screenshot and run screenshots run on shuttle’s desk_computer_* and desk_look verbs (wire, Computer calls), where a call without session uses its caller’s own default session; the toolhost keeps type_from_file (it reads the card and checks the page field, then clicks and types on shuttle) and Chrome’s launch_app (the agent’s window of the browser tools’ Chrome, then shuttle’s desk_still and desk_look), runs an action list that holds one of them step by step at the scale desk_computer_scale gives, grounds a target on shuttle’s desk_look, desk_shot, desk_click and desk_title_at, and reads the window titles of its held-file notes from desk_windows

/etc/cocoonbox/agent.env keys:

GW_BASE_URL=http://10.203.0.1:18090/v1   <through the sandboxd egress proxy>
GW_API_KEY=unprovisioned   <a placeholder; the egress proxy injects the box's own key, so the guest never holds one>
MODEL_MAIN=deepseek/deepseek-v4.1-flash
MODEL_COMPUTER=anthropic/claude-sonnet-5
AGENT_LISTEN=127.0.0.1:7443
CONNECT_URL=http://10.203.0.1:18091/mcp   <the Connect box door, through the sandboxd egress proxy; absent: no connector tools and, on cbcore, no flow sync (the flow library is the same door's /flows)>

Toolhost wire (agent ↔ toolhost, desk API ↔ shuttle)

The agent’s toolhost (through shuttle’s /run/cocoonbox/desk.sock, which splices the toolhost’s calls to its service socket after the first frame) and the desk API (/opt/desk/lib/desk.mts, desk) speak shuttle’s desk wire, implemented once in shuttle-desk-core (desk_core::wire, a git dependency pinned to a shuttle tag), which the toolhost also takes the desk call arguments from:

Edge (api) ↔ agent

State objects (agent ↔ api)

The agent writes the objects of a save through presigned URLs and reads them back in a restore. The api opens them for BrowseSnapshot, RestorePaths and the GC, and writes manifests itself (deltas, and the full manifest again before an agent reads one). docs/fixtures/state-v1.json holds one sealed manifest and one sealed chunk under a fixed key; the agent tests (state::manifest) and the api tests (boxes) both open it, so a change on either side that breaks the other fails a test.

Browser sign-in and device tokens

The CLI and the app sign in through the same authorization server, as first-party clients with a token of the whole account. Each sign-in is a device.

First-party clients are fixed in the api and never registered: cocoonbox-cli (cocoonbox CLI) and cocoonbox-app (CocoonBox app), each with redirect URI http://127.0.0.1/callback on any port (RFC 8252 §7.3). Their only scope is account, which no other client may ask for: an agent asking for it, or a first-party client asking for anything else, gets invalid_scope back.

Flow (loopback, RFC 8252). The client listens on 127.0.0.1:0, makes a PKCE pair and a state, and opens <endpoint>/cb/oauth/authorize?response_type=code&client_id=cocoonbox-cli&redirect_uri=http://127.0.0.1:<port>/callback&scope=account&code_challenge=…&code_challenge_method=S256&state=…&device_name=<host name>. device_name (at most 64 bytes of UTF-8, else invalid_request) names the device on the consent page and in the device list. POST /cb/api/oauth/check answers account: true and device_name, and the page asks “Sign in the cocoonbox CLI on ?" with Allow and Deny and no caps; a first-party client needs the user's click too, since any local process can claim its id. `POST /cb/api/oauth/approve {query}` stores a one-use code (60 s) and the browser goes to the loopback port with `code`, `state` and `iss`. The client checks `state` and that `iss` is its endpoint, serves a page that says it can be closed, and posts the code to `/cocoonbox.oauth/token` with `grant_type=authorization_code`, its `client_id`, `redirect_uri` and `code_verifier`. The answer is `{access_token: , token_type: "Bearer", scope: "account"}`, with no `expires_in` and no refresh token; a code of a user disabled meanwhile is `invalid_grant`.

Device tokens are shaped as the personal token (cb_ + 64 hex) and work wherever it does: gRPC, the box and control MCP, the screen, rsh, COCOONBOX_TOKEN. They stay in user_tokens (users migration 0009: id dev_ + 16 hex, user, sha256, client, label, created and last-used times) and in the same in-memory index as the personal token, so a call costs one map lookup; the last use is written at most once a minute per device. A device token has no expiry. It ends when the user revokes it, when the client sends it to POST /cocoonbox.oauth/revoke (token; 200 for an unknown token, and a personal token is left alone), or with its user’s disable (until re-enabled) or delete. Regenerating or revoking the personal token, or changing the password, leaves device tokens alone. A revoke notifies cocoonbox_users, so every api drops the token from its index. Every call still open on a revoked or replaced token (a gRPC stream, a box or control MCP exchange, a Connect door call) ends with it.

The clients. cocoonbox login uses cocoonbox-cli; the app’s sign-in sheet has “Sign in with browser” with cocoonbox-app (device name: the Mac’s computer name) above the paste field, and a Cancel that ends the wait and frees the port. Each revokes on sign-out only the device token it got through the browser: the CLI keeps the token’s source per endpoint in cli.json (browser, paste or app), the app in its defaults, so cocoonbox logout leaves a token copied from the app alone. A new CLI login revokes the browser token it replaces.

The user. The console’s Devices section lists them (GET /cb/api/devices: id, client, label, created_at, used_at) and DELETE /cb/api/devices/{id} revokes one at once (404 for a device of another user or one already gone). DELETE /cb/api/me/token revokes the personal token at once, 204 also when there is none; device tokens stay.

cocoonbox.control.v1.Account (proto/cocoonbox/control/v1/account.proto, the personal token or a device token): ListDevices answers the console’s list plus current, true for the device whose token made the call; RevokeDevice{device_id} revokes one (NOT_FOUND for another user’s device or one already gone); RevokeToken revokes the personal token; SignOutEverywhere signs the caller out everywhere (below). cocoonbox devices [--json] lists them, * marking the CLI’s own, and cocoonbox devices revoke ID revokes one.

Sign out everywhere. POST /cb/api/me/sign-out-everywhere (console session, 204, the session cookie cleared), Account.SignOutEverywhere and cocoonbox logout --everywhere (which then deletes the CLI’s stored token) end every sign-in of the user at once, the calling one too: every console session, the personal token, every device token and every agent’s OAuth grant with its tokens, and every call open on any of them. Boxes, shares, connected accounts, keys and the boxes’ gw and door keys stay. The grants go first, then the sessions and tokens in one transaction; a failure leaves the rest for a retry. A named share’s screen open in the console ends with its viewer too. Every api ends the calls of a revoked device or personal token once it reloads its index; the calls of a revoked grant and the user-wide end of sign out everywhere end only on the api that served the revoke, which is every call while one api runs, the deployed contract.

Sign-in with GitHub and signup

GitHub is the one identity provider. A dedicated GitHub OAuth App, apart from Connect’s GitHub App, signs people in: its id and secret come from LOGIN_GITHUB_CLIENT_ID and LOGIN_GITHUB_CLIENT_SECRET (/etc/cocoonbox/login.env), and its callback is <oauth.public_url>/cocoonbox.auth/callback, which Caddy routes to 127.0.0.1:18201. The package is api/signup, its tables in the users database under signup_schema_migrations.

Identities. identities(idp, subject, user_id, email, email_verified) with the subject GitHub’s numeric account id, unique per IdP account and per user and IdP. Only a signed-in user connects one (POST /cb/api/me/identities/github, then the GitHub consent); an email address never links an account. GET /cb/api/me/identities lists them, DELETE /cb/api/me/identities/github disconnects one, refused (409) for an account made through GitHub.

Sign-in. GET /cb/api/auth/providers answers {github, signup, invite_only}. POST /cb/api/auth/github/begin {return_to, invite} stores a flow (state, PKCE verifier, a nonce hash, 10 min, one use), sets the Lax nonce cookie cb_login on Path=/cocoonbox.auth/, and answers GitHub’s authorize URL (scope user:email, S256). The callback takes the flow only with its cookie’s nonce and the right state (a forged answer leaves it to the real one), redeems the code, reads /user and /user/emails (the primary address and whether it is verified) and drops GitHub’s token. A known identity of an enabled user gets a console session and goes to return_to, a /cb/ path (the consent page of a CLI or app sign-in sends its own). Begin and the callback allow 20 calls per client address in 15 min each. Failures and refusals go to /cb/?login_error=<code> (signup_closed, email_unverified, email_not_allowed, invite_required, declined, disabled, github_taken, github_linked, browser, too_many, github_denied, failed), which the page turns into text, so a link cannot put words on it. Linking an identity that waits in the queue drops its entry.

Signup (off by default). An admin sets signup_settings on the admin page (GET and PUT /cb/api/signup): enabled, invite_only (default on), seats, allow_domains and allow_emails (empty allows everyone), and a signup account’s daily_cap_micros (default $1), max_agent_boxes (default 1) and hourly_accounts (default 50). A new identity is refused when signup is off, the primary email is unverified, the email is outside the allowlist, signup is invite-only and the sign-in carries no usable invite, or an admin rejected the person; otherwise it joins signup_queue, at the front with an invite, and admission runs. A seat is one enabled non-admin account, admin-made or not; admins may exceed the cap.

Admission locks the settings row, counts the seats in use and the accounts admitted in the last hour, and admits the oldest waiting people into the free seats within the hourly brake, under that lock, so a freed seat admits one person across apis. It runs past the caller’s cancel, and each person in a savepoint, so one failure leaves the others admitted; the account’s id comes from the queue entry’s, so a retry reuses an account a failed attempt made. It runs on a signup, a user deleted or disabled, a settings change, a person leaving, and every 5 min. Admitting makes the account at once: users.idp github, the username from the GitHub login lowercased and cleaned to the user name grammar with -2, -3, … on a clash (no rename), its own gw daily cap, and max_agent_boxes; the identity is linked and the person’s next sign-in, or the one that admitted them, signs them in.

The queue. A queued person gets the Strict cookie cb_queue (90 days) and the page /cb/queue: GET /cb/api/queue/me answers {status, position, waiting, email, since}, DELETE /cb/api/queue/me leaves and deletes the entry. No email is sent. The consent page of a CLI or app sign-in carries an invite from its own URL into its GitHub sign-in (cocoonbox login --invite CODE puts it there), so a new person signs up and gets back to the consent in one pass; the Allow click stays. Admins list the queue (GET /cb/api/queue) and act on an entry (POST /cb/api/queue/{id}/admit|front|reject; admit makes the account past the cap). Invites: POST /cb/api/invites {uses 1..1000, days 1..90} answers the code once (cbi_ + 64 hex, its sha256 stored) for the link /cb/?invite=<code>; GET lists them, DELETE /cb/api/invites/{id} revokes one.

Invite-scoped tokens. An admin mints one on the admin page (POST /cb/api/invite-tokens {label} answers cbit_ + 64 hex once; GET lists them with the invites each made and its last use, DELETE /cb/api/invite-tokens/{id} revokes one). Only its sha256 is stored (invite_tokens, signup migration 0002), and it lives while its admin is an enabled admin. Sent as Authorization: Bearer <token>, it reaches only POST /cb/api/invites (one use, days 1 to 7, default 2; any other uses is a 400) and GET /cb/api/invites (the invites it made). Every other route of the api’s HTTP server (the console, the control and box MCPs, the Connect door and callback, the sign-in callback, the OAuth endpoints) answers 403 to a request carrying one, the check wrapping the whole mux; the bearer check of gRPC and MCP refuses the cbit_ prefix before any lookup, so no user token check accepts one. Logs name the token’s id, never the token. cocoonbox invite create [--days N] and invite list read it from COCOONBOX_INVITE_TOKEN, else from --token-file PATH or COCOONBOX_INVITE_TOKEN_FILE, never from argv. Seats cap the admissions its invites bring.

Agent boxes of a user. users.max_agent_boxes (users migration 0010; null for no cap, the admin-made default) bounds the boxes all of a user’s grants hold together, checked under the owner’s row lock when a box is added, and the tenant’s max_claims counts the agent boxes up to it: max_boxes + min(agent boxes, max_agent_boxes) + 1. An admin sets it per user (PATCH /cb/api/users/{id} {max_agent_boxes}, 0 for no cap).

Self-delete. POST /cb/api/me/delete {name} with the caller’s own user name runs the admin delete (disable, release the boxes, delete the state, revoke the connections, delete the row, whose cascade removes identities, device tokens, grants and the queue entry), ends the session and admits the next person; an admin’s account is refused (403).

Client (macos) ↔ edge

Agent names and removal

Sign in with Passwords (app only)

Remote command (Session.Exec, cocoonbox rsh)

Offload (design §27)

A local coding agent’s task moves into the box and back. The local agent writes a hand-over; a fresh box CLI session, signed in inside the box by the user, continues from it. The Mac packer is cocoonbox offload / cocoonbox back (macos); the box packer is cocoonbox-agent offload <verb> (agent), run as user through cocoonbox rsh.

Requires cbdesk:v22 or later (git, rsync, tmux and mise since v20; build-essential and the Exec terminfo upload since v21; the box packer, the inbox unit and the secrets tmpfs since v22), and the box CLI of the chosen kind installed and signed in inside the box. Git repositories only.

Names.

Directories.

Path Owner, mode Holds
Mac: ~/Library/Application Support/CocoonBox/offload/<repo key>/ the user, 0700 handover.md (path from cocoonbox offload handover-path), env.json, the bundle while it is sent, the box’s back.md, and offload.json while the offload is in flight: id, repo, box, box_name, name, dir, kind, base (S), top, started_at_ms, seen (the last outbox seq offload inbox showed); written before send, removed after end
Mac: ~/Library/Application Support/CocoonBox/offload/repos.json the user, 0600 in the 0700 offload/ per repo key: the opted-in secret entries (file:<repo path>, aws, kube, gcloud, ssh:<key name>; names and paths only, set with cocoonbox offload secrets add\|remove) and whether the user said yes to the size confirm
Box: /home/user/.local/share/cocoonbox/offload/ user, 0700 index.json (repo key → name), excludes, and per repo key the inbox, outbox, session record, hooks file, first prompt, env.log, handover.md (the box agent’s) and stage/ (emptied after each send). The state layer saves it
Box: /run/cocoonbox-offload/secrets/<repo key>/ user, 0700; files 0600 opted-in secrets, on a 64 MiB tmpfs the image mounts at /run/cocoonbox-offload (user, 0700). Never saved

Offload, Mac side (three Execs):

  1. cocoonbox rsh --box B -- cocoonbox-agent offload begin ….
  2. rsync -az --delete --exclude-from=<ignored> --exclude=/.git/objects/ -e "cocoonbox rsh --box B" <top>/ box:/workspace/<name>/, <ignored> from git ls-files -oi --exclude-standard --directory on the Mac plus begin’s ignored, each anchored at the top (/<path>). No --delete-excluded, so the box’s own ignored files (target/, node_modules/) stay, also where the Mac has none.
  3. cocoonbox rsh --box B -- cocoonbox-agent offload send … < stream.

History travels as a bundle in the stream, never by rsync: git bundle create <stage>/offload.bundle --all --not <every value in begin's refs the Mac has>, left out when git refuses it as empty. Only objects reachable from refs travel (older stash entries do not). A linked worktree (.git is a file) is sent without .git; the stream then carries its HEAD and index, and the box copy is a plain repo built from the bundle. The box takes the bundle with git index-pack --stdin --fix-thin on its pack and then sets its refs, never with git bundle unbundle: the refs rsync brought already name objects that only the bundle holds, and unbundle refuses its prerequisites as “not connected”.

Back, Mac side:

  1. offload send --repo K --back [--interrupt], unless the outbox already holds this offload’s back.
  2. offload read --repo K --since N --wait S, repeated until a back entry, for at most 600 s in all; then back fails (exit 1) with the session’s status, and BACK stays queued for the CLI’s next idle.
  3. GIT_SSH_COMMAND="cocoonbox rsh --box B" GIT_SSH_VARIANT=simple git fetch box:/workspace/<name> '+refs/heads/*:refs/cocoonbox/box/*' '+refs/cocoonbox/back/<id>:refs/cocoonbox/back/<id>'.
  4. Merge against S: fast-forward when the local repo still equals S, else a clean git merge-tree --write-tree applied uncommitted, else the branch cocoonbox/back-<UTC yyyymmddThhmmssZ> at S’ and an untouched tree.
    • Fast-forward: the work tree moves from S to S’. The uncommitted state it replaces is the one the box started from, kept in S.
    • Clean merge: a merge that would change a path the user changed since HEAD (staged, unstaged or untracked) takes the branch path instead, so uncommitted local work is never touched. The merged tree is applied uncommitted; when the local HEAD is still S’s parent and the box HEAD descends from it, the branch moves to the box HEAD, so the box’s commits stay commits.
    • Moving the branch: first refs/cocoonbox/pre-back/<id> → the old HEAD. The new index (the staged changes carried over, else the box HEAD’s tree) is built aside and held as git’s index.lock before the work tree changes; a held lock, or a patch that does not apply, changes nothing.
  5. offload end --repo K.

Verbs. stdout is one JSON line: {"ok":true,…}, or {"ok":false,"code":"usage|refused|failed|inbox","error":"<one line>"}. stderr carries progress for a human. Neither ever carries a secret; only read carries message bodies. Exit codes: 0 done; 1 failed, rolled back as stated; 2 usage; 3 refused, nothing changed; 4 inbox, the inbox is not running. A begin refusal also carries findings (the box-side blocks, as in begin’s output) and cli next to error.

Verb Run by Does; stdout fields
begin --repo K --name N --kind C --id ID [--no-repo] Mac the box half of the preflight (below); refuses on a block; else takes the copy for ID, records the name and makes /workspace/<name>. dir, copy (absent or mirror), refs ({"<ref>":"<oid>"} of the copy), cli (kind, version, signed_in, account; for the claude kind hooks_blocked, the first of /etc/claude-code/managed-settings.json, managed-settings.d/*, ~/.claude/remote-settings.json and ~/.claude/settings.json that sets allowManagedHooksOnly or disableAllHooks, which cocoonbox offload prints as a warning), ignored (the copy’s git-ignored directories relative to it, as git ls-files -oi --exclude-standard --directory lists those ending in /), findings ([{"check","outcome":"block\|warn","detail"}])
send --repo K --kind C --id ID [--prepare] [--no-repo] < stream Mac the steps below. delivery (started), accepted, tmux, timings_ms per step
send --repo K (--message \| --back) [--interrupt] < text Mac a message, or BACK, to the repo’s running session. seq, delivery (pasted or queued); no running session is refused
read --repo K [--since N] [--wait S] Mac outbox entries after seq N; --wait long-polls up to S ≤ 600 seconds. entries ([{"seq","kind","offload_id","cli","time_ms","body"}]), status
status [--repo K] Mac, app sessions ([{"repo","name","cli","offload_id","status","copy","tmux_alive","hook_at_ms","hooks_silent","transcript_mtime_ms","window_activity_ms","inbox_seq","outbox_seq"}])
end --repo K Mac, after the fetch ends the CLI (/exit, /quit for codex, SIGTERM after 10 s), wipes the repo’s secrets, and marks the copy returned
wipe-secrets --repo K Mac removes the repo’s secrets now
restart --repo K the user the inbox’s restart
post (--progress \| --question \| --back) [--repo K] < body box agent an outbox entry; --repo defaults to COCOONBOX_OFFLOAD_REPO. --progress and --back refuse a body without every hand-over heading and list the missing ones. --back also makes S’. seq, status (posted, or ready for --back)
env [--repo K] [--no-wait] [--json] box agent waits for the environment step through the inbox’s env, at most 900 s, and prints per tool the wanted version and its source, what is installed, and ready, missing or failed, then each lockfile fetch. Text unless --json. An inbox that is down, or stops during the wait, is inbox; a step that an inbox restart cut short is failed. --no-wait reads the report as it stands without the inbox
event --repo K --cli C --hook NAME < payload the CLI’s hooks the inbox’s event, with the hook’s JSON
serve cocoonbox-offload-inbox.service (user unit of user) the inbox

Copy states: absent (no index entry or no directory); mirror (no CLI session started on it since the last end); unreturned (one did); busy (a session that is not stopped). begin refuses unreturned and busy. begin takes a mirror or absent copy for its offload id, and send refuses another id.

Send stream: a tar (ustar or pax) of regular files, read in order:

Member When Goes to
handover.md always, except --prepare the first prompt
env.json always, except --no-repo the environment step
offload.bundle when history is new git index-pack --stdin --fix-thin on its pack (above), then deleted
HEAD, index a linked worktree the copy’s .git/
secrets/<entry> opted in the tmpfs, straight from the stream

send steps (box): read the stream; create .git/objects when absent and index the bundle (above); git -c core.fsmonitor=false update-index -q --refresh; check the copy equals S (a temporary-index snapshot’s tree equals S^{tree}, HEAD equals S’s parent), else failed; place secrets; write excludes; preset the CLI’s trust for the directory (claude: ~/.claude.json hasCompletedOnboarding and projects[<dir>].hasTrustDialogAccepted; codex: [projects."<dir>"] trust_level = "trusted" in ~/.codex/config.toml) and nothing else of its config; hand the first prompt to the inbox (start); wait until the CLI takes it (its prompt hook, else a new transcript file), at most 30 s, after which accepted is false. --prepare stops before the trust preset and runs only the environment step. Before the inbox accepts, a failure wipes the repo’s secrets, empties stage/ and releases the copy, which may stay partly synced: it held nothing unreturned, and the next offload syncs it again.

First prompt, composed by the box packer:

  1. The preamble: the agent runs in the user’s box, in /workspace/<name>, the user is away and nobody answers prompts; run cocoonbox-agent offload env first; HTTP_PROXY and HTTPS_PROXY are set and can break tests that read them; before blaming its change for a failure, compare against S (git stash, or a worktree at S); commit as it goes and never push; post progress, questions and back with post, writing its hand-over to handover.md in the repo’s offload directory; the full hand-over template.
  2. The hand-over.
  3. “Attached by the packer”: the branch and S, git status --short, git log --oneline @{upstream}..HEAD (else the last 5 commits), Mac-only settings found in .git/config (credential.helper osxkeychain, an absolute core.hooksPath), and env.json in short.

Box CLI in tmux cb-off-<repo key>-<kind>, a login shell in the copy, started with send-keys, the prompt read from a file. Bypass mode by default, as the offload sheet states: claude --dangerously-skip-permissions --settings <hooks file> "$(cat <prompt file>)", the hooks file (ours, never the user’s settings) also carrying "skipDangerousModePermissionPrompt": true, since the bypass-mode dialog would hold the first prompt; codex --dangerously-bypass-approvals-and-sandbox -c 'notify=["cocoonbox-agent","offload","event","--repo","<K>","--cli","codex","--hook","notify"]' "$(cat <prompt file>)"; cursor at O6. The tmux environment carries COCOONBOX_OFFLOAD_REPO, the MISE_<TOOL>_VERSION pins and the secrets’ variables. The claude hooks, all calling offload event: UserPromptSubmit → working, Stop and StopFailure → idle (or blocked), Notification → waiting_for_user (a blocked session stays blocked), SessionEnd → stopped (except reason clear). claude ends a turn that an API error stopped with StopFailure, not Stop. The hook commands send their stdout to /dev/null, so nothing reaches the model. codex: a delivered prompt → working, notify → idle (or blocked).

Inbox. Socket /run/cocoonbox/offload.sock, user:cbagent 0660, in /run/cocoonbox (2770, so the socket’s group is cbagent). The verbs (as user) and the agent (cbagent) connect; there is no port and no edge route, so from the Mac it is reached only through Session.Exec, owner only. One JSON object per line; each request gets one response line, except watch. Errors are {"ok":false,"code":"invalid|refused|failed","error"}.

Request Response Does
{"op":"send","repo","name","cli","offload_id","body","start"?,"back"?,"interrupt"?} {"ok","seq","delivery":"started\|pasted\|queued"} stores the message. start: a fresh CLI session with body as its first prompt, refused while one runs or when 3 sessions of the box are not stopped. Otherwise an idle session gets it pasted (tmux load-buffer, paste-buffer -p, Enter); a busy one queues it until its next idle. interrupt sends Esc first; back puts the BACK instruction first and drops a BACK still queued
{"op":"post","repo","kind":"progress\|question\|back","body"} {"ok","seq"} an outbox entry
{"op":"read","repo","since","wait_ms"?} {"ok","entries","status"} outbox entries after since; wait_ms long-polls
{"op":"env","repo","wait_ms"?} {"ok","report"} the repo’s environment report (null without a step) once the step that this inbox runs has ended, or after wait_ms (at most 900 s) with done false; an unfinished report whose step this inbox does not run is failed at once
{"op":"status","repo"?} {"ok","sessions"} as the status verb
{"op":"restart","repo"} {"ok","delivery":"started"} below
{"op":"event","repo","cli","hook","payload"} {"ok"} a hook’s report; status, queued delivery, blocked
{"op":"rescan"} {"ok"} reloads the session records and runs the restart check below; the agent sends it, without reading the answer, after a State.Restore replaced the home
{"op":"watch"} a line per change: {"repo","name","cli","offload_id","entry","seq","status"} the agent’s feed for Session.Events

Event. Session.Events carries offload (OffloadUpdate, session.proto) for each outbox entry and each status change, from the agent’s watch: repo key, name, CLI, offload id, the entry kind and seq (unset for a status change) and the status. Never a body. Replayed like other events; owner only, like every Events.

Environment (env.json, written by the Mac packer):

{
  "v": 1,
  "tools": {"go": "1.26.5", "rust": "1.88.0", "node": "22.11.0", "python": "3.12.7"},
  "package_managers": {"pnpm": "9.12.0"},
  "files": ["go.mod", "rust-toolchain.toml", ".tool-versions"],
  "lockfiles": ["go.sum", "Cargo.lock"]
}

Secrets (opt-in per repo on the Mac; never in argv, an environment variable or a log on the Mac, never by rsync, never in the state store):

State excludes. At every send the box packer writes to excludes the copy’s git-ignored directories (git ls-files -oi --exclude-standard --directory, the entries ending in /), one absolute path per line. The state layer leaves out every listed path under /workspace/ and ignores any other line; a restore rebuilds them.

Hand-over. The first line starts # Hand-over: . Every one of these headings appears once, as its own line: ## Goal and definition of done, ## User preferences and constraints, ## Current state, ## Decisions made, ## Files touched, ## Next steps, ## Commands, ## Environment facts, ## Gotchas learned, ## Open questions. An empty section says none. At most 256 KiB. cocoonbox offload refuses a hand-over that breaks this before any check; post --progress and post --back refuse the same way.

Preflight. block stops with nothing changed; warn goes on only with the user’s yes (or a go-ahead given beforehand); confirm is a warn naming the size.

Finding Where Detection Outcome
Box image older than cbdesk:v22 Mac the box’s image in Boxes.List block: “upgrade the box first”
Not a git repository Mac git rev-parse --show-toplevel block
Box tools missing begin git, rsync, tmux, mise on a login shell’s PATH block
Box CLI missing or signed out begin claude --version, claude auth status; codex --version, codex login status; cursor-agent --version, cursor-agent status block, with “Install in box” (the vendor’s official installer over rsh, into /home/user) when missing and “Sign in in the box” when signed out; both accounts shown when they differ
Unreturned or busy copy begin copy states block: “bring the last offload back first”
Three box sessions running begin inbox status block
macOS-only work Mac *.xcodeproj, *.xcworkspace, a Package.swift with only Apple platforms, Podfile, fastlane/; a hand-over Commands line running xcodebuild, xcrun, security, launchctl or open block when a Commands line needs it; warn when the repo only contains an Apple target
Local services Mac listening TCP ports (lsof -iTCP -sTCP:LISTEN) named in the repo’s config, .env* or compose files; a running docker compose project of the repo warn, per service and port
VPN-only hosts Mac hosts in the remotes, config and .env* that resolve to private ranges, or only through a split-DNS resolver (scutil --dns) warn
Secrets not opted in Mac git-ignored .env*; the hand-over’s “Secrets needed”; unset names the code reads (os.Getenv, process.env, std::env::var) warn, with a per-repo opt-in
Size Mac rsync’s dry run over 500 MB confirm; a yes is kept per repo in repos.json
LFS, submodules Mac filter=lfs in .gitattributes; .gitmodules warn: not carried

Entry points (design §27.4). The user’s own claude runs /cocoonbox:offload and /cocoonbox:back from the plugin, which cocoonbox setup claude (or the app’s plugin install item) installs or updates; codex and other CLIs get the request to paste from cocoonbox offload --handover-prompt, or the user runs cocoonbox offload and cocoonbox back directly. The app lists the offloads in flight in its sidebar’s Offloaded section, from the offload.json records, offload status of each running box and offload events, and only watches: offload and back start from the user’s own TUI, never from the app. A back that finds no record but a back.md names that file.

Watching. The app’s Offloaded section shows the box CLI in a Ghostty pane through Session.Exec with a tty running tmux attach -t cb-off-<repo key>-<kind>. Before Hibernate or Rebase of a running box, the app and the CLI call offload status; a working session gets a warning to confirm (hibernate pauses it; an upgrade restarts it from its last hand-over).

Boxes (api) ↔ sandboxd

Scheduled wake (design §15.13)

Remote tools over MCP (mode B: bring your own agent)

Mode A (the in-box loop) stays. Mode B lets an agent the user already runs — a local coding agent on their Mac — drive the box through the same tool layer. Both modes share the toolhost; neither owns the desktop exclusively.

Control MCP (box lifecycle for native agents, M4.7)

The api serves the lifecycle of the caller’s own boxes as MCP tools, so a local agent can list, create, wake, save, upgrade, hibernate and delete them without the app. It sits beside the per-box MCP above; tool names, input and output schemas, progress and error behaviour below are the contract between the api and the cocoonbox CLI bridge.

Results. Every successful call returns structuredContent matching the tool’s outputSchema, plus one text content block with the same object serialized as JSON. Enum values are the proto names, lower-cased and without the prefix: states absent starting running hibernating hibernated failed; phases creating waking snapshotting saving restoring deleting done failed restoring_files.

Errors. A tool that runs but cannot do what was asked returns a result with isError: true and one text block, and no structuredContent:

Only protocol problems use JSON-RPC errors: -32602 for an unknown tool or arguments that do not match the input schema, -32601 for an unknown method, and -32603 for an internal failure.

Progress. For each OperationEvent of the operation a tool waits on, the server sends one notifications/progress:

Without a progress token the server still waits; it just sends nothing until the result.

Every tool but box_list, box_create and usage takes an optional box: a box name or id of the caller’s ({"type":"string","description":"The box's name or id; without it, your first box."}). Without it a tool acts on the first box; box_delete requires it.

Tool Input schema structuredContent
box_list {} {boxes: [{box_id, name, state, image, last_saved_at_ms}]}, oldest first
box_status {box} {box_id, name, state, image, latest_image, upgrade_available, last_saved_at_ms}
box_ensure {box, "wait": boolean, default true} {box_id, state, operation_id}. operation_id is "" when the box was already running. With wait the result arrives when the operation is done.
box_create {"name": string, required; "image": string; "wait": boolean, default true} {box_id, name, state, operation_id}
box_hibernate {box} {box_id, state, operation_id} after the operation is done
box_save {box} {box_id, last_saved_at_ms, operation_id, bytes}; bytes is the last total_bytes of the saving phase
box_upgrade {box, "confirm": boolean, required}; it always targets latest_image {box_id, state, image, operation_id} after the operation is done
box_delete {box, required; "confirm": boolean, required} {box_id, operation_id} after the operation is done
usage {} {today, month, daily_cap_micros, default_cap, remaining_today_micros}, as Boxes.Usage (Model usage below)

Field types: box_id, name, state, image, latest_image and operation_id are strings; upgrade_available is a boolean; last_saved_at_ms and bytes are integers (0 means never, or nothing moved). For a user without a box, box_status returns state: "absent" with empty strings, and box_ensure creates the box.

Tool descriptions (model-facing; the api returns these exactly):

Model usage

gw meters every model call per user (user_id = the owner of the user’s one gw key) and model; the api reads it, the box reports its own share, and nobody estimates an outside agent’s tokens.

OAuth for agents (MCP authorization 2025-11-25)

An agent the user runs adds the control MCP URL and signs in through the standard MCP authorization flow; the user approves it in the console. On with oauth.public_url in api.yaml (the issuer, an https origin); off when empty.

Discovery. One protected resource, <endpoint>/cocoonbox.mcp, covers the control MCP and every box MCP under it; a client’s resource may name it or any URL under it. GET /.well-known/oauth-protected-resource/cocoonbox.mcp, and the same document at any path under it such as .../cocoonbox.mcp/control (RFC 9728): resource <endpoint>/cocoonbox.mcp, authorization_servers [<endpoint>], scopes_supported ["boxes"]. GET /.well-known/oauth-authorization-server (RFC 8414): issuer <endpoint>, authorization_endpoint <endpoint>/cb/oauth/authorize, token_endpoint /cocoonbox.oauth/token, registration_endpoint /cocoonbox.oauth/register, revocation_endpoint /cocoonbox.oauth/revoke, code_challenge_methods_supported ["S256"], token_endpoint_auth_methods_supported ["none"], client_id_metadata_document_supported and authorization_response_iss_parameter_supported true. Caddy routes /cocoonbox.oauth/*, /.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource/cocoonbox.mcp and .../cocoonbox.mcp/* to 127.0.0.1:18201 ahead of the /cocoonbox.* rule.

Clients (public only; a document or registration asking for another token endpoint auth method is refused):

Authorization. GET /cb/oauth/authorize?<request> is a console page. It needs the console session; without one it shows the sign-in form. It sends the raw query to POST /cb/api/oauth/check (JSON, console session), which answers what the page shows: the client’s name and id, the verified host of its metadata document (empty for a registered client, shown as unverified), the redirect URI’s host and whether it is loopback (shown with a warning to allow only a request the user just started on that computer), and the default caps. A client or redirect URI the api does not trust is a 400 the page shows; past those checks, response_type other than code, PKCE other than S256 with a 43-character challenge, a scope with anything but boxes, and a resource outside <endpoint>/cocoonbox.mcp go back to the client as unsupported_response_type, invalid_request, invalid_scope and invalid_target. An absent scope or resource means boxes and the whole resource. The page also says when the client already holds a live grant of the user, and that the agent cannot use the user’s saved keys or connected accounts. POST /cb/api/oauth/approve {query, max_boxes 1..16, max_running 1..max_boxes, days 1|7|30|90} (defaults 2, 1, 30) stores a code (one use, 60 s) and answers {"redirect"}: the redirect URI with code, state and iss. POST /cb/api/oauth/deny {query} answers the redirect with error=access_denied.

Tokens. POST /cocoonbox.oauth/token (form; client_id in the form or as the Basic user name):

What a grant reaches. Its access token works on the control MCP and on the box MCP of the boxes the grant created, as the grant’s user, while the grant lives and the user is enabled. The user-token check never maps a cba_ token to a user, so gRPC, the screen, the box MCP of any other box, the console, Connect’s doors, Keys, Shares and Flows answer it as a bad token. On the control MCP the tools see only the boxes the grant created (box_list, a box name or id, the first box without one), within its caps: at most max_boxes such boxes and max_running of them starting, running or hibernating. usage reports the user’s whole account, the daily cap the grant’s boxes spend from.

Agent boxes and the user’s limits. A box a grant created is an agent box (boxes.agent, users migration 0008). The grant’s caps bound it, and the user’s limits under “Client (macos) ↔ edge” do not: it does not count toward the user’s max_boxes, its Create and Ensure skip the user’s running limit, and it holds none of that limit for the user’s own boxes. The user’s max_boxes and running limit govern only the user’s own boxes, so an agent can create boxes while a user on the default max_boxes 1 keeps main. The user’s sandboxd tenant cap (max_claims) is max_boxes + the agent boxes + 1 (the Rebase overlap), rewritten when an agent box is added or removed. Spend stays bounded by the user’s gw daily cap; the number of agent boxes is bounded by the caps of each grant the user approved.

No owner credentials in agent boxes. A box a grant created gets none of its owner’s vault keys (no CBV_ entries, no COCOONBOX_VAULT), no Connect credentials (no injected grant entries or flags) and no box door key, at provisioning, on a wake, on a key or grant change and in the periodic pushes; it keeps this after its grant ends. Its programs still reach gw with the owner’s gw key and count against the owner’s daily cap. The box row carries the mark from its insert, before its machine starts, and the api reads it from the in-memory index.

The user. /cb/agents lists the grants (GET /cb/api/agents): client, verified host, approval and expiry times, last use (kept to the minute), caps and the boxes each created. DELETE /cb/api/agents/{id} revokes one; its tokens stop at once, its open box and control MCP exchanges end, and its boxes stay the user’s. A revoke at /cocoonbox.oauth/revoke and a replayed refresh token end them the same way. A grant ended this way stays ended for 10 minutes for a call that passed the token check just before the revoke. Grants the user has not seen are marked new there, and the console’s front page names them in a notice until the Agents page is opened (POST /cb/api/agents/seen).

Idle hibernate. A running box a grant created hibernates once no client call (agent gRPC, the box MCP or a screen through the edge, or an Ensure) is open and none began or ended for oauth.idle_hibernate (default 30m; 0s turns it off). A call that only watches the box is none: Session.Events, and a box MCP GET (its event stream). The api checks every minute and starts the time again at each wake. The box keeps this after its grant ends.

Idle user boxes. A user’s own running box gets the same clock with user_idle_hibernate (default 0s, off; the hosts run 2h). Once it is idle that long, it stays up while its scheduled wake is on (SleepAt ahead), while its agent lists a turn RUNNING (Agents.List), or while cocoonbox-agent offload status, run by Session.Exec with the owner’s credential, lists a session working (an image without the command has none); an answer the api cannot get within 15 s keeps it up until the next check, and the next box is asked. Otherwise the api runs a live save as an operation and, if no client came meanwhile, hibernates the box; a failed save still hibernates it (the operation ends save failed; hibernating it), since the hibernation keeps the box’s memory and disk. Work no client, agent turn or offloaded session holds, such as a job left under nohup or tmux, is paused with the box after the threshold and goes on when the box wakes; it is not lost. Any client wakes it as before (Ensure, the app, the CLI, the control MCP). The app closes its Session.Screen while its window is hidden, minimized or covered and reopens it when shown, with take-over input as it was; a screen started or reopened while its window is hidden opens once the window shows. The share page does the same while its tab is hidden, a tab hidden from the start included, so a screen open in the background keeps no box up.

Storage: the users database, migrations in api/oauth/migrations tracked in oauth_schema_migrations: oauth_key, oauth_codes, oauth_grants, oauth_tokens, oauth_boxes (which grant created which box; it goes with the box row or the grant). The agent mark is boxes.agent and stays after the grant ends, so the box keeps its exclusions and idle hibernate and stays outside max_boxes.

Flow library (design §28)

Each user’s saved flows (gui_plan flows and scripts in /workspace/flows) live in one library in the api, and every cbcore box of the user syncs it.

Flow: a file name and its UTF-8 content. The name is [A-Za-z0-9][A-Za-z0-9._-]*, at most 96 characters, ending in .json or .mts. Limits: at most 128 KiB per flow, 200 flows and 3 MiB per user. The library keeps per flow rev (the library rev of its last write; the library rev grows with every write and delete), sha256 (hex, of the content), size, saved_at_ms (api time), box_id and image (the box that wrote it last and the image it ran; empty box_id for a Save, whose image is the one the request names). A write whose content equals the stored one changes nothing. Otherwise the last write wins.

Storage: the state store, <uid>/flows/index and <uid>/flows/<object id>. Each object is sealed with AES-256-GCM under HKDF-SHA256(user state key, "cocoonbox flows"), with additional data flows|<uid>|index or flows|<uid>|<object id>. Removing <uid>/ removes the library.

Box door (connect.box_listen, the door of CONNECT_URL, authenticated by the machine’s door key exactly as /mcp; a request without a current key gets 401 with no body). Every request acts on the library of the machine’s owner.

Request Answer
GET /flows 200 {"rev": N, "flows": [{"name", "rev", "sha256", "size", "saved_at_ms", "box_id"?, "image"?}]}, newest first
GET /flows/{name} 200 with the content (text/plain; charset=utf-8, X-Flow-Rev, X-Flow-Sha256); 404 for a missing flow
PUT /flows/{name} (body: the content) 200 with the stored entry, box_id and image taken from the machine; 400 for a bad name or content that is not UTF-8; 413 for a flow or library over its limits

cocoonbox.control.v1.Flows (proto/cocoonbox/control/v1/flows.proto, user token; a share secret is no gRPC token at the edge):

Agent (cbcore with CONNECT_URL only):

CLI (cocoonbox flows, owner token like every command):

Exit codes: 0 on success, 1 on a failure.

Key vault

Each user’s own API keys, sealed in the api and put on every box of the user as egress credentials (api/vault). A value goes in once and never comes back out: no answer, page, log or audit row carries it.

cocoonbox.control.v1.Keys (proto/cocoonbox/control/v1/keys.proto, user token):

On the boxes: each key is a host-only claim env entry CBV_<id> (guest: false) with the rendered value, its hosts and its header or query, gated by the placeholder: sandboxd replaces the placeholder with the value on intercepted requests to those hosts only. The placeholder keeps a vendor prefix of the value (such as sk-proj- or ghp_), then cbv_ and 32 hex digits. The guest sees only COCOONBOX_VAULT (see Box API keys). The api pushes a change to every machine of the owner at once, and every incarnation gets the full set when it is provisioned.

Console (JSON, console session): GET /cb/api/keys, POST /cb/api/keys, PUT /cb/api/keys/{name} ({value}), DELETE /cb/api/keys/{name}; the page is /cb/keys. CLI: cocoonbox keys add NAME --host HOST... [--header H] [--template T | --query P] (the value from stdin or a prompt), ls, rotate NAME, rm NAME, each with --json.

Storage: the users database, migrations in api/vault/migrations: vault_secrets (each value sealed with AES-256-GCM under HKDF-SHA256(user state key, "cocoonbox vault value"), additional data vault_secrets|<id>|<owner>|value) and vault_audit (create, rotate, delete, with the actor app or console, kept 90 days). Both go with the user row.

Connect (design §16)

The api’s broker (api/connect) holds the user’s grants on external accounts and serves their MCP tools. No message, page, log or audit row carries an access token, a refresh token or an API key, and no door reads a grant back.

cocoonbox.control.v1.Connectors (proto/cocoonbox/control/v1/connectors.proto, user token):

Console flow (replaces the loopback of design §16.2):

  1. The signed-in page calls POST /cb/api/connect/{connector}/begin (JSON, console session). For custom the body carries name (the connector id, ^[a-z][a-z0-9-]{0,23}$, not a catalog id), url (https), and optionally header (default Authorization) and key.
  2. With a key, the api lists the server’s tools with it, stores the grant and answers {"connected": "<id>"}. Without one it stores a flow (random state, PKCE S256 verifier, 10 min, one use) holding the user, the SHA-256 of the console session secret and the SHA-256 of a random 32-byte nonce. It sets the cookie cb_connect to the nonce (Path=/cocoonbox.connect/, HttpOnly, Secure, SameSite=Lax, Max-Age=600) and answers {"url": <vendor authorization URL>}. The session secret stays in cb_session (SameSite=Strict, Path=/cb/), which never reaches the callback.
  3. The vendor redirects to GET /cocoonbox.connect/callback?code&state. The api clears cb_connect and completes only when the SHA-256 of that 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. A refused nonce leaves the flow for its own browser. For a flow whose authorization server came from MCP discovery, the callback’s iss (RFC 9207) must name that server (its metadata issuer when on the same origin, else the URL the resource metadata named; a trailing / aside), and a server whose metadata sets authorization_response_iss_parameter_supported must send it; otherwise the flow is spent and refused before any token request, so a code another server answered never reaches this server’s token endpoint. It then exchanges the code (with the verifier, and resource for MCP authorization) and stores the grant. The browser goes to /cb/connect?connected=<id>, or ?error=<message> on any refusal. A GitHub App install or repository change without a state goes to ?installed=github.

Authorization requests:

Console calls (JSON, console session): GET /cb/api/connect (catalog, connectors with tools, pending approvals); POST /cb/api/connect/{connector}/begin; PUT /cb/api/connect/{connector}/policy {tool, policy: allow|ask|off|default}; DELETE /cb/api/connect/{connector} ({"revoked"}); GET /cb/api/connect/activity[?connector=]; POST /cb/api/connect/approvals/{id}/answer {decision: once|always|deny}; POST /cb/api/connect/{connector}/check ({"account"}: the grant, refreshed when it expires within 5 min and once more after a 401, reads the vendor’s user endpoint; a second 401 sets needs_reconnect). The page is /cb/connect.

Mode B door: https://<endpoint>/cocoonbox.mcp/connect, Authorization: Bearer <user token> (401 with no body otherwise), on the edge’s MCP listener like /cocoonbox.mcp/control.

Box door (mode A, design §16.3; the same handler with door = box): http://10.203.0.1:18091/mcp, served on connect.box_listen on the host bridge and not routed by Caddy.

An ask tool on the box door needs an approval:

Injection (M5.2): a grant whose connector injects puts guest: false claim env entries with inject on every machine of its owner. On an intercepted request to one of the hosts that carries the placeholder, sandboxd replaces the header with the value. The guest holds only the placeholders. Only pools that intercept (v23 and later) accept the entries.

Connector Entry Hosts Value The guest sends
github GH_GIT github.com Basic base64(x-access-token:<token>) Authorization: Basic @GH_GIT@: git, from /run/cocoonbox-github.gitconfig
github GH_API api.github.com token <token> Authorization: token @GH_API@: gh, with GH_TOKEN=@GH_API@ from the /usr/local/bin/gh wrapper
google-calendar GOOGLE_CALENDAR www.googleapis.com Bearer <token> Authorization: Bearer @GOOGLE_CALENDAR@, in $GOOGLE_OAUTH_ACCESS_TOKEN
link LINK_API api.link.com Bearer <token> Authorization: Bearer cbc_link: link-cli, with LINK_ACCESS_TOKEN=cbc_link

Refresh: lazy, when less than 5 min of the access token remain, or once after a 401. It is single-flight per grant: in-process, and across replicas by the grant’s row lock, re-checked under the lock. The rotated refresh token is committed before the new access token is used. invalid_grant (also GitHub’s bad_refresh_token), or a 401 after a refresh, sets needs_reconnect.

Audit (connect_audit, kept 90 days): time, user, connector, tool, door, caller (mcp:<clientInfo.name>, box:…, or the door for a disconnect), decision (allow, approved, denied, off, approval_required, needs_reconnect, disconnect), outcome (ok, error, outcome_unknown), upstream status, latency, request and response bytes, and the arguments’ hash. Never arguments or results.

Storage: PostgreSQL, the users database, migrations in api/connect/migrations tracked in connect_schema_migrations. Each grant’s secrets are sealed with AES-256-GCM under HKDF-SHA256(user state key, "cocoonbox connect grant"), additional data connect_grants|<id>|<user>|<connector> (flows: connect_flows|…). The state key is the one users.state_key_sealed holds under the api master key.

Outbound: the broker dials only public addresses (no loopback, private, link-local or 100.64/10), so a custom URL cannot reach the host.

Configuration: connect.public_url in api.yaml (an https origin; empty turns Connect off) and connect.box_listen (the box door’s ip:port on the host bridge, 10.203.0.1:18091; empty turns the box door off). The OAuth clients come from the environment: GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRET, GITHUB_APP_SLUG, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, HUBSPOT_CLIENT_ID, HUBSPOT_CLIENT_SECRET, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET.