@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/dist/conformance/entry.d.ts +8 -2
- package/dist/conformance/entry.js +23 -8
- package/dist/conformance/host-kit.d.ts +8 -3
- package/dist/conformance/host-kit.js +58 -23
- package/dist/conformance/served.d.ts +4 -4
- package/dist/conformance/served.js +26 -16
- package/dist/node/index.d.ts +11 -3
- package/dist/node/index.js +70 -10
- package/dist/server/host.d.ts +42 -8
- package/dist/server/host.js +35 -5
- package/dist/server/index.d.ts +3 -1
- package/dist/server/pages.js +15 -1
- package/dist/server/proxy.js +2 -0
- package/dist/server/server.js +9 -2
- package/dist/server/types.d.ts +15 -6
- package/docs/ARCHITECTURE.md +277 -0
- package/docs/DECISIONS.md +1774 -0
- package/docs/DESIGN.md +1067 -0
- package/docs/OBJECTIVES.md +116 -0
- package/docs/PRINCIPLES.md +155 -0
- package/package.json +4 -3
- package/src/conformance/entry.ts +39 -10
- package/src/conformance/host-kit.ts +71 -28
- package/src/conformance/served.ts +34 -21
- package/src/node/index.ts +73 -10
- package/src/server/host.ts +84 -14
- package/src/server/index.ts +4 -0
- package/src/server/pages.ts +15 -1
- package/src/server/proxy.ts +2 -0
- package/src/server/server.ts +11 -2
- package/src/server/types.ts +18 -7
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).
|