Compare commits

..
3 Commits
Author SHA1 Message Date
dckcandClaude Opus 5.5 2820c015ee capsite/toybox: a grant is an object in env, not the vat's global fetch
A vat's globalOutbound is now always null. A granted host arrives as
env.egress, an Egress the app can hand to whichever of its parts should
have it; as globalOutbound it was ambient to every module in the vat.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 21:44:29 -05:00
dckcandClaude Opus 5.5 7bf6eb44c7 capsite/toybox: a switchboard, the vats' only way out
A vat granted a host (PUT /<vat>?egress=<host> on the admin socket) gets
Egress as its globalOutbound, with the host in ctx.props where the vat
can't change it. Egress forwards matching requests, WebSocket upgrades
included, to the switchboard and refuses the rest with a 403; with no
grant, fetch still throws.

The switchboard is a separate workerd: a Switchboard DO that calls fetch
and returns the response, so a 101's webSocket passes through. toybox
reaches it over a unix socket in HTTP proxy style, which keeps the full
URL, scheme included. Its network service is the outer fence, with TLS
trusting the browser CAs; systemd units for it join toybox's, and only
the toybox group can open its socket.

The loaded Worker is now named by vat and incarnation, not by code hash:
the same code with a different grant must not share a Worker. The new
incarnation table sits beside the old ones; vats installed before it
read as incarnation 0 with no grant.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 21:44:29 -05:00
dckcandClaude Opus 5.5 88739d9495 capsite/toybox: vats with code installed at runtime, on workerd
Each vat is a Durable Object that stores code by hash and runs it as a
facet through the Worker Loader: an empty env, no fetch, storage that
carries over upgrades. Two sockets with no configured address: whoever
starts workerd hands them in (systemd socket activation), so the admin
socket is the authority to install code.

