Files
captoys/toybox
dckcandClaude Opus 5.5 f0557d29d6 capsite/toybox: egress through Flower-style switchboard handles
The switchboard now serves a Cap'n Web Handle on each of its sockets:
public (no loopback or private addresses) and local. A socket's labels
are fixed in config, so holding the socket's fd is holding that handle;
constrain(labels) returns a narrower one, as Flower's Constrain returns
a narrower fd. Labels only accumulate: a missing key matches anything,
and a key both sides set must agree (labels.js, shared by both).

The switchboard enforces the labels; toybox's Egress no longer checks
URLs itself. It opens a session to its one switchboard socket, constrains
the handle to the vat's grant, and forwards through it; a WebSocket
crosses the session as a pair of message streams. A vat narrows its own
grant with env.egress.constrain().

Grants are now labels as JSON. One stored before, a bare host[:port],
reads as {host, port}.

pnpm start and the systemd units give toybox the public handle; the
switchboard unit binds the local one in its own 0700 runtime directory.
The tests give their toybox the local handle to reach an echo server,
and check that a toybox holding only the public one never reaches it.

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.

  • supervisor.js: Vat stores code by SHA-256 and runs the app on it; Public and Admin are the two entrypoints; Egress is a grant of one host.
  • 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.
  • 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 GET /<vat>/…: talk to a vat's app
admin Admin PUT /<vat> with an ES module body: install it and (re)start the app; its storage carries over. ?egress=<labels as JSON> grants the app egress within those labels.

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: install, upgrade, isolation, 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:

curl -X PUT --unix-socket admin.sock --data-binary @apps/counter-v1.js http://toybox/alice
curl localhost:8787/alice                # v1 count 1
curl -X PUT --unix-socket admin.sock --data-binary @apps/counter-v2.js http://toybox/alice
curl localhost:8787/alice                # v2 count 2

curl -g -X PUT --unix-socket admin.sock --data-binary @apps/egress-probe.js 'http://toybox/erin?egress={"host":"example.com"}'
curl 'localhost:8787/erin?url=https://example.com/'   # got: <!doctype html>…
curl 'localhost:8787/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".