Files
captoys/toybox
dckcandClaude Opus 5.5 9356f63c64 capsite/toybox: OCapN vats; createVat takes remote objects
The admin socket now also speaks OCapN over Cap'n Web: a WebSocket there
reaches the VatAdmin DO, an OCapN peer whose bootstrap (swissnum
vatAdmin) offers createVat(src, params). params may hold remote objects;
the vat's root comes back as one.

Each OCapN vat is its own peer: SES and @endo/ocapn run in its facet
(vat-runtime.js, bundled with esbuild), around the vat's own module,
which exports buildRootObject(vatPowers, params). Passing a client's
object to a vat, or a vat's root to the client, is an OCapN handoff, so
the receiver dials the exporter. Nothing here can dial out, so the
VatAdmin is also the router: a client registers its gateway under a
designator, a vat's designator is its id, and every peer reaches every
other through it. The netlayer is ertp-clerk's, made to work over Workers
RPC stubs and plain promises as well as Cap'n Web.

Not yet: sturdyrefs that survive a restart, an adminNode, and grants
(egress, mount) for OCapN vats. App vats (export class App) still work
beside them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 21:44:29 -05:00
..

toybox

Vats whose code is installed at runtime: each vat is a Durable Object whose app runs as a facet on code loaded through workerd's Worker Loader. Think vatAdmin.createVat(bundle), with a supervisor per vat. Vats have no names: creating one returns its id, which addresses it only on the admin socket. Everything else a vat can do or be reached by is granted explicitly: egress by labels it may fetch within, ingress by labels it is mounted at.

  • supervisor.js: Vat stores code by SHA-256 and runs the app on it; Directory holds the mounts; Public (the ingress) and Admin are the two entrypoints; Egress is an egress grant.
  • switchboard.js: a separate workerd process, the vats' only way out, after Flower's switchboard: each of its sockets serves a Cap'n Web Handle with fixed labels, and constrain(labels) returns a narrower one.
  • labels.js: Flower's label rule, shared by both: labels only accumulate, a missing key matches anything, and a key both sides set must agree. Ingress lookup is stricter (below).
  • config.capnp: the sockets have no addresses; whoever starts workerd hands them in, so holding a socket is the authority it grants.
socket reaches what its holder can do
http Public reach the vat mounted at labels the request carries
admin Admin POST / with an ES module body: create a vat and start it; the reply is {"vat": <id>, "hash": <code hash>}. PUT /<id> with a body: upgrade it; its storage carries over. PUT /by-name/<name>: the same, for a vat from before ids.

Every start states the vat's grants, as JSON labels in query parameters; absent means none:

  • ?egress=<labels>: what the app may fetch (below).
  • ?mount=<labels>: where the ingress finds it. A mount must conflict with every other one (some shared key, different values), so no request can carry two mounts' labels; an overlapping mount is a 409.

Ingress

A request's labels are its query parameters, plus a Toybox-Labels header (JSON) for secret ones. A URL ends up in logs and Referers, so a secret label belongs in the header: a webkey page keeps the secret in its fragment and sends it in-band. A key given twice is a 400.

The request reaches the vat whose mount labels it carries, every one with the same value. That is stricter than Flower's match (no conflicting key): an ingress client holds no constrained handle, so a label it omits must not match, or a secret label would protect nothing. The path is the app's own. The app sees the mount's and the request's labels together in Toybox-Connection-Labels (JSON), as a Flower server gets its connection labels. Responses carry Referrer-Policy: no-referrer.

Egress

An app must export class App extends DurableObject (see apps/). Its global fetch always throws: nothing in a vat is ambient. A grant arrives as env.egress, an Egress with the labels in ctx.props. Its fetch, WebSocket upgrades included, goes through a switchboard handle constrained to those labels; the switchboard refuses the rest, which comes back as a 403. env.egress.constrain(labels) returns a narrower Egress. It is an object, so the app can pass it to whichever of its parts should have it.

label constrains
scheme http or https
host host name or address, no port
port explicit, or the scheme's default
network set by the switchboard socket: public or local

The switchboard has two sockets, so two root handles: public (no loopback or private addresses) and local. toybox holds one, as an external service (-e switchboard=unix:…): pnpm start and the systemd units give it public; the tests give theirs local to reach an echo server.

Run it

pnpm install
pnpm test         # real workerds: createVat, upgrade, mounts, secret labels, egress grants and constrain, fetch and WebSocket out, crash and restart
pnpm switchboard  # systemd-socket-activate on ./switchboard-public.sock and ./switchboard-local.sock
pnpm start        # likewise on 127.0.0.1:8787 and ./admin.sock; state in ./state

Then, in a third terminal:

admin() { curl -sg --unix-socket admin.sock "$@"; }
alice=$(admin -X POST --data-binary @apps/counter-v1.js 'http://toybox/?mount={"vat":"alice"}' | jq -r .vat)
curl 'localhost:8787/?vat=alice'          # v1 count 1
admin -X PUT --data-binary @apps/counter-v2.js "http://toybox/$alice?mount={\"vat\":\"alice\"}"
curl 'localhost:8787/?vat=alice'          # v2 count 2

admin -X POST --data-binary @apps/egress-probe.js 'http://toybox/?mount={"vat":"erin"}&egress={"host":"example.com"}'
curl 'localhost:8787/?vat=erin&url=https://example.com/'   # got: <!doctype html>…
curl 'localhost:8787/?vat=erin&url=https://example.org/'   # got: egress refused: egress to example.org not granted

Under systemd

systemd/ has socket and service pairs for toybox and its switchboard, assuming this directory is installed at /opt/toybox. toybox gets no network of its own (PrivateNetwork=yes): only the two sockets it is handed and the switchboard's public socket, which only members of the toybox group can open. The switchboard binds its local socket in its own 0700 runtime directory, so no other unit holds that handle.

Neither unit has been run under the system manager yet.

PrivateNetwork= needs the system manager; as a systemd-run --user unit, systemd logs "lack privileges for network namespace, proceeding without".