Tests run a real workerd: install, upgrade, isolation between vats, no
fetch, the public port can't install, and code and state survive a crash.
They spawn the package binary rather than the .bin shim, which forks
workerd, so killing the shim orphaned it and the test run never ended.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 21:44:29 -05:00
17 changed files with 636 additions and 0 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
state/
admin.sock
switchboard.sock
+46
View File
@@ -0,0 +1,46 @@
# toybox
Vats whose code is installed at runtime: each vat is a Durable Object whose app runs as a [facet](https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/) on code loaded through workerd's Worker Loader. Think `vatAdmin.createVat(bundle)`, with a supervisor per vat.
- [supervisor.js](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](switchboard.js): a separate workerd process, the vats' only way out: a forward proxy for HTTP and WebSockets.
- [config.capnp](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=<host>` grants the app that one host. |
An app must export `class App extends DurableObject` (see [apps/](apps/)). Its global `fetch` always throws: nothing in a vat is ambient. A granted host arrives as `env.egress`, an `Egress` with the host in `ctx.props`: its `fetch` forwards matching requests, WebSocket upgrades included, to the switchboard and refuses the rest with a 403. It is an object, so the app can pass it to whichever of its parts should have it.
toybox reaches the switchboard over a unix socket (`-e switchboard=unix:…`), in HTTP proxy style so the full URL, scheme included, survives the hop.
## Run it
```sh
pnpm install
pnpm test # real workerds: install, upgrade, isolation, egress grants, fetch and WebSocket out, crash and restart
pnpm switchboard # systemd-socket-activate on ./switchboard.sock
pnpm start # likewise on 127.0.0.1:8787 and ./admin.sock; state in ./state
```
Then, in a third terminal:
```sh
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 -X PUT --unix-socket admin.sock --data-binary @apps/egress-probe.js 'http://toybox/erin?egress=example.com'
curl 'localhost:8787/erin?url=https://example.com/' # got: <!doctype html>…
curl 'localhost:8787/erin?url=https://example.org/' # got: egress to example.org not granted
```
## Under systemd
[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 socket, which only members of the `toybox` group can open. The switchboard has the network, fenced by `IPAddressDeny=`; `switchboard.capnp` allows `local` addresses only so the tests can reach a local echo server.
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".
+8
View File
@@ -0,0 +1,8 @@
import { DurableObject } from "cloudflare:workers";
export class App extends DurableObject {
fetch() {
const n = (this.ctx.storage.kv.get("n") ?? 0) + 1;
this.ctx.storage.kv.put("n", n);
return new Response(`v1 count ${n}\n`);
}
}
+8
View File
@@ -0,0 +1,8 @@
import { DurableObject } from "cloudflare:workers";
export class App extends DurableObject {
fetch() {
const n = (this.ctx.storage.kv.get("n") ?? 0) + 1;
this.ctx.storage.kv.put("n", n);
return new Response(`v2 count ${n}\n`);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { DurableObject } from "cloudflare:workers";
/** `?url=<target>` fetches it through `env.egress`; with `&ws=1`, opens a WebSocket there and reports the echo of "hello". */
export class App extends DurableObject {
async fetch(request) {
const params = new URL(request.url).searchParams;
const target = params.get("url");
const { egress } = this.env;
if (!egress) return new Response("no egress granted\n");
try {
if (!params.has("ws")) return new Response(`got: ${await (await egress.fetch(target)).text()}\n`);
const resp = await egress.fetch(target, { headers: { Upgrade: "websocket" } });
const ws = resp.webSocket;
if (!ws) return new Response(`no websocket: ${resp.status} ${await resp.text()}`);
ws.accept();
const reply = new Promise((resolve) => ws.addEventListener("message", (e) => resolve(e.data)));
ws.send("hello");
const got = await reply;
ws.close();
return new Response(`echo: ${got}\n`);
} catch (e) {
return new Response(`refused: ${e.message}\n`);
}
}
}
+7
View File
@@ -0,0 +1,7 @@
import { DurableObject } from "cloudflare:workers";
export class App extends DurableObject {
async fetch() {
try { await fetch("https://example.com/"); return new Response("fetch ALLOWED\n"); }
catch (e) { return new Response(`fetch refused: ${e.message}\n`); }
}
}
+36
View File
@@ -0,0 +1,36 @@
# toybox: vats whose code is installed at runtime, each a Durable Object facet.
#
# Sockets have no address here: whoever starts workerd hands them in
# (-S name=fd from systemd, or -s name=addr), so holding a socket is the
# authority it grants. `state` is likewise overridden with -d state=<dir>,
# and the switchboard's address with -e switchboard=<addr>.
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [
(name = "main", worker = .supervisor),
(name = "nowhere", network = (allow = [])),
# the way out: see switchboard.capnp
(name = "switchboard", external = (address = "unix:switchboard.sock", http = (style = proxy))),
(name = "state", disk = (path = "state", writable = true)),
],
sockets = [
# reach a running vat by name
(name = "http", http = (), service = (name = "main", entrypoint = "Public")),
# install code into a vat and (re)start it
(name = "admin", http = (), service = (name = "main", entrypoint = "Admin")),
],
);
const supervisor :Workerd.Worker = (
compatibilityDate = "2026-10-01",
modules = [(name = "supervisor.js", esModule = embed "supervisor.js")],
durableObjectNamespaces = [(className = "Vat", uniqueKey = "toybox-vat", enableSql = true)],
durableObjectStorage = (localDisk = "state"),
bindings = [
(name = "VATS", durableObjectNamespace = "Vat"),
(name = "LOADER", workerLoader = ()),
(name = "SWITCHBOARD", service = "switchboard"),
],
globalOutbound = "nowhere",
);
+15
View File
@@ -0,0 +1,15 @@
{
"name": "toybox",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"start": "mkdir -p state && rm -f admin.sock && systemd-socket-activate -l 127.0.0.1:8787 -l ./admin.sock -- node_modules/workerd/bin/workerd serve --experimental config.capnp -S http=3 -S admin=4 -d state=state -e switchboard=unix:./switchboard.sock",
"switchboard": "rm -f switchboard.sock && systemd-socket-activate -l ./switchboard.sock -- node_modules/workerd/bin/workerd serve --experimental switchboard.capnp -S http=3",
"test": "node --test"
},
"devDependencies": {
"workerd": "1.20261006.1",
"ws": "8.22.0"
}
}
+92
View File
@@ -0,0 +1,92 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
workerd:
specifier: 1.20261006.1
version: 1.20261006.1
ws:
specifier: 8.22.0
version: 8.22.0
packages:
'@cloudflare/workerd-darwin-64@1.20261006.1':
resolution: {integrity: sha512-lCX6avJO2It9AyDEYitjmOtJRVp/xmaW0TziyoLAnfxlzozyyRbSFfIIMMCiDx/sTP1++E4VLbV+7BHSD33f0Q==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20261006.1':
resolution: {integrity: sha512-PyFGjOaldtppnLuKsdkoYWXfLVpDjuf8u+uoykX+1Xvxo2EPmCVViq0iDKm+U7c7PRno85ehS3/MPVXyk2HwHw==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20261006.1':
resolution: {integrity: sha512-Ntf9S8eesmVufdjaT2kzWlz6Ng9XGYTc6cHQVRKAg59lFufwsIabGeMT6U4KkrpEeSIxgHq8aPtuLbZXE19tCA==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20261006.1':
resolution: {integrity: sha512-JyeDBymVWw5tVRebSsVE8ojDYxd6UlZrAseHU8G+NpIyd5wF61tXlF/axLeCUqu0pOhBFHb8I5QKi0jwrCK41Q==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20261006.1':
resolution: {integrity: sha512-nWP0URNqOBN9fa/gZ7eh/w051DwjOXN8j4lDZDYVXyQPsux6sV3DfyYZT+CjR/rt9nKBSmJT9QE9/bWT18+84w==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
workerd@1.20261006.1:
resolution: {integrity: sha512-8MadBuffZ5NdpLwZjUmsqT5QlaKXq4gjgT6wzcKeynZTICNXyePhc+5QVoKiK7+MMhFTLNrRO2KXLlpk8Q6BkA==}
engines: {node: '>=16'}
hasBin: true
ws@8.22.0:
resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
snapshots:
'@cloudflare/workerd-darwin-64@1.20261006.1':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20261006.1':
optional: true
'@cloudflare/workerd-linux-64@1.20261006.1':
optional: true
'@cloudflare/workerd-linux-arm64@1.20261006.1':
optional: true
'@cloudflare/workerd-windows-64@1.20261006.1':
optional: true
workerd@1.20261006.1:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20261006.1
'@cloudflare/workerd-darwin-arm64': 1.20261006.1
'@cloudflare/workerd-linux-64': 1.20261006.1
'@cloudflare/workerd-linux-arm64': 1.20261006.1
'@cloudflare/workerd-windows-64': 1.20261006.1
ws@8.22.0: {}
+102
View File
@@ -0,0 +1,102 @@
/** @file toybox supervisor: one Vat per name; its app runs as a facet on installed code. */
import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
/**
* Holds the vat's installed code and egress grant; the app facet holds the app's state.
*
* Code is stored by hash so the facet can be rebuilt after hibernation or a
* restart. The app's global `fetch` throws; a granted host arrives as
* `env.egress`, an {@link Egress} the app can pass along like any object.
*
* Each `start` begins a new incarnation, which names the loaded Worker: the
* same code with a different grant must not share a Worker.
*/
export class Vat extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS bundle (hash TEXT PRIMARY KEY, src TEXT)");
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS current (k INTEGER PRIMARY KEY CHECK (k = 0), hash TEXT)");
ctx.storage.sql.exec(
"CREATE TABLE IF NOT EXISTS incarnation (k INTEGER PRIMARY KEY CHECK (k = 0), n INTEGER, egress TEXT)",
);
}
/**
* Install `src` and run the app on it; the app's storage carries over.
*
* @param {string} src an ES module exporting `class App extends DurableObject`
* @param {string | null} egress the one host (`name[:port]`) the app may fetch, or none
* @returns {Promise<string>} the code's SHA-256, hex
*/
async start(src, egress) {
const hash = hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(src)));
const { sql } = this.ctx.storage;
sql.exec("INSERT OR IGNORE INTO bundle VALUES (?, ?)", hash, src);
sql.exec("INSERT OR REPLACE INTO current VALUES (0, ?)", hash);
const [prev] = sql.exec("SELECT n FROM incarnation").toArray();
sql.exec("INSERT OR REPLACE INTO incarnation VALUES (0, ?, ?)", (prev?.n ?? 0) + 1, egress);
this.ctx.facets.abort("app", new Error("upgraded"));
return hash;
}
#app({ hash, src, n, egress }) {
return this.ctx.facets.get("app", () => {
const worker = this.env.LOADER.get(`${this.ctx.id}:${n}`, () => ({
compatibilityDate: "2026-10-01",
mainModule: "app.js",
modules: { "app.js": src },
env: egress ? { egress: this.ctx.exports.Egress({ props: { host: egress } }) } : {},
globalOutbound: null,
}));
return { class: worker.getDurableObjectClass("App") };
});
}
fetch(request) {
const [row] = this.ctx.storage.sql
.exec(
`SELECT b.hash, b.src, coalesce(i.n, 0) AS n, i.egress
FROM current c JOIN bundle b USING (hash) LEFT JOIN incarnation i ON i.k = 0`,
)
.toArray();
if (!row) return new Response("no code installed\n", { status: 404 });
return this.#app(row).fetch(request);
}
}
/**
* A grant of one host: `fetch`, WebSocket upgrades included, reaches the
* switchboard only for the host in `ctx.props`, which no holder can change.
*/
export class Egress extends WorkerEntrypoint {
fetch(request) {
const { host } = new URL(request.url);
if (host !== this.ctx.props.host) return new Response(`egress to ${host} not granted\n`, { status: 403 });
return this.env.SWITCHBOARD.fetch(request);
}
}
const vatOf = (env, url) => env.VATS.getByName(new URL(url).pathname.split("/")[1]);
/** The http socket: `/<vat>/...` goes to that vat's app. */
export class Public extends WorkerEntrypoint {
fetch(request) {
return vatOf(this.env, request.url).fetch(request);
}
}
/**
* The admin socket: `PUT /<vat>` with an ES module body installs and starts
* it; `?egress=<host>` grants it that one host.
*/
export class Admin extends WorkerEntrypoint {
async fetch(request) {
if (request.method !== "PUT") return new Response("PUT code to /<vat>\n", { status: 405 });
const egress = new URL(request.url).searchParams.get("egress");
return new Response((await vatOf(this.env, request.url).start(await request.text(), egress)) + "\n");
}
}
export default Public;
+26
View File
@@ -0,0 +1,26 @@
# switchboard: toybox's only way out. A forward proxy (HTTP and WebSocket)
# that toybox's Egress gateway reaches over a socket handed in with -S/-s.
#
# The network service is the switchboard's outer fence; under systemd,
# IPAddressAllow=/IPAddressDeny= narrow it further (see systemd/).
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [
(name = "main", worker = .switchboard),
(name = "internet", network = (allow = ["public", "local"], tlsOptions = (trustBrowserCas = true))),
],
sockets = [
# proxy style: the request line carries the full URL, scheme included
(name = "http", http = (style = proxy), service = "main"),
],
);
const switchboard :Workerd.Worker = (
compatibilityDate = "2026-10-01",
modules = [(name = "switchboard.js", esModule = embed "switchboard.js")],
durableObjectNamespaces = [(className = "Switchboard", uniqueKey = "toybox-switchboard")],
durableObjectStorage = (inMemory = void),
bindings = [(name = "BOARD", durableObjectNamespace = "Switchboard")],
globalOutbound = "internet",
);
+22
View File
@@ -0,0 +1,22 @@
/** @file switchboard: forwards each proxied request, WebSocket upgrades included. */
import { DurableObject } from "cloudflare:workers";
/**
* The one place egress happens. A 101 response from `fetch` carries its
* `webSocket`; returning it passes the socket through to the caller.
*
* Which vat may reach which host is decided before a request gets here
* (toybox's Egress gateway); this process's network service and its
* systemd unit are the outer fence.
*/
export class Switchboard extends DurableObject {
fetch(request) {
return fetch(request);
}
}
export default {
fetch(request, env) {
return env.BOARD.getByName("switchboard").fetch(request);
},
};
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=toybox switchboard (workerd forward proxy)
Requires=toybox-switchboard.socket
[Service]
ExecStart=/opt/toybox/node_modules/workerd/bin/workerd serve --experimental /opt/toybox/switchboard.capnp -S http=3
DynamicUser=yes
# the outer fence: switchboard.capnp allows "local" for tests; deny it here
IPAddressDeny=localhost link-local multicast
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
SystemCallFilter=@system-service
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=toybox switchboard: the vats' only way out
[Socket]
ListenStream=/run/toybox/switchboard.sock
# only toybox may dial out: its service joins this group
SocketGroup=toybox
SocketMode=0660
[Install]
WantedBy=sockets.target
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=toybox vats (workerd)
Requires=toybox.socket toybox-switchboard.socket
After=toybox-switchboard.socket
[Service]
ExecStart=/opt/toybox/node_modules/workerd/bin/workerd serve --experimental /opt/toybox/config.capnp -S http=3 -S admin=4 -d state=/var/lib/toybox -e switchboard=unix:/run/toybox/switchboard.sock
DynamicUser=yes
# to open the switchboard socket
SupplementaryGroups=toybox
StateDirectory=toybox
# no network but the sockets handed in, and the switchboard's
PrivateNetwork=yes
RestrictAddressFamilies=AF_UNIX
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
SystemCallFilter=@system-service
# not MemoryDenyWriteExecute: V8's JIT needs W+X
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=toybox vats: public port and admin socket
[Socket]
# order sets the fds: http=3, admin=4 (see toybox.service)
ListenStream=127.0.0.1:8787
ListenStream=/run/toybox/admin.sock
# whoever can open admin.sock can install code
SocketUser=root
SocketMode=0600
[Install]
WantedBy=sockets.target
+190
View File
@@ -0,0 +1,190 @@
/** @file toybox end to end: a real workerd, an admin socket, a public port, a switchboard. */
import { spawn } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { createServer as createHttpServer, request } from "node:http";
import { connect, createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, test } from "node:test";
import assert from "node:assert/strict";
import { WebSocketServer } from "ws";
const here = new URL("..", import.meta.url).pathname;
// the package binary, not the .bin shim: the shim forks it, so a kill would orphan workerd
const workerd = join(here, "node_modules/workerd/bin/workerd");
const app = (name) => readFile(join(here, "apps", name), "utf8");
const freePort = () =>
new Promise((resolve) => {
const s = createServer().listen(0, "127.0.0.1", () => {
const { port } = s.address();
s.close(() => resolve(port));
});
});
/** Resolves once something accepts connections at `opts` (a port or a socket path). */
const accepting = (opts) =>
new Promise((resolve) => {
const s = connect(opts, () => (s.end(), resolve(true)));
s.on("error", () => resolve(false));
});
/** @param {string} socketPath the admin socket; holding it is the authority to install */
const makeAdmin = (socketPath) => ({
install: (vat, src, egress) =>
new Promise((resolve, reject) => {
const path = egress ? `/${vat}?egress=${encodeURIComponent(egress)}` : `/${vat}`;
const req = request({ socketPath, path, method: "PUT", agent: false }, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => resolve(body.trim()));
});
req.on("error", reject);
req.end(src);
}),
});
/** @param {string} base the public origin */
const makePublic = (base) => ({
get: async (path) => (await fetch(`${base}/${path}`)).text(),
put: async (vat, src) => (await fetch(`${base}/${vat}`, { method: "PUT", body: src })).status,
});
/**
* Run workerd on `config` until `stop()`; resolves once `listening` accepts.
*
* @param {string} config
* @param {string[]} args socket, directory, and external overrides
* @param {{ port: number } | { path: string }} listening
* @param {string[]} stale socket files a crash leaves behind; bind() would fail on them
*/
const startWorkerd = async (config, args, listening, stale = []) => {
for (const f of stale) await rm(f, { force: true });
const child = spawn(workerd, ["serve", "--experimental", join(here, config), ...args], {
stdio: ["ignore", "ignore", "pipe"],
});
let log = "";
child.stderr.on("data", (c) => (log += c));
const exited = new Promise((resolve) => child.on("exit", resolve));
for (let i = 0; i < 100; i++) {
// SIGKILL: a crash is the harder durability test
if (await accepting(listening)) return { stop: () => (child.kill("SIGKILL"), exited) };
await new Promise((r) => setTimeout(r, 50));
}
child.kill("SIGKILL");
throw new Error(`workerd did not start: ${log}`);
};
/** An HTTP server that answers GET with "plain ok" and echoes WebSocket messages. */
const startEcho = async () => {
const server = createHttpServer((_req, res) => res.end("plain ok"));
const wss = new WebSocketServer({ server });
wss.on("connection", (ws) => ws.on("message", (m) => ws.send(String(m))));
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
return {
host: `127.0.0.1:${server.address().port}`,
stop: () => new Promise((resolve) => (wss.close(), server.closeAllConnections(), server.close(resolve))),
};
};
let dir, port, server, switchboard, echo, admin, pub;
const startToybox = () =>
startWorkerd(
"config.capnp",
[
"-s", `http=127.0.0.1:${port}`, "-s", `admin=unix:${join(dir, "admin.sock")}`,
"-d", `state=${join(dir, "state")}`, "-e", `switchboard=unix:${join(dir, "switchboard.sock")}`,
],
{ port, host: "127.0.0.1" },
[join(dir, "admin.sock")],
);
before(async () => {
dir = await mkdtemp(join(tmpdir(), "toybox-"));
await mkdir(join(dir, "state"));
port = await freePort();
echo = await startEcho();
switchboard = await startWorkerd(
"switchboard.capnp",
["-s", `http=unix:${join(dir, "switchboard.sock")}`],
{ path: join(dir, "switchboard.sock") },
[join(dir, "switchboard.sock")],
);
server = await startToybox();
admin = makeAdmin(join(dir, "admin.sock"));
pub = makePublic(`http://127.0.0.1:${port}`);
});
after(async () => {
await server?.stop();
await switchboard?.stop();
await echo?.stop();
await rm(dir, { recursive: true, force: true });
});
test("a vat with no code is 404", async () => {
assert.equal(await pub.get("nobody"), "no code installed\n");
});
test("install, run, upgrade: the app's state carries over", async () => {
assert.match(await admin.install("alice", await app("counter-v1.js")), /^[0-9a-f]{64}$/);
assert.equal(await pub.get("alice"), "v1 count 1\n");
assert.equal(await pub.get("alice"), "v1 count 2\n");
await admin.install("alice", await app("counter-v2.js"));
assert.equal(await pub.get("alice"), "v2 count 3\n");
});
test("vats don't share state", async () => {
await admin.install("carol", await app("counter-v1.js"));
assert.equal(await pub.get("carol"), "v1 count 1\n");
});
test("an app gets no fetch", async () => {
await admin.install("bob", await app("try-fetch.js"));
assert.match(await pub.get("bob"), /^fetch refused: This worker is not permitted to access the internet/);
});
test("the public port can't install code", async () => {
assert.equal(await pub.put("mallory", "export class App {}"), 404);
assert.equal(await pub.get("mallory"), "no code installed\n");
});
test("code and state survive a restart", async () => {
await admin.install("dave", await app("counter-v1.js"));
assert.equal(await pub.get("dave"), "v1 count 1\n");
await server.stop();
server = await startToybox();
assert.equal(await pub.get("dave"), "v1 count 2\n");
});
const probe = (vat, target, ws) =>
pub.get(`${vat}?url=${encodeURIComponent(`http://${target}/`)}${ws ? "&ws=1" : ""}`);
test("a vat granted a host reaches it through the switchboard: fetch and WebSocket", async () => {
await admin.install("erin", await app("egress-probe.js"), echo.host);
assert.equal(await probe("erin", echo.host), "got: plain ok\n");
assert.equal(await probe("erin", echo.host, true), "echo: hello\n");
});
test("same code, no grant: no egress", async () => {
await admin.install("frank", await app("egress-probe.js"));
assert.equal(await probe("frank", echo.host), "no egress granted\n");
});
test("a grant is an object, not ambient: the global fetch still throws", async () => {
await admin.install("heidi", await app("try-fetch.js"), echo.host);
assert.match(await pub.get("heidi"), /^fetch refused: This worker is not permitted to access the internet/);
});
test("a grant covers one host only", async () => {
await admin.install("grace", await app("egress-probe.js"), "127.0.0.1:1");
assert.equal(await probe("grace", echo.host), `got: egress to ${echo.host} not granted\n\n`);
assert.match(await probe("grace", echo.host, true), /^no websocket: 403 egress to .* not granted/);
});
test("a grant survives a restart", async () => {
await server.stop();
server = await startToybox();
assert.equal(await probe("erin", echo.host, true), "echo: hello\n");
});