From 2820c015ee8374da50491d09f25d3cc6b70026b6 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Tue, 13 Oct 2026 20:18:39 -0500 Subject: [PATCH] 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) --- toybox/README.md | 4 ++-- toybox/apps/egress-probe.js | 8 +++++--- toybox/supervisor.js | 12 ++++++------ toybox/test/vat.test.js | 9 +++++++-- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/toybox/README.md b/toybox/README.md index c9b59e6..d7511f0 100644 --- a/toybox/README.md +++ b/toybox/README.md @@ -2,7 +2,7 @@ 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 vat's `fetch`. +- [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. @@ -11,7 +11,7 @@ Vats whose code is installed at runtime: each vat is a Durable Object whose app | `http` | `Public` | `GET //…`: talk to a vat's app | | `admin` | `Admin` | `PUT /` with an ES module body: install it and (re)start the app; its storage carries over. `?egress=` grants the app that one host. | -An app must export `class App extends DurableObject` (see [apps/](apps/)). It gets an empty `env`. With no grant, `fetch` throws; with one, its `globalOutbound` is `Egress` with the granted host in `ctx.props`, which forwards matching requests, WebSocket upgrades included, to the switchboard and refuses the rest with a 403. +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. diff --git a/toybox/apps/egress-probe.js b/toybox/apps/egress-probe.js index 0547e3d..3c09f5e 100644 --- a/toybox/apps/egress-probe.js +++ b/toybox/apps/egress-probe.js @@ -1,13 +1,15 @@ import { DurableObject } from "cloudflare:workers"; -/** `?url=` fetches it; with `&ws=1`, opens a WebSocket there and reports the echo of "hello". */ +/** `?url=` 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 fetch(target)).text()}\n`); - const resp = await fetch(target, { headers: { Upgrade: "websocket" } }); + 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(); diff --git a/toybox/supervisor.js b/toybox/supervisor.js index 48dc8b4..527b41a 100644 --- a/toybox/supervisor.js +++ b/toybox/supervisor.js @@ -7,8 +7,8 @@ const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart * 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 gets an empty `env`; its `fetch` reaches only the host it - * was granted, if any, through {@link Egress}. + * 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. @@ -47,8 +47,8 @@ export class Vat extends DurableObject { compatibilityDate: "2026-10-01", mainModule: "app.js", modules: { "app.js": src }, - env: {}, - globalOutbound: egress ? this.ctx.exports.Egress({ props: { host: egress } }) : null, + env: egress ? { egress: this.ctx.exports.Egress({ props: { host: egress } }) } : {}, + globalOutbound: null, })); return { class: worker.getDurableObjectClass("App") }; }); @@ -67,8 +67,8 @@ export class Vat extends DurableObject { } /** - * A vat's `globalOutbound`: its `fetch`, WebSocket upgrades included, reaches - * the switchboard only for the host in `ctx.props`, which the vat can't set. + * 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) { diff --git a/toybox/test/vat.test.js b/toybox/test/vat.test.js index 0e18b04..1811d23 100644 --- a/toybox/test/vat.test.js +++ b/toybox/test/vat.test.js @@ -167,9 +167,14 @@ test("a vat granted a host reaches it through the switchboard: fetch and WebSock assert.equal(await probe("erin", echo.host, true), "echo: hello\n"); }); -test("same code, no grant: no fetch", async () => { +test("same code, no grant: no egress", async () => { await admin.install("frank", await app("egress-probe.js")); - assert.match(await probe("frank", echo.host), /^refused: This worker is not permitted to access the internet/); + 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 () => {