@botiverse/porthole 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/DESIGN.md ADDED
@@ -0,0 +1,1067 @@
1
+ # Phase-1 design
2
+
3
+ The stable shape is in [ARCHITECTURE.md](ARCHITECTURE.md) and the reasons in
4
+ [DECISIONS.md](DECISIONS.md). This is the concrete phase-1 plan. Web first:
5
+ a framework-agnostic TypeScript core plus a thin React binding.
6
+
7
+ ## Threat model
8
+
9
+ Two classes of attacker, handled by different layers (ARCHITECTURE § Trust
10
+ boundaries):
11
+
12
+ 1. **Attacking the host** — steal the host's credentials or data, act as the
13
+ person, or escape the sandbox. Countered by **origin isolation**: the content
14
+ runs on an origin that is never the host's, so same-origin policy denies it
15
+ the host's DOM, storage, cookies and credentials. This is the catastrophic
16
+ class; a regression here is the one bug that matters most.
17
+ 2. **Leaking or deceiving** — exfiltrate what the content was already handed
18
+ (whatever the agent wrote into it, or data a capability returned), or draw a
19
+ convincing fake (a login form, an "approve" button) to fool the viewer.
20
+ Origin isolation does not help. Countered by **egress hardening** (close every
21
+ outbound channel to all but declared origins), **gating** (powers that affect
22
+ the person or the agent need a gesture or confirmation), and the host drawing
23
+ its own chrome/approvals outside the frame, never inside it.
24
+
25
+ The agent is itself a trust boundary: the HTML is agent-authored, so loading a
26
+ library from an allowed CDN is no more trusted than the agent's own code. The
27
+ manual's supply-chain rule (pin exact, aged versions) is advice to the agent, not
28
+ a wall; the walls are isolation and egress.
29
+
30
+ ## Package layout
31
+
32
+ One package, `@botiverse/porthole`, with subpath exports (P013), in a pnpm
33
+ workspace (`packages/porthole`):
34
+
35
+ | Import | What | Source |
36
+ | --- | --- | --- |
37
+ | `@botiverse/porthole` | The core: config, presets, compiler, theme, guide, and the seams below. No DOM framework, no host, no MCP SDK. | `src/index.ts` |
38
+ | `@botiverse/porthole/react` | The React binding: `<PortholeProvider>`, `<PortholeFrame>`, the default `ConfirmDialog`. Peer: `react`. | `src/react/` |
39
+ | `@botiverse/porthole/mcp` | The MCP Apps adapter for full MCP Apps views. Depends on the core only. | `src/mcp/` |
40
+ | `@botiverse/porthole/conformance` | The leak/conformance suite and the host test interface. Peer: `@playwright/test`. | `src/conformance/` |
41
+ | `@botiverse/porthole/server` | Serving for domain, artifact and url modes, and the MCP Apps sandbox proxy: a request handler a host's backend mounts. | `src/server/` |
42
+
43
+ Inside `src/`:
44
+
45
+ ```
46
+ config/ schema.ts (the config types), capabilities.ts (the vocabulary), validate.ts
47
+ compile/ compile.ts (config → CompiledConfig), policy.ts (CSP, sandbox, allow, embedder CSP)
48
+ shell/ runtime.ts (the in-frame script, a string), plan.ts (what it wires), document.ts
49
+ guide/ guide.ts (manual + tool description), examples.ts
50
+ theme/ variables.ts (the default vocabulary, resolveTheme)
51
+ presets/ presets.ts (inlineWidget, artifact, mcpApp, url; LIBRARY_CDNS)
52
+ protocol/ adapter.ts (the adapter seam), mcp-apps.ts (method names, HostContext), jsonrpc.ts
53
+ bridge/ types.ts (the host-function bridge)
54
+ transport/ types.ts platform/ types.ts isolation/ types.ts
55
+ host/ types.ts (host API), create-host.ts
56
+ react/ mcp/ conformance/ server/ the subpath entries
57
+ ```
58
+
59
+ Tooling: Node ≥ 24 running TypeScript by type stripping (erasable syntax only;
60
+ relative imports carry `.ts`), pnpm 11, TypeScript 7 for typechecking, oxlint
61
+ (type-aware) and oxfmt, vitest for unit tests (`packages/porthole/test/`), and
62
+ Playwright for the leak suite (`packages/porthole/leak/`). In the workspace the
63
+ package exports its sources; `pnpm build` emits JS and declarations to `dist/`,
64
+ which a packed package exports instead (Node does not strip types under
65
+ `node_modules`).
66
+
67
+ ## Config schema
68
+
69
+ One config per kind of display (`src/config/schema.ts`); `validateConfig` refuses
70
+ a bad one whole (unknown fields, capabilities or gates; any origin that isn't
71
+ exactly `scheme://host[:port]`, so nothing can inject a CSP directive; theme values
72
+ that could break out of CSS). Presets return validated configs.
73
+
74
+ ```ts
75
+ interface PortholeConfig {
76
+ mode: "inline" | "artifact" | "mcp-app" | "url";
77
+ isolation: "opaque" | "domain" | "external"; // external ⇔ url mode
78
+ capabilities: { [name in CapabilityName]?: {
79
+ gates?: Gate[]; // all must pass; omitted = the vocabulary's defaults
80
+ connect?: string[]; resource?: string[]; // extra origins it needs
81
+ guide?: string; // replaces the generated manual entry
82
+ } };
83
+ egress: { connect?, script?, style?, img?, font?, media?: string[] }; // base allowlists, closed by default
84
+ theme: { variables: Record<string, string | { light; dark }>; event: string };
85
+ limits: { maxHeightPx; minHeightPx; maxBytes; maxLiveFrames;
86
+ declinesBeforeMute; maxPromptChars; maxErrorReports };
87
+ layout: { minWidthPx; narrowWidthPx; wideWidthPx }; // for the manual's size rules
88
+ guide: { toolName?; guideToolName?; topics?: Record<string, string> };
89
+ contentOrigins: string[]; // domain/external: where content is served from
90
+ embedder: { frameSrc?: string[] }; // extra frames the embedding page shows
91
+ }
92
+ type Gate = "user-activation" | "confirm" | { rateLimit: { perMinute: number } };
93
+ ```
94
+
95
+ `mode` and `isolation` select the mechanism; everything else is policy. There is
96
+ no `components` field (P015).
97
+
98
+ ### The capability vocabulary
99
+
100
+ | Capability | Default gates | Served by | Shell global | MCP Apps request (App SDK) |
101
+ | --- | --- | --- | --- | --- |
102
+ | `sendPrompt` | user-activation, confirm | handler | `sendPrompt(text)` | `ui/message` (`app.sendMessage`) |
103
+ | `openLink` | user-activation, confirm | handler | `openLink(url)` | `ui/open-link` (`app.openLink`) |
104
+ | `downloadFile` | user-activation, confirm | handler | — | `ui/download-file` (`app.downloadFile`) |
105
+ | `callTool` | confirm | handler | — | `tools/call` (`app.callServerTool`) |
106
+ | `readData` | 120/min | handler | — | `resources/read` (`app.readServerResource`) |
107
+ | `setModelContext` | 30/min | handler | — | `ui/update-model-context` (`app.updateModelContext`) |
108
+ | `reportError` | 10/min | event | (automatic) | `notifications/message`, level `error` |
109
+ | `resize` | none | event | (automatic) | `ui/notifications/size-changed` |
110
+ | `copyText` | user-activation, 30/min | handler | `copyText(text)` | `porthole/copy-text` (none in the standard) |
111
+ | `annotate` | 120/min (the frame's reports) | event | — | `porthole/annotate.*` (the host drives it, P053) |
112
+
113
+ Every preset declares only `resize` and `reportError` (they report to the host and
114
+ carry nothing out); the host declares the rest. Gates are explained in P021: all
115
+ must pass; `confirm` calls the host's `confirm` function and fails closed without
116
+ one; `user-activation` is checked by the shell (advisory: it shares the content's
117
+ realm) and by the host where the engine passes the gesture up.
118
+
119
+ ## What the compiler derives
120
+
121
+ `compile(config)` (`src/compile/compile.ts`) validates the config and returns:
122
+
123
+ - `csp` / `cspDirectives`: the frame's policy. `default-src 'none'`; scripts may be
124
+ inline (agent content is inline) plus `blob:` and the script origins; each other
125
+ kind opens only to its origins; `connect-src 'none'` unless something is
126
+ declared; `frame-src`, `object-src`, `base-uri`, `form-action` all `'none'`;
127
+ `webrtc 'block'`. Never `'self'` (P022). Empty in url mode.
128
+ - `sandbox`: `allow-scripts` (opaque); `allow-scripts allow-same-origin` (domain,
129
+ P016); `allow-scripts allow-same-origin allow-forms` (url, P020).
130
+ - `allow`: empty (no powerful feature delegated).
131
+ - `embedderFrameSources` (and `embedderCsp(configs)` for a page that shows several
132
+ kinds): `frame-src 'self'` plus content origins plus the host's own frames, and
133
+ `child-src` the same; no `default-src`.
134
+ - `shell` (the plan) and `shellDocument({ origins, title?, document?, cspMeta? })`:
135
+ the frame's document with the CSP `<meta>` first, the base styles, and the runtime
136
+ given its plan. A given `document` (an MCP Apps view, a served page) travels in the
137
+ plan as data; the runtime renders it as it renders any document (§ The shell
138
+ runtime), never handing its markup to the page's parser.
139
+ - `guide(topics?)` and `toolDescription()`: the manual and the show tool's
140
+ description.
141
+
142
+ **The agreement test** (`test/agreement.test.ts`) compiles every preset and
143
+ variations of it, and asserts: the shell injects exactly the config's theme
144
+ variables and the manual teaches exactly them; the manual names exactly the
145
+ globals the shell defines, and the runtime defines each; the shell wires exactly
146
+ the declared capabilities, and each capability's manual entry states its gates
147
+ (confirm, click, rate); the manual names exactly the origins the CSP allows; each
148
+ CSP directive equals the egress plus the declared capabilities' needs; the manual
149
+ states the limits, layout and theme event; and changing the config (opening an
150
+ origin, dropping a capability or a variable, overriding an entry) changes all of
151
+ them together. The leak suite's `0.*` probes check the same wiring in real frames.
152
+
153
+ ## Public interfaces
154
+
155
+ The seams other work builds against. Interfaces are typed in the files below;
156
+ functions not built yet throw `NotBuiltYet` naming their milestone.
157
+
158
+ | Seam | File | Built by | Used by |
159
+ | --- | --- | --- | --- |
160
+ | Config, presets, compiler, guide, theme | `src/config/`, `src/presets/`, `src/compile/`, `src/guide/`, `src/theme/` | core (M1, done) | everyone |
161
+ | Host API: `createHost`, `MountOptions`, `DisplayHandle`, `DisplayEvents` | `src/host/types.ts`, `src/host/create-host.ts` | core (M2, built: opaque, domain and url frames) | React binding, hosts |
162
+ | Isolation: `InstanceRef`, `ServedDocument`, `InstanceLocator`, `FrameLocation`, `FrameSource`; `checkFrameLocation` | `src/isolation/types.ts`, `src/isolation/served.ts` | core; served modes (the checks) | served modes, hosts |
163
+ | Platform host: `PlatformHost`, `Surface`, `SurfaceSpec`; `webPlatform` | `src/platform/` | core (web, M2, built) | native hosts later |
164
+ | Transport; `postMessageTransport` | `src/transport/` | core (M2, built) | adapters |
165
+ | Protocol adapter: `ProtocolAdapter`, `AdapterSession`, `AdapterConnection`, `ShellConnection`; `shellAdapter` | `src/protocol/adapter.ts`, `src/protocol/shell-adapter.ts` | core (shell adapter, M2, built) | MCP adapter |
166
+ | MCP Apps names: `MCP_APPS_METHODS`, `MCP_APPS_STYLE_VARIABLES`, `PORTHOLE_STYLES`, `HostContext`; JSON-RPC parsing | `src/protocol/mcp-apps.ts`, `src/protocol/jsonrpc.ts` | core | shell, both adapters |
167
+ | MCP Apps adapter: `mcpAppsAdapter`, `McpAppConnection`, `viewEgress`, `readViewResource`, `MCP_APPS_STYLE_VARIABLES`, `sandboxProxyDocument` | `src/mcp/` | MCP adapter (done) | hosts showing MCP Apps views |
168
+ | Host-function bridge: `HostFunctionBridge`, `CapabilityHandlers`, `Confirm`, `ConfirmRequest`, `Refusal`; `createBridge` | `src/bridge/` | core (M2–M3, built and proven) | adapters, hosts |
169
+ | React: `PortholeProvider`, `PortholeFrame`, `ConfirmDialog`, `admitFrame` | `src/react/` | core (M4, built) | first host |
170
+ | Conformance: `ConformanceHost`, `LaunchSpec`, `LaunchedDisplay`, `defineLeakSuite`, `PROBES`, the oracles; `defineServedSuite`, `ServedConformanceHost` | `src/conformance/` | core (M2, built); served modes (done) | any host |
171
+ | Server: `createServer`, `PortholeServer`, `TicketStore`, `ArtifactStore`, `ContentDomain`, `Grant`, `Access`, `Upstream`, `proxyTo`, `memoryTicketStore` | `src/server/` | served modes (done) | host backends |
172
+
173
+ ### Host API
174
+
175
+ ```ts
176
+ import { createHost, presets, LIBRARY_CDNS } from "@botiverse/porthole";
177
+
178
+ const host = createHost(
179
+ presets.inlineWidget({
180
+ capabilities: { sendPrompt: {}, openLink: {} }, // default gates
181
+ egress: LIBRARY_CDNS, // opt in to library CDNs
182
+ guide: { toolName: "show_widget", guideToolName: "display_guide" },
183
+ }),
184
+ {
185
+ handlers: { sendPrompt: ({ text }, call) => send(text, call.displayId), openLink: ({ url }) => open(url) },
186
+ confirm: (request) => askThePerson(request), // or the React binding's default dialog
187
+ theme: (scheme) => readMyTokens(scheme), // live values for the config's names
188
+ },
189
+ );
190
+
191
+ const display = host.mount(container, { displayId, title, scheme: "light" });
192
+ display.setContent(code, { partial: true }); // while the agent writes it
193
+ display.setContent(code); // once, whole: now it runs
194
+ display.setTheme("dark");
195
+ display.on("resize", ({ height }) => {});
196
+ display.on("error", ({ message }) => {});
197
+ display.on("outcome", (outcome) => {}); // loaded | blocked (why) | timeout (P040)
198
+ display.refreshTheme(); // the host's values changed without a flip
199
+ display.destroy();
200
+
201
+ host.compiled.guide(["widget", "chart"]); // the manual, from the same config
202
+ host.compiled.toolDescription();
203
+ host.compiled.csp; // the exact CSP, for audit
204
+ embedderCsp([config]); // what the embedding page must send
205
+ ```
206
+
207
+ `mount` throws if a declared capability served by a handler has none, so a manual
208
+ never teaches a function nobody serves. A `DisplayHandle` is the framework's
209
+ controls plus the adapter's own operations (`setContent` for the shell adapter;
210
+ the MCP adapter adds its own, such as a tool result).
211
+
212
+ ### Isolation modes and served content
213
+
214
+ Opaque frames need nothing from outside: the host builds the shell document and
215
+ the frame is `srcdoc`. Served frames (domain, external) load a URL: the host's
216
+ `InstanceLocator` turns an `InstanceRef` into a `FrameLocation`, usually by asking
217
+ the host's own backend, which authenticates the viewer, decides they may see it,
218
+ and calls `PortholeServer.issue`.
219
+
220
+ - **Storage is keyed by origin.** An `InstanceRef` names its `scope` (the host's
221
+ choice: a display id, an app id); `/server` maps each scope to
222
+ `<label>.<content domain>`, the label keyed from the scope with the server's
223
+ secret. Same scope, same origin, shared storage; different scope, nothing shared.
224
+ Browsers partition a framed origin's storage by the top-level site, so it is
225
+ stable within one host app.
226
+ - **What a served frame loads** is a `ServedDocument`: the shell (content then
227
+ arrives over the transport, as for opaque frames), a stored artifact version, the
228
+ MCP Apps sandbox proxy, or an external app (url mode).
229
+ - **Tickets** are minted by `issue` (one-time, short-lived, bound to an instance and
230
+ a viewer, stored as their hash), carried once in the frame's URL, and taken by the
231
+ content origin. A served document (at `/__porthole/d/<instance key>`) comes back in
232
+ that same answer, with a cookie of that origin and instance only (`__Host-`,
233
+ `Secure`, `HttpOnly`, `SameSite=None; Partitioned` in a frame, `Lax` on a tab) for
234
+ what follows, a reload; a url-mode app's ticket buys the cookie and redirects to
235
+ the app's own landing path, every request of the app riding on the cookie. WebKit
236
+ keeps no cookie in a cross-site frame (measured), so a framed url-mode app runs
237
+ only where the engine keeps partitioned cookies; elsewhere the host opens it on a
238
+ tab (P025).
239
+ - **Separation is checked.** The content domain is a registrable domain the host
240
+ uses for nothing else, and a mount refuses a located origin that doesn't match
241
+ `contentOrigins` or is same-site with the host.
242
+ - **Names** (P047). By default a scope's label is keyed (24 base32 characters). A
243
+ host may name its own (`content.name`; `prefixedName("a-")` gives `a-<scope>`,
244
+ one-to-one): one lowercase DNS label, never an IDNA form or a `reserved` one.
245
+ `reserved` labels are other servers' namespaces under the same domain (`f` for
246
+ `<label>.f.<domain>`) or the host's own (`p-*`: every label starting `p-`; or a
247
+ function of the label, P056): requests for them and anything under them return
248
+ null, to the host's routes; any other deeper name gets this server's 404.
249
+ - **Requests** (P056). The label is the request URL's; a request whose Host header
250
+ names another host, where either is under the content domain, gets a 400 (a Node
251
+ adapter builds the URL from an absolute-form target, and a host or a cache routes
252
+ by Host). A Host that is a local address (an IP literal, `localhost`), as a test
253
+ runner sends, isn't compared. A server without an `upstream` serves only Porthole's
254
+ own paths: any other is a plain 404. Every answer says `x-robots-tag: noindex`.
255
+ - **Who** (P046, P050). A grant names a `viewer`, or is `anonymous` (a public link:
256
+ documents only, never a url-mode app), and may carry the host's opaque `subject`
257
+ (a share or session id), signed into the ticket and the cookie. The host's
258
+ `authorize.check` runs after a ticket or cookie verifies and before anything is
259
+ served: document loads, url-mode entry, every app request, `/__porthole/session`
260
+ and `access()`. A deny is a 403 status page (`denied`, the host's code); answers
261
+ are not reused unless the host sets `ttlSeconds` (at most 300).
262
+ - **Runtimes** (P044, P045). `/server` uses no Node module (hashing in plain
263
+ JavaScript, randomness from `crypto.getRandomValues`), so it runs on Cloudflare
264
+ Workers without `nodejs_compat`; tickets there live in D1 (`d1TicketStore`, taken
265
+ by one `DELETE … RETURNING`), never KV (eventually consistent). Sessions are
266
+ signed cookies: no store.
267
+
268
+ `/server`'s handler is fetch-style (`Request → Response | null`, null for requests
269
+ that aren't for the content domain or are in a reserved namespace), so a backend
270
+ mounts it in front of its own routes. Dev and tests use `*.localhost` subdomains, which browsers resolve to
271
+ loopback. In url mode an app's requests go, once its cookie checks out, to the
272
+ host's `upstream` (`proxyTo` relays them by HTTP, telling the app it is on its own
273
+ origin and nothing of the proxy); WebSockets take the host's own upgrade path, let
274
+ in by `PortholeServer.access`. The MCP Apps sandbox proxy is served the same way,
275
+ its CSP from the config plus the view's declared egress, signed into the ticket; it
276
+ becomes the view rather than nesting it (P026).
277
+
278
+ #### Wiring on Cloudflare Workers
279
+
280
+ A Worker with Hono, D1 and R2 serving artifact pages on `a-<id>.<content domain>`
281
+ (`test/workers/` runs one like it in workerd, with real D1):
282
+
283
+ - **Bindings.** A D1 database (`DB`) with `d1TicketSchema()` applied once as a
284
+ migration; a secret of at least 32 random bytes (`wrangler secret put
285
+ PORTHOLE_SECRET`); the host's own artifact source (R2, D1) behind `artifacts.get`.
286
+ No `nodejs_compat`.
287
+ - **Routes.** The content domain's wildcard (`*.<content domain>/*`, with wildcard
288
+ DNS and a certificate for every level served, `*.f.<domain>` included) and the
289
+ host's own domain, to the same Worker, or the content domain to a Worker of its own.
290
+ - **Handler.** One server per isolate, made on first use from `env`:
291
+
292
+ ```ts
293
+ const porthole = createServer({
294
+ content: { domain: "example-usercontent.com", scheme: "https", name: prefixedName("a-"), reserved: ["f", "www"] },
295
+ secret: new TextEncoder().encode(env.PORTHOLE_SECRET),
296
+ hostOrigins: ["https://app.example.com"],
297
+ configs: { artifact: presets.artifact({ isolation: "domain", contentOrigins: ["https://*.example-usercontent.com"] }) },
298
+ artifacts: { get: (id, version) => env.ARTIFACTS.get(`${id}/${version}`).then((o) => o && o.text()).then((html) => html && { html }) },
299
+ tickets: d1TicketStore(env.DB),
300
+ authorize: { check: ({ subject }) => stillShared(env, subject) }, // the host's policy
301
+ });
302
+ app.all("*", async (c, next) => (await porthole.handle(c.req.raw)) ?? next());
303
+ ```
304
+
305
+ and from the host's own authenticated API, once it has decided the person may see
306
+ it, `porthole.issue({ scope: id, document: { kind: "artifact", id, version } },
307
+ { viewer: userId, subject: shareId })` (a public share route: `{ anonymous: true,
308
+ subject: shareId }`) returns the `FrameLocation` the host page frames. Scopes named
309
+ by `prefixedName` must already be label-safe (lowercase letters, digits, inner
310
+ hyphens); other ids keep the keyed default.
311
+
312
+ A page whose `script-src` is nonce-based (no `'unsafe-inline'`) blocks an opaque
313
+ frame's shell: a `srcdoc` frame inherits the embedding page's whole policy (P054).
314
+ The host passes its nonce (`HostOptions.nonce`, `PortholeProvider`'s `nonce`); the
315
+ shell's script and style carry it, and the shell gives it to every script it runs
316
+ for the content. Inline event handlers in content (`onclick="…"`) stay blocked by
317
+ such a page's policy, as on the page itself; scripts and `addEventListener` work.
318
+ The whole leak suite runs again on a reference page with a nonce-only `script-src`
319
+ (`leak/strict-csp.spec.ts`), and passes on all three engines; its negative control
320
+ `nonce` (the shell without the nonce) fails it.
321
+
322
+ The served modes have their own conformance cases (`defineServedSuite`, run against
323
+ `leak/served/`'s reference serving, which starts its own servers per worker): content
324
+ runs on its own origin and can't reach the host's DOM, storage, cookies or URL,
325
+ navigate the host's page or open a window (domain and url mode, each also after a
326
+ click); storage persists within a scope and is never shared across scopes; a ticket
327
+ opens its content once; only the host origins may frame content; the CSP in headers
328
+ closes egress; each version of an artifact page shows as stored, hardened before it runs; an app shows nothing without its cookie, runs on a tab, and runs
329
+ framed where the engine keeps partitioned cookies; the sandbox proxy becomes a
330
+ hardened view, takes it only from its host, and opens only what the view declares.
331
+ `served.domain.egress` reads the policy that applied to the document (from the
332
+ violations its attempts raise) and holds the host to its config: scripts, styles,
333
+ fetches and frames never reach the canary, and no directive for them opens a scheme;
334
+ images and media are closed unless the config opened them to any https site
335
+ (`anyHttps`, P059), where an image then must reach an https canary, reported as
336
+ expected rather than as a leak (P062; `leak/served/any-https.spec.ts`).
337
+ A served shell draws nothing, and reports no height, until its host sends content,
338
+ so the suite gives `show` a line of content for each shell (`options.content`) and a
339
+ host that speaks to the shell sends it (P057). The shell cases also run against a
340
+ host on `createHost` whose frames take their content's height
341
+ (`leak/served/core-host.spec.ts`), as a React host's do.
342
+
343
+ ### The host kit (0.2.0: P066, P068)
344
+
345
+ Two hosts each wrote the same ~2,000 lines around `/server`. The host kit makes that machinery
346
+ Porthole's, so an app writes its policy and its settings. The policy is four callbacks (five
347
+ with redirect entry):
348
+ - `instance`: the instance an issue request proposes, as the app means it;
349
+ - `access`: may this viewer see it, and with what grant;
350
+ - `authorize`: may this cookie still see it, on each request (P050);
351
+ - `source`: a stored page by id and version;
352
+ - `entry.at`: the instance a clean address's path names (redirect entry).
353
+
354
+ **An adopter's whole wiring** (the kit reference app, `packages/porthole/leak/kit/app.ts`, is
355
+ this, runnable):
356
+
357
+ ```ts
358
+ const content = createContentHost<Viewer>({
359
+ domain: env.CONTENT_DOMAIN, // "content.example.com" | "content.localhost:8790"; "" = off
360
+ appOrigins: [env.APP_ORIGIN], // frame-ancestors, and the clash guard
361
+ secret: env.CONTENT_SECRET, tickets: d1TicketStore(env.DB),
362
+ configs: (source) => ({ page: presets.artifact({ isolation: "domain", contentOrigins: [source] }) }),
363
+ name: prefixedName("a-"), reserved: ["p-*"], // only labels the app itself serves there
364
+ instance: (proposal, viewer) => mapToMyInstance(proposal), // a proposal: validate, derive the scope
365
+ access: async (instance, viewer) =>
366
+ viewer !== null && (await canSee(viewer, instance)) ? { viewer: viewer.id, subject: viewer.id } : null,
367
+ authorize: ({ subject }) => (revoked(subject) ? { allow: false, code: "revoked" } : { allow: true }),
368
+ source: (id, version) => store.page(id, version), // { html } | null
369
+ entry: {
370
+ authorizeUrl: `${env.APP_ORIGIN}/porthole/authorize`,
371
+ at: (scope, path) => pageAt(scope, path), // "/" → latest, "/v/3" → version 3
372
+ signIn: (returnTo) => Response.redirect(`/login?next=${encodeURIComponent(returnTo)}`, 303),
373
+ },
374
+ health: { probe: nodeProbe(), onChange: (view) => pushToClients(view) }, // optional: the opaque fallback
375
+ });
376
+ // Hono / Workers: content first, then the app's own routes.
377
+ app.use("*", async (c, next) => (await content.fetch(c.req.raw)) ?? next());
378
+ app.post("/api/frames", (c) => content.issue(viewerOf)(c.req.raw));
379
+ app.get("/porthole/authorize", (c) => content.authorize(viewerOf)(c.req.raw));
380
+ // The page: frame-src from content.frameSources; meta from content.meta(); "open in a new tab"
381
+ // and "copy link" from content.urlOf(scope, path).
382
+ ```
383
+
384
+ **Server: `createContentHost(options)` (`@botiverse/porthole/server`, runtime-agnostic: Node,
385
+ Workers, Deno, Bun).** It gives:
386
+ - **`fetch(request)`, the routing middleware.** Every host under the content domain is
387
+ Porthole's, so the app never answers there. Reserved labels and the app's own hosts return
388
+ null, for the app's routes. `reserved` is only for labels the app itself serves on the content
389
+ domain (Ferry's `p-*` previews); a label nothing serves needs no entry. A label the naming
390
+ never makes, the bare domain, and a clean address `entry.at` says names nothing all get
391
+ Porthole's 404 there, never a trip to the app (P070). Settings that can't be served answer a
392
+ plain 503, never the app's. `owns(request)` says the same for a WebSocket upgrade.
393
+ - **`issue(viewerOf)`, the issue endpoint** (a fetch handler). The body is always `{ instance }`
394
+ (an `InstanceRef`, the wire shape). The app's `instance` callback treats it as a proposal: it
395
+ validates it, derives the scope from what it names (a session, a page), and refuses anything
396
+ else (404). App ids travel in the document's fields. **The client never chooses an origin**:
397
+ the scope the server maps to does. Then `access` (401 without a viewer, 403 refused), a ticket,
398
+ and `201 FrameLocation`. `503 not-served` while content isn't served; `409` when `/server`
399
+ refuses the instance; an optional per-viewer rate limit (429).
400
+ - **`authorize(viewerOf)` and `urlOf(scope, path)`: redirect entry** (below).
401
+ - **`meta()`** `{ served, contentSource, health }` for the app's page; **`frameSources`** for
402
+ its CSP (`frame-src`), the authorize route's path included.
403
+ - **Domain helpers.** `contentDomain(input, { appOrigins })` parses (the scheme from loopback or
404
+ `*.localhost`, the port) and gives `originOf(label)`, `source`, `isContentHost(host)`,
405
+ `labelOf(host)`; `ContentDomainError` names the rule (`invalid`, `clash`, `same-site`).
406
+ - **Clashes.** Porthole never serves a content domain that holds an app origin or may share its
407
+ site. With the health gate (the opaque fallback) on, such settings are a lasting "unhealthy:
408
+ misconfigured, <reason>": logged once (`porthole.content.misconfigured`, with its `code`),
409
+ `meta().served` false, frames run opaque. Without it, `createContentHost` throws the typed
410
+ error at startup. Fail closed either way; there is no separate switch.
411
+ - **The health gate (optional).** Porthole answers a who-am-I on its health label
412
+ (`/__porthole/health`, a keyed id, so another deployment's answer doesn't count). The kit
413
+ probes it over the real network (DNS, the edge, TLS): every 60 s while unhealthy, every 10 min
414
+ while healthy, 10 s timeout. While unhealthy, `issue` answers `503 not-served` and the page's
415
+ `servedOrOpaque` picks opaque configs. `health.onChange(view)` is told each change of state
416
+ (P070), for an app that pushes "frames served" to its clients. On Workers the probe is optional (a host that doesn't
417
+ enable it serves from startup).
418
+
419
+ **Redirect entry (P068).** Every instance has a clean, stable address
420
+ (`https://a-<id>.content.example.com/v/3`) that opens directly: a new tab, a reload, a bookmark,
421
+ a link sent to someone with access.
422
+ 1. A request on a clean address that names nothing gets Porthole's 404 on the content origin:
423
+ the naming never made its label, or `entry.at(scope, path)`, asked with no viewer, says
424
+ nothing (null or undefined) is there (P070).
425
+ A request on a clean address without a cookie for it gets a 302 to the app's authorize route
426
+ with a signed state (label, path, nonce, five minutes), and a cookie holding the nonce on the
427
+ content origin (partitioned in a frame, `Lax` on a tab, like the session cookie).
428
+ 2. `authorize` asks `entry.at(scope, path)` and `access`. No viewer: on a tab, the app's own
429
+ sign-in (`signIn(returnTo, request, entry)`), which comes back to the same route; **in a
430
+ frame, never the sign-in**: a plain page asking for a new tab (`blocked: sign-in`). No
431
+ access: the app's refusal on a tab (`refusal(status, request, entry)`; nothing returned:
432
+ Porthole's page), a plain page in a frame; never content. Both hooks get the address decoded
433
+ (`EntryContext`: label, scope, path, clean URL, the instance, framed).
434
+ 3. Granted, a 302 back to `/__porthole/entry?code=…`: a one-time code (60 s), bound to the
435
+ label, the path and the nonce. The content origin redeems it only on that label and only with
436
+ the nonce's cookie, sets the session (bound to the path) and 303s to the clean address. The
437
+ code is in an address for that one hop (gone from history), every redirect sends
438
+ `no-referrer`, and Porthole never logs it.
439
+ 4. A browser that keeps no cookie in a frame (WebKit, P025) has no nonce at the redeem: it gets
440
+ the "open in a new tab" outcome (`blocked: cookies`), and an entered path whose session still
441
+ didn't come back is caught on the second bounce. No loops.
442
+
443
+ Use tickets (`issue` → `FrameLocation`) for frames: no extra round trip, and no dependence on the
444
+ app's own cookie reaching its authorize route inside a frame (measured: Firefox and WebKit don't
445
+ send a `Lax` app cookie on a frame navigation that came through a cross-site redirect; Chromium
446
+ does). Use clean addresses for tabs, links, bookmarks and reloads: a host's "open in a new tab"
447
+ and "copy link" use `urlOf`, never a ticket. The first open of an instance per browser costs one
448
+ extra round trip through the app.
449
+
450
+ **Node: `@botiverse/porthole/node`.** `nodeHost(content, { request, upgrade, appSocket })`
451
+ returns a request listener and an upgrade listener for `http.createServer`: content hosts to
452
+ `content.fetch`, WebSockets on reserved labels to the app's upgrade, a url-mode app's socket to
453
+ `appSocket(access, …)` once its cookie checks out, any other socket on the content domain a 404.
454
+ `toRequest`/`sendResponse` convert (P070): the request carries a signal aborted when the client
455
+ goes away (`proxyTo` passes it on, so a proxied event stream ends with its page); the body is
456
+ written as the socket drains and cancelled when the client leaves; an error mid-body destroys
457
+ the response rather than end it looking whole. `nodeProbe()` is the health probe (`*.localhost`
458
+ resolved to loopback, no redirects, 4 KB read). `/server` stays free of Node.
459
+
460
+ **Client** (core). `httpLocator(url, { headers, credentials, onNotServed })` POSTs
461
+ `{ instance }`, passes the abort signal, refuses redirects, and throws a `LocateError` with the
462
+ endpoint's status and code. `servedOrOpaque(meta, servedConfigs, opaqueConfigs)` gives
463
+ `{ served, configs }`, or null until meta is known, so a frame never starts opaque and then
464
+ restarts served. A redirect entry's pages on the app origin report the frame's status too
465
+ (`blocked: sign-in`), and the frame transport hears that from the host's own origin only for
466
+ frame status.
467
+
468
+ **Conformance runner (`@botiverse/porthole/conformance`).** `defineHostConformance(test,
469
+ { name, open, issue, config, instances, entry?, nonce?, skip? })` registers the leak suite, the
470
+ served cases and the redirect entry cases against the app's real page:
471
+ - `open(page)` opens it as a person sees it (signed in, its real CSP);
472
+ - the harness is framework-free, on the core's `createHost` (no React: an app's own frame
473
+ components are its own e2e's; Porthole's React binding is Porthole's own tests'). It loads as a
474
+ script from the page's own origin (routed by the test), with the page's nonce, so a strict CSP
475
+ needs nothing added;
476
+ - served frames are located by the app's issue endpoint from the page, with its cookies;
477
+ - the canaries and a foreign origin are routed; cases for instances the app doesn't give
478
+ (versions, url mode, the MCP Apps proxy) are skipped with the reason.
479
+
480
+ The tests are located in the app's own spec file, at the `defineHostConformance` call, so
481
+ `playwright test <spec>` and `<spec>:<line>` select them (P070). `instances` and the entry
482
+ addresses may be async (an app publishes a test page on demand and returns its real instance,
483
+ which the issue endpoint then gets); `entry.unknown` lists addresses that name nothing, each of
484
+ which must be a 404 on the content domain. `hostConformance(options)` gives the hosts for an app
485
+ that registers the cases itself.
486
+
487
+ **Prod shape** (`ops/prod-shape/`, `ops/prod-shape/run.sh`). Docker compose: Caddy's internal
488
+ CA serving `app.test`, `*.content.test` (same-site siblings, as in production without a Public
489
+ Suffix List entry), `clash.test` and `evil.test`; wildcard DNS (CoreDNS); the kit reference app
490
+ behind the edge, its health probe over the real TLS; a misconfigured twin that must run opaque,
491
+ and a strict one that must not start; a Playwright image trusting the CA (Chromium's NSS store,
492
+ the system store for WebKit; Playwright's Firefox reads neither, so its project overrides
493
+ certificate errors). The suite (`packages/porthole/prod-shape`) runs the whole runner over https,
494
+ then the sibling cases: Porthole's cookies host-only and `__Host-`; a sibling label (an app's
495
+ preview) can't plant a cookie that opens content, or frame it; another site can't frame it; the
496
+ clash. CI (`.github/workflows/ci.yml`) runs on every push: the checks, the loopback suites on
497
+ each engine, and the prod-shape profile.
498
+
499
+ **Compatibility.** Every 0.1.x export stays as it was; the kit wraps `createServer`. The npm
500
+ package carries these docs (`docs/`), and `/server` exports the types an app's callbacks take
501
+ (`InstanceRef`, `FrameLocation`, `ServedDocument`, `PortholeConfig`, `EntryContext`). Nothing was
502
+ renamed, so there is no compat module. `FrameBlock` gains `sign-in`; `ContentDomain.name` takes
503
+ a `Naming` (any function still fits).
504
+
505
+ ### Protocol adapters
506
+
507
+ The core ships the adapter for the MCP Apps subset its own shell speaks
508
+ (`ShellConnection`): `ui/initialize` answered with the host context,
509
+ `tool-input-partial` (0..n) then `tool-input` carrying `{ html }`,
510
+ `host-context-changed`, `size-changed` and `notifications/message` (errors) into
511
+ the bridge, `ui/message` and `ui/open-link` into the bridge, `ui/resource-teardown`.
512
+ Content may also use the official App SDK directly; the adapter answers a second
513
+ `ui/initialize` from the same frame, and the shell numbers its own requests with
514
+ strings (`porthole-shell-1`) so neither takes the other's answers.
515
+
516
+ The host context carries in `styles.variables` only the names MCP Apps defines
517
+ (`MCP_APPS_STYLE_VARIABLES`): the official SDK refuses any other name there and fails
518
+ the whole handshake. Porthole's own variables (the accent, the chart palette) go in
519
+ the host context's own-prefixed field `porthole/styles: { variables }`, which the
520
+ shell applies beside the standard ones and a view ignores (P028). The `0.app-sdk-*`
521
+ probes connect the official SDK from content under the inline, artifact and MCP Apps
522
+ presets.
523
+
524
+ The `/mcp` adapter (full MCP Apps views) implements `ProtocolAdapter` with its own
525
+ connection operations (`sendToolInputPartial`, `sendToolInput`, `sendToolResult`,
526
+ `sendToolCancelled`), carries `ui/message`, `ui/open-link`, `tools/call`,
527
+ `resources/read`, `ui/download-file` and `ui/update-model-context` through the
528
+ bridge, answers `ui/request-display-mode` with the current mode, sends a view only
529
+ the standard style variables (the official SDK refuses others), and performs the
530
+ sandbox-proxy handshake when the frame is the proxy (P027). A host maps a view's
531
+ `_meta.ui.csp` into its config's egress with `viewEgress` (or `readViewResource`,
532
+ which reads the view and its egress from a `resources/read` result). An adapter
533
+ validates every message and never calls a host handler itself: `bridge.call`
534
+ decides. The leak suite runs the official ext-apps examples, as published, through
535
+ it (`leak/mcp/`), in an opaque frame and behind the sandbox proxy.
536
+
537
+ ### The host-function bridge
538
+
539
+ `bridge.call(capability, args)` checks, in order: declared → arguments valid
540
+ (`openLink` http(s) only and without credentials in the URL; a prompt
541
+ 1..`maxPromptChars` characters) → muted? → rate limit → user activation → `confirm`
542
+ → the host's handler. Each refusal is a
543
+ `Refusal` and a `refused` display event; the frame gets an error result. After
544
+ `declinesBeforeMute` declines a display stops asking (`muted`) until it runs again.
545
+
546
+ ### React binding
547
+
548
+ `@botiverse/porthole/react` (`src/react/`, TSX that a host's bundler or `pnpm build`
549
+ compiles; peer `react` 19.2+ for `useEffectEvent`). All isolation is the core's; the
550
+ binding is mounting and lifecycle (P034).
551
+
552
+ ```ts
553
+ <PortholeProvider confirm? hostInfo? locator? nonce? dialogClassName? toastClassName?> // once per page
554
+ <PortholeFrame
555
+ config={widgetConfig} // a preset's config, stable (module scope or memoized)
556
+ displayId={id} title={title} // height memory and confirmations key on these
557
+ scheme={scheme} // "light" | "dark": flips in place
558
+ content={code} partial={streaming} // drafts inert while partial; whole runs once
559
+ handlers={{ sendPrompt, openLink }} // one per declared capability; latest are called
560
+ theme={(scheme) => tokens} // live values for the config's variable names
561
+ generation={n} // bump to run again from the start
562
+ onContentError onHostError onResize onNavigated onRefused onMuted onInitialized onOutcome
563
+ fill? // fill the box and scroll inside, not grow with the content
564
+ annotating? annotations? focusedAnnotation? onAnnotation? // where annotate is declared
565
+ aliases? themeKey? renderPlaceholder? eager? confirm? document? instance?
566
+ className? style? />
567
+ </PortholeProvider>
568
+ ```
569
+
570
+ - **Provider** (per page): which frames run (a live-frame cap: the config's
571
+ `limits.maxLiveFrames`; frames off screen longest stop first, show a placeholder at
572
+ their last height with "Run again", and run again when they come back), the
573
+ heights displays reported (by `displayId`, so a frame mounting again starts at its
574
+ last height), and the default confirmation dialog (`ConfirmDialog`, P019): the
575
+ call's whole effect and the display's title, Cancel focused, the accept button
576
+ ignoring presses for `ARM_DELAY_MS` after it opens, Escape cancels; a modal in the
577
+ host page, outside every frame. `confirm` replaces it. Without a provider frames
578
+ still run (one page-wide registry) and calls that need a person are refused. It
579
+ also serves `copyText` for frames whose handlers don't (P052): the host's page
580
+ writes the clipboard and the copy toast (`CopyToast`) shows the person what went on
581
+ it, clipped to four lines and `TOAST_CHARS`; if the write failed it says so and
582
+ offers a Copy button, a click on the host's own page.
583
+ - **Frame**: mounts when it nears the screen (800 px) unless `eager`; until its
584
+ content reports a size it holds its remembered height (at least
585
+ `PLACEHOLDER_HEIGHT_PX`) and is hidden behind its placeholder (`data-porthole-state`:
586
+ `waiting`, `running`, `stopped`; `data-porthole-ready` while it loads); then it is
587
+ as tall as its content, within the config's limits. Only a new `config`,
588
+ `document`, `instance` or `generation` makes a new display; `content`, `partial`,
589
+ `scheme`, handlers, and even `displayId` and `title` are fed to the running one (a
590
+ streamed display keeps its frame when its id settles, and its confirmations name
591
+ it as the host now does).
592
+ Navigation recovery is the core's: started again up to three times, `onNavigated`
593
+ each time, then stopped with an error.
594
+ - The shell reports no size before it has drawn something, so an empty shell never
595
+ overwrites a remembered height (the first host's frame jumped there).
596
+ - **Annotations** follow props: `annotating` (what the person may point at now, or
597
+ null), `annotations` (the marks), `focusedAnnotation` (each new id is brought into
598
+ view; `{ id, key }` with a new key focuses the same one again); `onAnnotation` gets
599
+ what the frame reported. A frame that mounts again gets them all.
600
+ - **Errors** (P055): `onContentError` gets what the content did wrong (what it
601
+ threw, what the sandbox blocked, that it kept navigating away): the agent's to
602
+ hear. `onHostError` gets what failed on the host's side (`locate`, `mount`,
603
+ `annotations`): never the agent's. `onError(message, source)` gets both.
604
+ - **Identity** (P057): `config`, `instance`, `annotations` and the provider's
605
+ `hostInfo` are compared by value, and the provider's `locator` and `confirm` are
606
+ read when used, so a host may build them inline. A new `config`, `document`,
607
+ `instance` or `generation` still makes a new display.
608
+ - **Fill** (P057): `fill` makes the frame fill its box (give the box, or its parent,
609
+ a height) and the content scroll inside it, instead of growing with it; a url-mode
610
+ app always fills. In the core, `MountOptions.fill`.
611
+ - **A strict page** (P054): `nonce` passes the page's CSP nonce to opaque frames, which
612
+ inherit the page's policy (`HostOptions.nonce` in the core).
613
+
614
+ Its tests run in real browsers (`leak/react.spec.ts`, against `leak/react/app.tsx`,
615
+ which the leak server bundles with esbuild because React ships CommonJS only).
616
+
617
+ ## Widget side (what the agent writes against)
618
+
619
+ The content speaks **MCP Apps**. An author can use the official
620
+ `@modelcontextprotocol/ext-apps` `App` SDK, or the raw JSON-RPC over `postMessage`
621
+ the spec documents — the host side answers it. For convenience the shell defines
622
+ `sendPrompt(text)` and `openLink(url)` when those capabilities are declared (sugar
623
+ over `ui/message` and `ui/open-link`), turns link clicks into `openLink`, stands in
624
+ for `localStorage` in an opaque frame, applies the theme variables, and fires the
625
+ config's theme event after a change. A capability that is not declared is simply
626
+ absent.
627
+
628
+ ## The modes
629
+
630
+ - **Inline widget** (`presets.inlineWidget`). The host has the HTML (the agent
631
+ wrote it) and the shell draws it in an opaque frame, streamed as it is written.
632
+ The phase-1 workhorse.
633
+ - **Artifact page** (`presets.artifact`). A whole document: written into an opaque
634
+ frame by the shell where the host has it (the first host's "show an HTML file"),
635
+ or served on its own origin (domain isolation), CSP in headers, versioned. It
636
+ scrolls inside past a shorter height cap.
637
+ - **MCP Apps view** (`presets.mcpApp`). A standard `ui://` template plus tool
638
+ input/result, through the `/mcp` adapter: hardened in an opaque frame (the shell's
639
+ runtime first in the view's document), or behind the sandbox proxy on a second
640
+ origin.
641
+ - **Domain mode** (any of the above with `isolation: "domain"`). A stable origin per
642
+ storage scope, so `localStorage` persists (P016).
643
+ - **URL mode** (`presets.url`). A running app by URL (the first host's dev-server
644
+ preview): frame, isolation and access auth; no shell, no egress control (P020).
645
+ It has no handshake: its first load makes it ready, and it fills its container (an
646
+ app reports no size; give the frame a height), at least `minHeightPx`. A host may
647
+ add popups, modals and downloads to its sandbox, never top navigation (P037), and
648
+ delegate clipboard writes and full screen (`permissions`, P049; none by default).
649
+ Through `/server`, a framed app is entered through a page that checks the browser
650
+ kept its cookie, tells the host (P040), and goes on once it has loaded itself; the host's `outcome` says loaded,
651
+ blocked (and why: `cookies` on WebKit, `framing`, `session`, `upstream`, `denied`
652
+ with the host's code, `locate` when the host's own locator failed) or timeout, so
653
+ it can offer its own "Open in a new tab". A `denied` may follow `loaded`: the host
654
+ revoked access mid-session (P050).
655
+
656
+ ## Egress hardening, each with its test
657
+
658
+ Closed by default; opened only to origins the config declares. The leak suite
659
+ (`src/conformance/probes.ts`, run by `pnpm test:leak` against the reference host)
660
+ attempts each channel from inside the content, as an agent's code would, and asserts
661
+ with an out-of-band oracle that nothing left: an HTTP canary that also counts raw TCP
662
+ connections, a UDP listener for STUN, a witness origin a navigated document would
663
+ reach, and the page's responses. Each oracle has a positive control (an allowed
664
+ origin loads; an unsandboxed peer connection sends STUN; an unsandboxed hint
665
+ connects) or the probe says it has none. Each defense has a negative control
666
+ (`PORTHOLE_LEAK_WEAKEN`, P032) that takes it out, so its probes are seen to fail.
667
+ Engines: Chromium 153, WebKit 26.6 and Firefox 155 (Playwright 1.63) all gate (P017,
668
+ P035).
669
+
670
+ Origins are exact, or `https://*.` a domain of the host's own: a wildcard over a
671
+ public suffix (`*.com`, `*.co.uk`, `*.github.io`) is refused, as is any scheme
672
+ source, with one exception (P060). A host may let passive content load from any
673
+ https site (`egress.anyHttps: ["img", "media", "font"]`, P059): those directives
674
+ get `https:`, scripts, styles, data and frames never do, and the manual says so
675
+ (`leak/cdn.spec.ts`: a hotlinked image loads; a script, a stylesheet and a fetch
676
+ to the same site don't reach it).
677
+
678
+ An opaque frame inherits the embedding page's whole policy (P058): on a page that
679
+ closes images or fetches to its own origin, the positive controls of `1.connect`
680
+ and `2.resources` can't load, through no fault of the host's isolation. The suite
681
+ then reports the probe inapplicable on this host, with the reason, instead of
682
+ failing it: a probe sees the allowed origin blocked by a policy that isn't the
683
+ frame's own (the frame's allows it). `12.no-confirm` likewise, on a host that
684
+ always has a confirm function (a default dialog). Tested against a reference page
685
+ closed like a strict host's (`leak/inherited-policy.spec.ts`).
686
+
687
+ | # | Channel | Mechanism | Probes | Negative control → fails (Chromium / WebKit / Firefox) |
688
+ |---|---------|-----------|--------|------------------------------------------|
689
+ | 1 | fetch, XHR, WebSocket, EventSource, `sendBeacon`, `fetchLater`, a blob worker's fetch | CSP `connect-src` = the allowlist only (`'none'` by default) | `1.connect` (with an allowed origin as positive control) | `connect-src` → `1.connect` (all three); `8.prefetch` too on Chromium |
690
+ | 2 | images, CSS images, SVG `<image>`/`<use>`, media and posters, `input type=image`, stylesheets, `@import`, fonts, scripts, `import()`, workers, manifests, icons | CSP `img/media/font/style/script-src` = the allowlist only; `worker-src blob:` | `2.resources` (an allowed image as positive control) | — (the same CSP as item 1) |
691
+ | 3 | WebRTC (ICE gathering reaches the network outside `connect-src`) | CSP `webrtc 'block'` **and** the shell removes every `RTC*`/`webkitRTC*` constructor, fixed in place, before content runs | `3.webrtc` (an unsandboxed peer connection as positive control: it sends STUN on both engines) | `webrtc` → `3.webrtc` (all three; Firefox's positive control needs `media.peerconnection.ice.loopback`, set in the suite's Firefox project, P035) |
692
+ | 4 | a nested frame: a fresh realm where item 3's removal never happened | the shell drops frame-like elements (`iframe, frame, frameset, object, embed, portal, fencedframe`) synchronously wherever content connects a node (P030), and an observer removes any that arrive another way, in the document and every shadow root (`attachShadow` is wrapped so closed ones are reached too); documents are rendered inert and imported, never written; `document.open/write` throw; declarative shadow roots can't be built (P029); CSP `frame-src 'none'` | `4.light-dom`, `.sync-realm`, `.open-shadow`, `.closed-shadow`, `.shadow-nested`, `.declarative-shadow`, `.parse-html-unsafe`, `.domparser`, `.document-write`, `.plugins`, `.foreign-object`, `.template-clone`, `.static` (each: the child never runs, no STUN, no frame left) | `frame-guard,sync-guard` → light-dom, sync-realm, plugins, foreign-object, template-clone; `attach-shadow,sync-guard` → closed-shadow, shadow-nested; `declarative-shadow` → declarative-shadow; `parser-entry-points` → document-write (each on all three engines). Either guard alone is covered by the other. |
693
+ | 5 | self-navigation out, with data in the URL (`location`, `replace`, `open(…, "_self")`, meta refresh, a link clicked past the shell, an SVG link), to the host's own origin, or to a `blob:`/`data:` document of its own (a fresh realm) | the **embedding page's** `frame-src` (`embedderCsp`: `'none'` for opaque content, P033; frames only, so the host's own workers are untouched, P038): a frame's own CSP cannot stop its own navigation; the shell sends link clicks out through `openLink` and drops refreshes (P030); a display whose frame navigates is started again, at most three times (P031) | `5.location`, `.replace`, `.open-self`, `.refresh`, `.anchor`, `.svg-link`, `.host-origin`, `.blob`, `.data` (no request reaches the canary or the host; a navigated document never runs: a witness origin hears nothing) | `embedder-csp` → location, replace, open-self, anchor, svg-link, blob, data, `6.remote-download` (all three; `.refresh` holds: the synchronous guard drops the refresh; Firefox also holds `.data` by itself, P035); `embedder-self` → `5.host-origin` |
694
+ | 6 | forms, popups, downloads, top and parent navigation | `sandbox="allow-scripts"` only; CSP `form-action 'none'` | `6.submit`, `.request-submit`, `.blank-form`, `.open`, `.blank-link`, `.download`, `.remote-download`, `.top`, `.top-link`, `.parent` | `sandbox` → open, blank-link, top, top-link, parent (all three), download (Chromium; WebKit and Firefox started no download either way). Form submits still fail: `form-action` is a second wall |
695
+ | 7 | `<base>` hijack, plugins | CSP `base-uri 'none'`, `object-src 'none'`; plugins removed as frames | `7.base-plugins` | `base-uri` → `7.base-plugins` (all three); `frame-guard,sync-guard` too (plugins left in place) |
696
+ | 8 | resource hints and speculation rules | CSP for those an engine governs (`prefetch`, `preload`, `modulepreload`); for the rest the shell drops `dns-prefetch`, `preconnect`, `prefetch`, `prerender` links, speculation rules, refreshes and `x-dns-prefetch-control` metas synchronously, and refuses changes that would make a connected link one (P030); `x-dns-prefetch-control: off` | `8.preconnect`, `.dns-prefetch`, `.prefetch`, `.prerender`, `.preload`, `.modulepreload`, `.speculation-rules` (each in markup, inserted, and switched on by attribute change; no request, no connection) | `sync-guard` → `8.preconnect` (WebKit: it preconnects synchronously, outside CSP). Measured: Chromium and Firefox opened no preconnect even unsandboxed. `dns-prefetch` has no out-of-band oracle here (DNS isn't observed); the element is refused before it connects |
697
+ | 9 | the host's DOM, storage, cookies, credentials; another display | opaque origin (no `allow-same-origin`); nothing secret is sent into the frame; the shell takes messages only from its parent | `9.host`, `9.siblings` (a display can't navigate or drive another) | — (the sandbox is the platform's; measured: a new frame's realm is cross-origin even in the task that made it, both engines) |
698
+ | 10 | the embedding page's own origin | no `'self'` in any directive (P022) | `10.self` | `self` → none: measured, an opaque frame's `'self'` reaches nothing of the host on either engine. Kept out anyway (nothing legitimate needs it) |
699
+
700
+ Interop (`0.*`): the content runs in an opaque origin; the shell defines a global only
701
+ per declared capability, fixed in place; it applies exactly the declared theme
702
+ variables; streamed content is drawn inert and runs once; and content using the
703
+ official App SDK connects under the inline, artifact and MCP Apps presets
704
+ (`style-split` → all three, P028).
705
+
706
+ ### Behavior and gates, each with its test
707
+
708
+ | # | What | Mechanism | Probes | Negative control → fails (all three engines) |
709
+ |---|------|-----------|--------|------------------------------------------|
710
+ | 11 | streaming | `setContent(html, { partial: true })` any number of times, then whole once; the shell draws a draft from a template with everything that runs or navigates removed (scripts, frames, `on*`, `javascript:`), changing only what changed | `11.streaming`: each draft shows; a drawn node is kept across drafts (an image doesn't reload); nothing runs (no script, handler or `javascript:` href) until the whole, which runs once; a repeat or a late draft changes nothing | `draft-inert` → `11.streaming` |
711
+ | 11 | auto-size | the shell reports its height (`size-changed`) as it changes; the host clamps it to `minHeightPx`..`maxHeightPx` and sizes the frame; past the cap the content scrolls inside | `11.size` (grows, shrinks, stops at the cap and scrolls, floors at the minimum; events carry the clamped height), `11.size-document` (a whole page's height; its own `load` fires; its title kept) | — |
712
+ | 11 | theming | `setTheme(scheme)`: the host sends the resolved variables (the host's live values over the defaults, P028), the shell sets them in place, with `data-theme` and `color-scheme`, then fires the config's theme event once, only when the scheme or a value changed (never at mount, never for a host re-sending what the content has, P061); the frame element's `color-scheme` follows | `11.theme-flip` (no event at mount or for the same scheme; in place: content state kept, the event fired once per flip, back and forth), `11.theme-host-values` (live values for standard and own names; undeclared names dropped) | — |
713
+ | 11 | base styles | plain CSS at zero specificity (`:where`): native buttons, inputs, selects and text areas take the theme's borders, radius, colors and focus ring, 44 px tall (the manual's touch size); the manual says so for inline widgets | `11.base-styles` (themed; content's own CSS overrides them) | — |
714
+ | 12 | `sendPrompt` | the shell refuses a call without a gesture; the host refuses one without its page's user activation, then asks `confirm` with the whole text; declines count toward a mute | `12.prompt-gesture-confirm` (a call on load is refused; a click asks; a no sends nothing; a yes reaches the handler), `12.raw-message` (a raw `ui/message` around the shell, no click: refused by the host), `12.mute`, `12.no-confirm` | `confirm-gate` → prompt-gesture-confirm, link, mute, no-confirm; `shell-gesture` → prompt-gesture-confirm, mute; `host-gesture` → raw-message |
715
+ | 12 | `openLink` | link clicks go out through `openLink`; the bridge takes http(s) only, without credentials in the URL; confirmed | `12.link` (a web link asks and opens; `javascript:` links and `openLink("javascript:…")` do nothing and are refused) | `link-scheme` → `12.link` |
716
+
717
+ Measured: a click in the frame gives the host page user activation (it propagates to
718
+ ancestors) on Chromium, WebKit and Firefox, so the host-side gesture check holds for real
719
+ clicks; it is still advisory, since a person may have clicked the host a moment
720
+ before. Playwright's own page evaluations count as gestures, so the gate probes wait
721
+ for that to lapse and click with raw input.
722
+
723
+ ### Served content, each with its test
724
+
725
+ | What | Mechanism | Tests (all three engines unless noted) | Negative control → fails |
726
+ |------|-----------|-------|------------------|
727
+ | The host's check (P050) | `authorize.check` after a ticket or cookie verifies, before anything is served; a deny is a 403 page saying `denied` and the host's code, and the display's `outcome` says so even after `loaded` | `leak/served/authorize.spec.ts`: a revoked subject's ticket (a page, an app); a framed app's next request and reload mid-session (Chromium, Firefox: WebKit keeps no framed cookie, P025); a framed page's next load; an app and a page in a tab | `authorize` → every case but WebKit's framed page (`session` either way) |
728
+ | Url-mode delegation (P049) | `allow` from `permissions`, `""` by default | `leak/served/permissions.spec.ts`: full screen follows it on all three; clipboard writes on Chromium only (WebKit and Firefox let any frame write in a click) | `url-permissions` → the default case (all three) |
729
+ | Library CDNs for artifacts (P048) | `egress: LIBRARY_CDNS` in the host's artifact config; closed by default (P022) | `leak/cdn.spec.ts`: opaque and served, a CDN library loads only when opened; another origin never | — (the CSP of rows 1–2) |
730
+ | Workers (P044, P045) | no Node module in `/server`; D1 tickets taken by one `DELETE … RETURNING` | `test/workers.test.ts` (workerd, no `nodejs_compat`, real D1: once per ticket, a race, anonymous, a revocation, flat names, reserved namespaces) | — |
731
+
732
+ ### The clipboard, and copy buttons
733
+
734
+ Content never writes the clipboard (P051). The frame's `allow` denies
735
+ `clipboard-read` and `clipboard-write` by name; the shell makes the async Clipboard
736
+ API refuse (`write`, `writeText`, `read`, `readText`), makes `execCommand` refuse
737
+ `copy`, `cut` and `paste`, and stops copy and cut events at the window before any
738
+ listener of the content's, so a copy the person makes copies their selection as the
739
+ browser makes it. Copy buttons call `copyText(text)` (P052): the host's page
740
+ writes the clipboard, inside the person's click (a click in a frame activates its
741
+ ancestors), and shows them what went on it.
742
+
743
+ | # | Channel | Mechanism | Probes | Negative control → fails |
744
+ |---|---------|-----------|--------|--------------------------|
745
+ | 13 | the async Clipboard API in a click (`writeText`, `write`, `readText`) | the shell's lock; Chromium also by the `allow` denial | `13.clipboard-api` | `clipboard` → WebKit, Firefox (Chromium holds by the `allow` denial) |
746
+ | 13 | `execCommand("copy")`/`("cut")` of the content's own selected text | the shell refuses the commands | `13.clipboard-command` | `clipboard` → all three |
747
+ | 13 | a copy listener swapping what the person copies (`clipboardData.setData`) | copy and cut events stopped at the window, first | `13.clipboard-event` (the person's own copy works: positive control) | `clipboard` → all three |
748
+ | 12 | `copyText` | a click (shell and host), 30 a minute, the host's handler | `12.copy-text` (refused on load; the whole text reaches the handler from a click) | — |
749
+
750
+ Measured before the walls (0.1.2): a click in an opaque widget wrote the clipboard
751
+ on all three engines (the async API on WebKit and Firefox, the copy command and copy
752
+ listeners everywhere). The oracle is the person's clipboard: text copied on the
753
+ host's page, then pasted back there. What the shell can't stop: text hidden by CSS
754
+ inside what the person selects goes with their copy (the browser's own copy); hosts
755
+ that care point people at copy buttons, whose toast shows exactly what was copied.
756
+
757
+ ### Annotations
758
+
759
+ A person points at part of a display to comment on it (P053). The host drives the
760
+ shell with Porthole's own messages and the shell reports back; the config declares
761
+ `annotate` (inline widgets and artifact pages; MCP Apps views where the host
762
+ chooses), and the annotation runtime is in the shell only then.
763
+
764
+ | Direction | Message | Params |
765
+ |-----------|---------|--------|
766
+ | host → frame | `porthole/annotate.start` | `{ elements, text }`: picking elements (the one under the pointer or finger outlined with its name; a click or tap picks it, never reaching the content; Escape cancels; one pick), selecting text (each settled selection), or both (a click picks, a drag selects) |
767
+ | host → frame | `porthole/annotate.stop` | — |
768
+ | host → frame | `porthole/annotate.show` | `{ marks: [{ id, anchor }], active? }`: drawn over the content in the shell's own layer (a closed shadow root), found again whenever the content changes |
769
+ | host → frame | `porthole/annotate.focus` | `{ id }`: scrolled into view inside the frame, made the active mark, flashed |
770
+ | host → frame | `porthole/annotate.clear` | — |
771
+ | frame → host | `porthole/annotate.selected` | `{ anchor, rect }` |
772
+ | frame → host | `porthole/annotate.unselected` | — (the text selection went away) |
773
+ | frame → host | `porthole/annotate.cancelled` | — (Escape) |
774
+ | frame → host | `porthole/annotate.anchored` | `{ results: [{ id, state: "attached" \| "orphaned", rect? }] }`: every mark after `show`, the one after `focus`, and when the content changed, only if a state did |
775
+
776
+ - **Anchors** are W3C Web Annotation selectors, at most one of each:
777
+ `CssSelector` (unique: the nearest id, else a path of `nth-of-type` steps),
778
+ `TextQuoteSelector` (the words, with 32 characters of context each side), and
779
+ `TextPositionSelector` (offsets into the body's text, without scripts, styles and
780
+ templates). An element anchor has its selector and its first 300 characters of
781
+ text; a text anchor its words (at most 4,000), position and nearest element.
782
+ - **Found again**, best-effort: a text anchor where it was, else its words anywhere
783
+ (the occurrence whose context matches best), else its words with any spacing; an
784
+ element anchor by its selector if its text still starts with its words (or the
785
+ selector is an id, or it has no words), else the element holding its words. What
786
+ can't be found is `orphaned`, said so; a host keeps the comment and shows it apart.
787
+ - **Rects** are in the frame's viewport, CSS px: add the frame element's position.
788
+ - **Settled.** Nothing is reported before the content is in place (marks shown
789
+ before it arrives are anchored once it has), nor while a run of changes settles; a
790
+ `focus` asked for meanwhile waits for anchoring, then scrolls and reports, so a
791
+ focus right after `show` says where the mark is, never `orphaned` first (P057).
792
+ - **Trust.** The shell shares its realm with the content, which can interfere with
793
+ all of it or fake any report. The host takes a selection only while it asked for
794
+ that kind, a cancel only while picking, results only for its own marks; it
795
+ rebuilds each from known fields within `ANNOTATION_LIMITS` and drops the rest,
796
+ through the bridge's rate limit. UI hints only, never permissions: what a
797
+ report says decides where a comment is drawn, never who may see or do anything.
798
+ - **Host API.** `display.annotations.start(mode?)`, `stop()`, `show(marks)`,
799
+ `focus(id)`, `clear()`, and the `annotation` event (`AnnotationReport`); the state
800
+ is given to every frame the display runs. `parseAnchor` checks an anchor a host
801
+ stores (its server, before saving a comment).
802
+
803
+ Tests (`leak/annotate.spec.ts`, all three engines): a pick outlines and picks
804
+ without the content seeing the click, by id or by path; Escape cancels; a text
805
+ selection and its clearing; both modes together; marks shown, one focused into
806
+ view, one orphaned, all cleared; re-anchoring on a revised page (by id, by words
807
+ moved, by words respaced, one deleted and orphaned); live changes reported only as
808
+ they change; an MCP Apps view; and a hostile page faking every report (not asked,
809
+ malformed, too large, the wrong kind, no such mark, a flood), whose negative
810
+ control is `annotation-checks`.
811
+
812
+ ## The shell runtime
813
+
814
+ The runtime (`src/shell/runtime.ts`, plain ES2020, no build) runs first in every
815
+ frame it serves. Before any content: an in-memory `localStorage` (opaque frames), the
816
+ WebRTC removal (item 3), the frame and hint guards (items 4, 8: the observer, the
817
+ wrapped `attachShadow`, the synchronous insertion and attribute guards), and the
818
+ closed parser entry points (P029). Then, when it speaks for the content: the
819
+ handshake, the theme (P028), content drawn inert while partial and run once when
820
+ whole (a fragment parsed in a template, its scripts recreated in order; a document
821
+ parsed with `DOMParser` and imported, its scripts recreated in order, then
822
+ `DOMContentLoaded` and `load`), size and error reports, and link clicks sent out
823
+ through `openLink`. A view that speaks for itself (`protocol: "none"`, MCP Apps) is
824
+ rendered the same way from the plan, once the shell's document has loaded.
825
+
826
+ ## Streaming
827
+
828
+ Agents write a widget's code over seconds, so the shell renders it as it grows
829
+ (PRINCIPLE 10 — the manual tells the agent to order it styles → markup →
830
+ scripts). While the code is incomplete the shell draws the **markup inert**: it
831
+ parses in a `<template>`, removes anything that runs or navigates (`script`,
832
+ frames, `on*` handlers, `javascript:` URLs) and changes only what changed, so an
833
+ image does not reload and an animation does not restart. When the code is
834
+ complete the shell drops the draft and renders once, running scripts in order
835
+ (external ones awaited). Partial input never runs code. The adapter carries this
836
+ as MCP Apps `tool-input-partial` (0..n) then `tool-input` (once). A leak-suite
837
+ test asserts: markup appears as it streams, no script or handler runs until the
838
+ code is whole, then runs exactly once.
839
+
840
+ ## Theme and base styles
841
+
842
+ The theme vocabulary is the MCP Apps style variables (`--color-background-*`,
843
+ `--color-text-*`, `--color-border-*`, `--color-ring-*`, `--font-*`,
844
+ `--border-radius-*`, `--border-width-regular`, `--shadow-*`) plus Porthole's own
845
+ `--color-accent(-soft)` and chart palette (`--color-chart-1…8`, `-seq-1…7`,
846
+ `-neg/-mid/-pos`), each with a neutral light and dark default
847
+ (`src/theme/variables.ts`). A host supplies live values for the same names from
848
+ its own tokens (`HostOptions.theme`); `resolveTheme` takes a host value for each
849
+ declared name, the default otherwise, and drops undeclared names. The shell
850
+ applies them in place on a flip and fires the theme event; the host's values are
851
+ read two frames after the flip, so a host that restyles on its next frame is read
852
+ after it has (`refreshTheme()` reads them again, P043). A host drops a default it
853
+ doesn't want with `null` in a preset's theme. Its base styles set the body's type
854
+ and color from the variables; base styles for native form elements (buttons,
855
+ inputs) are the only further look Porthole adds, as plain CSS (P015). A whole
856
+ document (an artifact page) takes the person's scheme only when it declares it
857
+ supports both, `<meta name="color-scheme" content="light dark">`, or the host sets
858
+ `theme.documents: "always"`; otherwise it shows as its author styled it (P039).
859
+
860
+ ## Guide generation
861
+
862
+ The manual and the tool description are generated from the same config, lazily,
863
+ so they cannot teach a variable the shell does not inject or a capability the CSP
864
+ blocks (PRINCIPLE 3, 10). Topics: `widget` (what to pass, where it runs, sizes that
865
+ read on a phone, what is not allowed, the network in prose from the CSP's origins,
866
+ the size limit, libraries when scripts may load, the color variables by group,
867
+ diagrams), `chart` (form, look, size, libraries or hand-drawn SVG, the chart
868
+ palette, marks, reading values), and `interactive` (one entry per declared
869
+ capability, with its gates in words, and how state behaves under the isolation
870
+ mode); a host adds its own topics (`guide.topics`) for its own tools. A sentence
871
+ that needs a variable the config doesn't declare, and an example that uses one, is
872
+ left out. The examples (`GUIDE_EXAMPLES`) use only the default theme and no
873
+ network; the leak suite renders each through the real sandbox at phone and desktop
874
+ widths in light and dark and asserts it reads (no overflow, text above a floor
875
+ size) — the guide's examples are themselves conformance cases.
876
+
877
+ The `widget` topic's "The sandbox:" list states every limit that silently changes
878
+ how content behaves, each one line derived from the config (`manualLimits`, P064):
879
+
880
+ | Limit | Says | Stated when | Checks (all three engines) |
881
+ |-------|------|-------------|----------------------------|
882
+ | `silent` | what's blocked shows nothing on the page (nothing loads, a fetch rejects); where it is reported follows `errors` (P067): back to the agent, to the app only, to the app, or (no `reportError`) nowhere | always | `14.silent` |
883
+ | `network` | what loads from where, from the CSP's sources | always | `1.connect`, `2.resources` |
884
+ | `inline-assets` | images, fonts and sound inline as data: URLs or SVG; CSS in a `<style>` | something is open (else Network: none says it) | `14.inline-assets` |
885
+ | `dialogs` | alert/confirm/prompt show nothing: confirm false, prompt null | no `allow-modals` | `14.dialogs` |
886
+ | `print` | `window.print()` does nothing | no `allow-modals` | `14.print` |
887
+ | `downloads` | no `<a download>`, data:/blob: links or script saves; `downloadFile` if declared | no `allow-downloads` | `14.downloads`, `6.download` |
888
+ | `forms` | forms never submit and some browsers fire no submit event: `type="button"` and a click handler | no `allow-forms` | `14.forms` |
889
+ | `link-schemes` | mailto:, tel:, sms: open nothing: show the text | always | `14.link-schemes` |
890
+ | `windows` | window.open and target=_blank open no window (null or a throw); through `openLink` if declared | no `allow-popups` | `14.windows`, `6.open` |
891
+ | `devices` | camera, microphone, location, sharing denied without asking; file inputs and FileReader work | always | `14.devices` |
892
+ | `workers` | blob: Web Workers work; no service workers; no WebRTC | always | `14.workers`, `3.webrtc` |
893
+ | `sound` | sound plays only from a click | always | `14.sound` |
894
+ | `address` | about:srcdoc (opaque) or its own address with Porthole's ticket (domain) | always | `14.address`, `served.domain.address` |
895
+ | `shell` | document.write/open throw; nested frames removed; full screen and clipboard closed | always | `14.shell`, `4.document-write`, `4.light-dom` |
896
+ | `storage` (interactive topic) | per isolation; in domain mode reads and writes can throw: try/catch | always | `14.storage`, `served.domain.storage-scope` |
897
+
898
+ Three more limits sit outside the list:
899
+ - `layout` (where it runs): a card whose height follows its content, or `layout.kind:
900
+ "fill"`, a frame as tall as its box with the page scrolling itself. Checks: `11.size`, `14.fill`.
901
+ - `theme` (where it runs): the person's theme, a page that declares it, or
902
+ `theme.documents: "never"`, always light. Checks: `11.theme-flip`, `11.document-theme`,
903
+ `14.theme-never`.
904
+ - `errors` (interactive): `errors: "agent" | "host" | "none"`. Check: `14.errors`.
905
+
906
+ They are config facts a whole-page viewer needed (P065):
907
+ - `layout.kind: "fill"` makes every mount fill its container (`MountOptions.fill`, React
908
+ `fill`). The manual says the page fills the app's viewer (as tall as that, often smaller than
909
+ the window), that `position: fixed` and `sticky` work, and to read the frame's own width,
910
+ designing for the phone's width first; it drops the card's height cap and the "no
911
+ `position: fixed`" line (0.2.1: the viewer, not the window).
912
+ - `theme.documents: "never"` (pages only) gives the frame light values and a light
913
+ `color-scheme`. A host flip doesn't reach it, so it hears no theme event. The manual says
914
+ style it yourself, and not to rely on `prefers-color-scheme`. Measured with the person on
915
+ dark: inside, that query says light on WebKit and Firefox (they follow the frame element's
916
+ `color-scheme`) and dark on Chromium.
917
+ - `limits.maxBytes` is stated in KB or MB (`Keep it under 10 MB.`).
918
+ - `storage.scope` names what one display's storage is kept for ("this artifact, across its
919
+ versions"), without `storage.shared`'s prefix advice.
920
+ - `errors` says where the content's errors go: back to the agent, to the app only, or nowhere
921
+ (no `reportError`). Absent, the manual keeps its old "reported to the app".
922
+
923
+ `test/limits.test.ts` fails if a limit names no check, names one no suite defines,
924
+ or the list holds a line that isn't a limit. Measured differences, each stated as
925
+ the behavior safe everywhere:
926
+ - submit events fire on WebKit only;
927
+ - `window.open` returns null on Chromium and WebKit and throws on Firefox;
928
+ - reading `navigator.serviceWorker` in an opaque frame throws on Chromium and
929
+ WebKit, and `register` rejects on Firefox;
930
+ - `play()` without a click is refused on Chromium and WebKit, and allowed by
931
+ Playwright's Firefox;
932
+ - Web Share is absent on all three desktop engines.
933
+
934
+ On a page whose own policy closes blob: workers, `14.workers` reports itself
935
+ inapplicable (P058).
936
+
937
+ ## MCP Apps adapter vs AppBridge
938
+
939
+ The official SDK ships `AppBridge` (host side), which extends the MCP SDK's
940
+ `Protocol` class and pulls in the MCP client/core packages. **Decision: Porthole's
941
+ MCP adapter reimplements the thin host-side subset of MCP Apps that maps to
942
+ capabilities; it does not embed AppBridge** (P007), and no AppBridge-backed adapter
943
+ is offered for now (P014). On the **widget side** we reimplement nothing — authors
944
+ use the official `App` SDK, which is exactly the point of speaking the standard.
945
+ Upstream, once the code and tests exist (P018), we contribute what is general: the
946
+ conformance/leak runner, the container-agnostic framing note (a single hardened
947
+ frame instead of the mandated double-iframe, where equally secure), and the
948
+ shadow-root / document-rewrite frame-escape findings.
949
+
950
+ ## Migration plan: the first host (Ferry)
951
+
952
+ Ferry already has a working widget sandbox; it becomes Porthole's first user and
953
+ proves the API is general (PRINCIPLE 9). This section names Ferry only as that
954
+ first adopter. Every Ferry feature that shows agent-made or foreign HTML goes
955
+ through Porthole: widgets (inline widget), shown HTML files (artifact page), and
956
+ the dev-server preview pane (URL mode).
957
+
958
+ **Moved into Porthole** (seeded from Ferry, renamed, wired from the config): the
959
+ shell (`widget-shell.ts`) → `src/shell/`; its CSP builder → `src/compile/policy.ts`
960
+ (now without `'self'`, its CDNs an opt-in, `LIBRARY_CDNS`); the embedding-page CSP
961
+ (`webAppCsp`) → `embedderCsp`; the theme map (`widget-theme.ts`) → the default
962
+ vocabulary and `resolveTheme` (Ferry's rUI token names become its `theme` function;
963
+ its chart palette is the default); the guide (`display-guide.ts`) →
964
+ `src/guide/` (its `file` topic stays Ferry's, as `guide.topics.file`); the frame
965
+ component (`WidgetFrame.tsx`) → the React binding (live-frame cap, heights).
966
+
967
+ **Names that change** for content Ferry already shows: the theme event
968
+ `ferry:theme` → `config.theme.event` (Ferry may keep its name there, P024); the
969
+ error method `ferry/notifications/error` → `notifications/message` (level
970
+ `error`); the tool-input argument `widget_code` → `html` (Ferry maps its tool's
971
+ argument when it calls `setContent`); the root `#ferry-root` → `#porthole-root`.
972
+
973
+ **Stays in Ferry** (product policy and plumbing): the `show_file`/`show_widget`/
974
+ `display_guide` MCP tools and their receipts/log entries; the daemon upload and
975
+ attachment storage; the session-log fold that decides where a display draws; the
976
+ `sendPrompt` send-permission rules (its dialog may stay its own, through
977
+ `confirm`); lazy-mount/live-frame numbers as config; the preview gateway and its
978
+ daemon streams (URL mode frames it); anything tied to Ferry's session model.
979
+
980
+ **Ferry files that change:** `packages/core` drops the moved modules and depends
981
+ on `@botiverse/porthole`, re-exporting what its own tools need; `WidgetFrame.tsx`
982
+ becomes a thin wrapper over `@botiverse/porthole/react` supplying callbacks and
983
+ theme; `apps/server/src/http.ts`, `apps/web/vite.config.ts` and
984
+ `apps/desktop/src/main.ts` take the embedding-page CSP from `embedderCsp`;
985
+ `apps/daemon/src/crew/mcp.ts` builds its tool description and guide from the
986
+ Porthole config; the preview pane frames through the url preset; the native bridge
987
+ keeps carrying `@ferry/core`, which re-exports Porthole's shell. Ferry's display
988
+ decision entry gets a follow-up noting the extraction. The goal is Ferry's widget
989
+ code reduced to a config plus callbacks (OBJECTIVE 4, the dogfooding success
990
+ test).
991
+
992
+ ## The leak / conformance suite
993
+
994
+ A Playwright suite (`@botiverse/porthole/conformance`) that:
995
+
996
+ - drives a **platform host** (the web iframe host, and later others) through an
997
+ **adapter**, via a small host **test interface** (`ConformanceHost`: launch a
998
+ display in a fresh page; answer confirmations; read the calls that reached the
999
+ handlers and the errors; flip the theme) so the suite is not coupled to any one
1000
+ host's DOM — aligned with the upstream conformance-interface effort so results
1001
+ can feed it;
1002
+ - is registered by the host in a spec file of its own (`leakTests(host)`,
1003
+ `servedTests(host)`: one entry per probe, `test(each.title, ({ page, browserName },
1004
+ info) => each.run({ page, browserName }, info))`), so its tests are located there
1005
+ and selected by that file or a probe id; it runs on the host's own Playwright, which
1006
+ Porthole doesn't declare (P041);
1007
+ - runs every egress and escape probe in the table above, each attempting its
1008
+ channel and asserting nothing leaves, using out-of-band oracles (`httpOracle`: a
1009
+ local HTTP server that also counts TCP connections; `udpOracle`: a UDP listener
1010
+ for STUN), each with its positive control;
1011
+ - runs the MCP Apps examples unmodified and checks the lifecycle and interaction;
1012
+ - renders the generated guide's examples and checks they read at the target
1013
+ widths and themes.
1014
+
1015
+ Porthole runs it against its own reference host (`leak/host/reference.ts`, the real
1016
+ `createHost` in a page; `leak/reference-host.ts`, its `ConformanceHost`; served by
1017
+ `leak/server.ts`, which serves the core's TypeScript stripped as Node does, with the
1018
+ embedding CSP `embedderCsp` derives): `pnpm test:leak` runs Chromium, WebKit and
1019
+ Firefox;
1020
+ `PORTHOLE_LEAK_ENGINES=chromium` runs a subset where an engine's system libraries
1021
+ are missing; `PORTHOLE_LEAK_WEAKEN=<control>[,<control>]` serves the core with
1022
+ defenses taken out (P032; the table above lists what each must fail).
1023
+ `ConformanceHost.launch` called twice on one page shows two displays side by side
1024
+ (`9.siblings`).
1025
+
1026
+ ## Milestones
1027
+
1028
+ The core (this repository's lead work), in order; each lands when its tests pass:
1029
+
1030
+ 1. **M1 — scaffold, config, compiler, presets, interfaces** (done). The workspace
1031
+ and tooling; the config schema and validation; the compiler (CSP, sandbox and
1032
+ `allow`, embedder CSP, shell plan and document, guide and tool description) with
1033
+ the agreement test; the presets as configs; the public interfaces above as typed
1034
+ stubs; the shell's wiring checked in real frames on Chromium and WebKit.
1035
+ 2. **M2 — isolation core, shell runtime, egress hardening** (done). The web
1036
+ platform host and transport, `createHost` mounting opaque, domain and url frames
1037
+ (served frames checked by `checkFrameLocation`), the shell adapter and the bridge,
1038
+ the reference host and `defineLeakSuite`, items 1–10 above each with its probes,
1039
+ positive controls and negative controls, green on Chromium and WebKit; the
1040
+ official App SDK connecting under every preset.
1041
+ 3. **M3 — streaming, auto-size, theming, the gated bridge** (done). Streaming
1042
+ drafts through the host API, size reporting under the limits, theme apply and
1043
+ flip with the host's live values, base styles for native form elements, and the
1044
+ host-function bridge's gates, each proven in real frames (items 11, 12) with
1045
+ negative controls, and in unit tests (`test/bridge.test.ts`,
1046
+ `test/shell-adapter.test.ts`).
1047
+ 4. **M4 — the React binding and its default confirmation dialog** (done).
1048
+ `<PortholeProvider>`, `<PortholeFrame>`, `ConfirmDialog`: streaming through props,
1049
+ height memory per display, the live-frame cap with "Run again", theme flips,
1050
+ navigation recovery, generations, and the default, replaceable dialog, each
1051
+ proven in a real browser.
1052
+ 5. **M5 — Firefox** in the gate (done): every Playwright suite (leak, served, MCP
1053
+ examples, React) runs on Chromium, WebKit and Firefox, and every negative control
1054
+ was run on Firefox; its differences are recorded in P035.
1055
+
1056
+ In parallel, against the interfaces: the **MCP Apps adapter** (`/mcp`, running the
1057
+ official ext-apps examples unmodified); the **served modes** (domain mode, `/server`,
1058
+ the artifact page served, the MCP Apps sandbox proxy, URL mode); and the **Ferry
1059
+ migration**.
1060
+
1061
+ ## Open questions
1062
+
1063
+ 1. ~~URL mode's sandbox tokens~~: popups, modals and downloads are a host's
1064
+ opt-in (P037); clipboard writes and full screen too (P049).
1065
+ 2. ~~`'self'` in the embedding page's `frame-src`~~: measured and dropped (P033).
1066
+ 3. ~~Clipboard writes from agent-written content~~: closed (P051); copy buttons go
1067
+ through the host (`copyText`, P052).