@botiverse/porthole 0.2.0 → 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/harness.js +4 -4
- package/dist/conformance/host-kit.d.ts +8 -3
- package/dist/conformance/host-kit.js +58 -23
- package/dist/conformance/limits.js +6 -0
- package/dist/conformance/served.d.ts +4 -4
- package/dist/conformance/served.js +26 -16
- package/dist/guide/guide.js +4 -4
- package/dist/guide/limits.js +1 -1
- 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/limits.ts +8 -0
- package/src/conformance/served.ts +34 -21
- package/src/guide/guide.ts +4 -4
- package/src/guide/limits.ts +1 -1
- 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
|
@@ -0,0 +1,1774 @@
|
|
|
1
|
+
# Decision log
|
|
2
|
+
|
|
3
|
+
Append-only. Each entry records the decision, why, what we rejected, and **when to
|
|
4
|
+
revisit**. To change a decision, add a new entry that supersedes the old one and
|
|
5
|
+
mark the old one `Superseded by Pxxx`. The "revisit when" line is the most
|
|
6
|
+
important part.
|
|
7
|
+
|
|
8
|
+
Format: `Pxxx · YYYY-MM-DD · Title · Status`
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## P001 · 2026-10-11 · Two layers: a framework of mechanisms, and a preset policy layer · Accepted
|
|
13
|
+
|
|
14
|
+
**Decision.** Split the library into a framework layer (isolation, egress
|
|
15
|
+
hardening, shell runtime, streaming, sizing, theming, the host-function bridge,
|
|
16
|
+
the guide generator — mechanisms only, no policy) and a preset policy layer
|
|
17
|
+
(inline-widget, artifact, mcp-app — named configs with safe defaults). A host
|
|
18
|
+
adopts a preset, passes theme tokens and callbacks, and gets a safe display plus
|
|
19
|
+
the agent manual.
|
|
20
|
+
**Why.** Policy (which origins, which confirmations, what theme) is
|
|
21
|
+
product-specific and changes faster than mechanism. Separating them lets the
|
|
22
|
+
mechanisms be audited once and reused, while hosts get "out of the box" from the
|
|
23
|
+
presets without touching internals.
|
|
24
|
+
**Rejected.** One monolithic widget component (couples mechanism to one policy);
|
|
25
|
+
mechanisms with no presets (every host re-derives a safe config and most get it
|
|
26
|
+
wrong).
|
|
27
|
+
**Revisit when** a host needs a mode no preset covers, or the preset set grows
|
|
28
|
+
enough to need its own structure.
|
|
29
|
+
|
|
30
|
+
## P002 · 2026-10-11 · The core abstraction is agent-drawn HTML plus declared host functions · Accepted
|
|
31
|
+
|
|
32
|
+
**Decision.** The content may draw freely; every power beyond drawing is a host
|
|
33
|
+
function (capability) named in the config, each declaring its gate, its egress
|
|
34
|
+
needs, and its guide entry. Sending a prompt to the agent is one such capability,
|
|
35
|
+
not a privileged built-in.
|
|
36
|
+
**Why.** One capability list then drives the bridge, the CSP and the manual
|
|
37
|
+
together (P003), and "what can this content do?" is answered by reading the
|
|
38
|
+
config. Treating agent-interaction as ordinary keeps the model uniform and makes
|
|
39
|
+
hosts that don't want it simply omit it.
|
|
40
|
+
**Rejected.** A fixed built-in set of powers (inextensible, and couples the core
|
|
41
|
+
to one product's needs); ambient powers granted implicitly (unauditable).
|
|
42
|
+
**Revisit when** a capability needs structure the flat list can't express (e.g.
|
|
43
|
+
capabilities that compose).
|
|
44
|
+
|
|
45
|
+
## P003 · 2026-10-11 · One config is the single source for the CSP, the shell, and the manual · Accepted
|
|
46
|
+
|
|
47
|
+
**Decision.** The CSP, the iframe sandbox/`allow` attributes, the in-frame shell
|
|
48
|
+
wiring, and the agent guide + tool description are all generated from one config
|
|
49
|
+
by a pure compiler. A test asserts the three outputs agree (the guide names only
|
|
50
|
+
injected variables and allowed capabilities; the CSP allows only what the manual
|
|
51
|
+
teaches).
|
|
52
|
+
**Why.** These artifacts must agree to be safe and usable; drift between them is
|
|
53
|
+
the most common defect in hand-built sandboxes, and a manual that promises what
|
|
54
|
+
the sandbox blocks wastes the agent's turns.
|
|
55
|
+
**Rejected.** A hand-maintained guide; a literal CSP string no test ties to the
|
|
56
|
+
capabilities.
|
|
57
|
+
**Revisit when** a host needs to hand-tune one artifact — then the override must
|
|
58
|
+
flow back through the config, never edited in place.
|
|
59
|
+
|
|
60
|
+
## P004 · 2026-10-11 · The external contract is MCP Apps; the host side is protocol-pluggable · Accepted
|
|
61
|
+
|
|
62
|
+
**Decision.** Agent-written HTML speaks MCP Apps (`io.modelcontextprotocol/ui`,
|
|
63
|
+
2026-01-26). We do not define a widget-side protocol; gaps go through the spec's
|
|
64
|
+
extension points (experimental capabilities, own-prefixed methods) and, when
|
|
65
|
+
general, upstream. Internally the host side is protocol-pluggable: a thin adapter
|
|
66
|
+
maps capabilities/lifecycle onto a wire protocol, with MCP Apps the first and
|
|
67
|
+
default adapter.
|
|
68
|
+
**Why.** Standard agent-UI is only valuable if one widget runs in many hosts and
|
|
69
|
+
one host renders many widgets; a bespoke protocol forfeits that. A pluggable
|
|
70
|
+
inside keeps the core host-agnostic and lets a second protocol be an adapter, not
|
|
71
|
+
a fork.
|
|
72
|
+
**Rejected.** A new wire format; coupling the core to one protocol SDK's object
|
|
73
|
+
model.
|
|
74
|
+
**Revisit when** the spec changes incompatibly, or a second protocol is needed.
|
|
75
|
+
|
|
76
|
+
## P005 · 2026-10-11 · Phase-1 isolation is an opaque origin; domain mode is designed, built later · Superseded in part by P016 (domain mode is phase 1)
|
|
77
|
+
|
|
78
|
+
**Decision.** Phase 1 isolates content in an opaque-origin frame (sandboxed
|
|
79
|
+
without `allow-same-origin`): a different origin from the host with no persistent
|
|
80
|
+
storage. The domain mode (a per-instance subdomain on a separate registrable
|
|
81
|
+
domain, giving a real, stable origin with storage) is designed in phase 1 and
|
|
82
|
+
built in phase 2.
|
|
83
|
+
**Why.** An opaque origin is the cheapest isolation that fully solves the
|
|
84
|
+
host-attack class and needs no server, domain or certificate — so phase 1 ships
|
|
85
|
+
without infrastructure. Stable storage and a real origin require serving
|
|
86
|
+
infrastructure that is phase-2 work.
|
|
87
|
+
**Rejected.** Shipping domain mode in phase 1 (pulls server/DNS/cert work into
|
|
88
|
+
the first release); same-origin isolation with a strict CSP (a CSP mistake then
|
|
89
|
+
becomes host takeover — isolation must be the floor, not the CSP).
|
|
90
|
+
**Revisit when** a host needs persistent per-instance state or a stable origin
|
|
91
|
+
(then build domain mode).
|
|
92
|
+
|
|
93
|
+
## P006 · 2026-10-11 · Egress is closed by default, including channels a frame CSP doesn't cover · Accepted
|
|
94
|
+
|
|
95
|
+
**Decision.** All outbound network and navigation is closed by default and opened
|
|
96
|
+
only to origins a capability declares. Beyond `connect-src`/resource directives,
|
|
97
|
+
the shell closes channels a frame's own CSP cannot: it removes the WebRTC
|
|
98
|
+
constructors (a peer connection reaches the network via ICE outside
|
|
99
|
+
`connect-src`), removes nested frames the content adds — including inside shadow
|
|
100
|
+
roots and after a content-initiated document rewrite (a nested frame runs in a
|
|
101
|
+
fresh realm where the removals never happened) — and the embedding page sets
|
|
102
|
+
`frame-src` to stop the frame navigating itself out with data in the URL. Each
|
|
103
|
+
item has a leak-suite probe (P008).
|
|
104
|
+
**Why.** Origin isolation stops the host-attack class but not exfiltration of
|
|
105
|
+
what the content already holds; egress hardening does. These three channels sit
|
|
106
|
+
outside a frame's own CSP, so they need in-shell defenses and the embedder's
|
|
107
|
+
policy, verified by measurement because engines differ.
|
|
108
|
+
**Rejected.** Trusting `connect-src` alone (misses WebRTC, nested-frame realms,
|
|
109
|
+
self-navigation); trusting one CSP directive honored unevenly across engines.
|
|
110
|
+
**Revisit when** an engine changes how it honors `webrtc`/`frame-src`, or a new
|
|
111
|
+
egress channel is found (add the probe first).
|
|
112
|
+
|
|
113
|
+
## P007 · 2026-10-11 · The MCP adapter reimplements the host-side subset; the widget uses the official SDK · Accepted; amended by P014 (no AppBridge adapter) and P018 (upstream timing)
|
|
114
|
+
|
|
115
|
+
**Decision.** The MCP Apps adapter reimplements the thin host-side subset of the
|
|
116
|
+
protocol that maps to capabilities, rather than embedding the official
|
|
117
|
+
`AppBridge`. On the widget side we reimplement nothing — authors use the official
|
|
118
|
+
`App` SDK. An AppBridge-backed adapter is offered as an optional, separate entry.
|
|
119
|
+
We contribute upstream what is general: the conformance/leak runner, the
|
|
120
|
+
container-agnostic framing note, and the frame-escape findings.
|
|
121
|
+
**Why.** `AppBridge` extends the MCP SDK's `Protocol` class and pulls in its
|
|
122
|
+
client/core packages; embedding it would tie the host-agnostic core to that
|
|
123
|
+
dependency tree and class hierarchy (against P004/P007-core-agnostic) for a
|
|
124
|
+
subset we can state and test directly against the reference examples. The widget
|
|
125
|
+
side is where speaking the standard matters, so there we use the real SDK.
|
|
126
|
+
**Rejected.** Embedding AppBridge in the core (heavy coupling); reimplementing the
|
|
127
|
+
widget side (defeats the point of the standard).
|
|
128
|
+
**Revisit when** the host-side subset grows large or the SDK sheds its coupling
|
|
129
|
+
(then prefer wrapping), or a host standardized on the MCP SDK needs the
|
|
130
|
+
AppBridge adapter sooner.
|
|
131
|
+
|
|
132
|
+
## P008 · 2026-10-11 · A conformance/leak suite is first-class, cross-engine, and host-runnable · Accepted; engine order amended by P017
|
|
133
|
+
|
|
134
|
+
**Decision.** A standalone Playwright suite drives a platform host through an
|
|
135
|
+
adapter via a small host test interface, attempts every egress and escape
|
|
136
|
+
channel, and asserts nothing leaks — on Chromium, WebKit and Firefox. It also
|
|
137
|
+
runs the MCP Apps examples and renders the generated guide's examples. Any host
|
|
138
|
+
can run it against its own integration. A hardening item exists only if a test
|
|
139
|
+
proves it (PRINCIPLE 5), and isolation facts are decided by measurement, not
|
|
140
|
+
assumption (PRINCIPLE 6).
|
|
141
|
+
**Why.** Browser isolation behavior changes across versions and engines; only an
|
|
142
|
+
executed probe shows the wall is there and keeps it there. Making the suite
|
|
143
|
+
host-runnable turns it into the proof hosts can trust and the artifact we offer
|
|
144
|
+
upstream.
|
|
145
|
+
**Rejected.** Unit-testing CSP strings only (doesn't prove the browser enforces
|
|
146
|
+
them); a single-engine suite (misses engine-specific gaps).
|
|
147
|
+
**Revisit when** a target engine is dropped or added, or an upstream conformance
|
|
148
|
+
interface stabilizes (align to it).
|
|
149
|
+
|
|
150
|
+
## P009 · 2026-10-11 · Web first: a framework-agnostic core plus a thin React binding · Accepted
|
|
151
|
+
|
|
152
|
+
**Decision.** Ship a framework-agnostic TypeScript core and a separate thin React
|
|
153
|
+
binding first. Native hosts (WKWebView, maybe Android) come later behind the same
|
|
154
|
+
platform-host interface. Tooling: Node ≥ 24 with type stripping (erasable syntax
|
|
155
|
+
only), pnpm workspace, oxlint + oxfmt, vitest or node:test, Playwright.
|
|
156
|
+
**Why.** The web is where the first host lives, and a framework-agnostic core
|
|
157
|
+
keeps the library usable by non-React hosts and portable to native engines (the
|
|
158
|
+
shell is plain dependency-free script). The toolchain matches the wider workspace
|
|
159
|
+
so agents and contributors share one setup.
|
|
160
|
+
**Rejected.** React-only (excludes other hosts and native); a bundler-heavy build
|
|
161
|
+
(type stripping needs none).
|
|
162
|
+
**Revisit when** a native host is built, or a second UI framework needs a binding.
|
|
163
|
+
|
|
164
|
+
## P010 · 2026-10-11 · Apache-2.0, private for now · Accepted
|
|
165
|
+
|
|
166
|
+
**Decision.** License Apache-2.0, copyright Botiverse. The repository stays
|
|
167
|
+
private during phase 1.
|
|
168
|
+
**Why.** Apache-2.0 is permissive with an explicit patent grant, standard for a
|
|
169
|
+
library meant for broad embedding. Staying private during design keeps the shape
|
|
170
|
+
settling before it is public.
|
|
171
|
+
**Rejected.** MIT (no patent grant); public from day one (premature while the API
|
|
172
|
+
is unsettled).
|
|
173
|
+
**Revisit when** phase 1 is done and the owner decides to open it.
|
|
174
|
+
|
|
175
|
+
## P011 · 2026-10-11 · The first host is a normal user of the public API · Accepted
|
|
176
|
+
|
|
177
|
+
**Decision.** The first adopter migrates onto Porthole through the public API
|
|
178
|
+
with no privileged path; anything it needs goes into the config or a documented
|
|
179
|
+
extension point any host could use. The first host's existing shell, theme map,
|
|
180
|
+
embedding-page CSP, frame component and guide seed the framework layer, then are
|
|
181
|
+
consumed from the packages.
|
|
182
|
+
**Why.** A library shaped around one caller's internals is not reusable and leaks
|
|
183
|
+
that caller's assumptions into the core. Dogfooding by a real host is the test
|
|
184
|
+
that the API is general (OBJECTIVE 4).
|
|
185
|
+
**Rejected.** Private hooks for the first host; keeping the extraction in the
|
|
186
|
+
host's own repo (then it is not a library).
|
|
187
|
+
**Revisit when** a second host adopts it (confirm nothing was first-host-specific).
|
|
188
|
+
|
|
189
|
+
## P012 · 2026-10-11 · Three modes: inline widget, MCP Apps view, artifact page; URL mode out of scope · Superseded in part by P020 (URL mode is phase 1)
|
|
190
|
+
|
|
191
|
+
**Decision.** Support inline widget, MCP Apps view, and artifact page (a
|
|
192
|
+
host-control spectrum from host-injected code to a host-served document). URL mode
|
|
193
|
+
(embedding a running app by URL, with only origin isolation and access auth, no
|
|
194
|
+
shell, no egress control) is out of scope for now.
|
|
195
|
+
**Why.** The three modes cover showing agent-made HTML the host or an MCP server
|
|
196
|
+
produced, which is the stated subject. URL mode is a different problem (isolating
|
|
197
|
+
a third party's live site) with little shared mechanism beyond origin isolation,
|
|
198
|
+
and no host needs it yet.
|
|
199
|
+
**Rejected.** Adding URL mode in phase 1 (scope creep for a mode nobody needs);
|
|
200
|
+
dropping the artifact page (it is the standalone-page half of the stated goal).
|
|
201
|
+
**Revisit when** a host needs to embed a live external app, or the artifact and
|
|
202
|
+
URL modes turn out to share enough to justify both.
|
|
203
|
+
|
|
204
|
+
## P013 · 2026-10-11 · One package, `@botiverse/porthole`, with subpath exports · Accepted
|
|
205
|
+
|
|
206
|
+
**Decision.** Porthole ships as one package: the core at `@botiverse/porthole`, and
|
|
207
|
+
`/react`, `/mcp`, `/conformance` and `/server` as subpath exports of it. Optional
|
|
208
|
+
peers (`react`, `@playwright/test`) are needed only by the subpaths that use them.
|
|
209
|
+
The workspace exports the TypeScript sources directly; `pnpm build` emits JS and
|
|
210
|
+
declarations to `dist/`, which a packed or published package exports instead
|
|
211
|
+
(`publishConfig.exports`), because Node does not strip types under `node_modules`.
|
|
212
|
+
**Why.** The shell runtime, the compiler and the adapters must move in lockstep (a
|
|
213
|
+
shell from one version and a host from another is an untested pair); one version
|
|
214
|
+
makes that impossible to get wrong. The seams are module boundaries, which keep the
|
|
215
|
+
core free of React, Playwright and server code without separate packages.
|
|
216
|
+
**Rejected.** Six packages (`-react`, `-mcp`, `-components`, `-conformance`,
|
|
217
|
+
`-server`): version skew between core and shell, more release work, no isolation
|
|
218
|
+
gain.
|
|
219
|
+
**Revisit when** a subpath's dependencies or release cadence burden hosts that don't
|
|
220
|
+
use it.
|
|
221
|
+
|
|
222
|
+
## P014 · 2026-10-11 · No AppBridge-backed adapter for now · Accepted
|
|
223
|
+
|
|
224
|
+
**Decision.** Porthole ships only its own MCP Apps adapter (P007); the optional
|
|
225
|
+
AppBridge-backed adapter P007 offered is not built.
|
|
226
|
+
**Why.** No host has asked for it, and every adapter is surface the leak suite must
|
|
227
|
+
cover.
|
|
228
|
+
**Rejected.** Building it speculatively in phase 1.
|
|
229
|
+
**Revisit when** a host standardized on the MCP SDK asks for it.
|
|
230
|
+
|
|
231
|
+
## P015 · 2026-10-11 · Content is plain HTML/SVG plus the generated manual; no component vocabulary · Accepted
|
|
232
|
+
|
|
233
|
+
**Decision.** What an agent writes for Porthole is plain HTML and SVG, taught by the
|
|
234
|
+
generated manual. A consistent look comes from the injected theme variables and, at
|
|
235
|
+
most, plain CSS the shell ships (such as base styles for native form elements),
|
|
236
|
+
never from a component vocabulary. The "preset components" of the first design (a
|
|
237
|
+
stat tile, chart primitive, table and elicitation form as custom elements) are
|
|
238
|
+
dropped; the config has no `components` field.
|
|
239
|
+
**Why.** The owner: no DSL-like things for now, and a component vocabulary is one:
|
|
240
|
+
the agent must learn tag names and props, Porthole must version them, and content
|
|
241
|
+
stops being portable HTML. Theme variables already give content the host's look in
|
|
242
|
+
light and dark.
|
|
243
|
+
**Rejected.** Preset web components inside the sandbox; a host-extensible
|
|
244
|
+
component list.
|
|
245
|
+
**Revisit when** agents repeatedly hand-roll the same structure badly in a way base
|
|
246
|
+
styles and the manual can't fix.
|
|
247
|
+
|
|
248
|
+
## P016 · 2026-10-11 · Domain mode is phase 1 · Accepted
|
|
249
|
+
|
|
250
|
+
**Decision.** Supersedes P005's timing. Domain mode is built in phase 1: content is
|
|
251
|
+
served on a registrable domain the host uses for nothing else, one subdomain per
|
|
252
|
+
storage scope (the label keyed from the host's scope with a secret: stable, DNS-safe,
|
|
253
|
+
not guessable), so each scope has a real, stable origin and its storage persists.
|
|
254
|
+
Documents go out with their CSP, plus `frame-ancestors` naming the host origins, in
|
|
255
|
+
HTTP headers. Private content is reached with a one-time ticket the host's backend
|
|
256
|
+
mints (`/server`'s `issue`) and the content origin exchanges for a cookie of that
|
|
257
|
+
origin only. `/server` is a fetch-style request handler a host's backend mounts.
|
|
258
|
+
The served frame's sandbox adds `allow-same-origin`.
|
|
259
|
+
**Threat it re-opens.** `allow-scripts allow-same-origin` lets a frame act with its
|
|
260
|
+
own origin's full powers; if that origin were same-site with the host, it could
|
|
261
|
+
reach cookies and storage the host shares with its site. Bounded by: the content
|
|
262
|
+
domain is a separate registrable domain; a mount refuses a content origin that is
|
|
263
|
+
same-site with the host; and the leak suite's item 9 (no reach into the host) must
|
|
264
|
+
pass in domain mode, shipped with the domain-mode work.
|
|
265
|
+
**Why.** Hosts need stable storage and a real origin for some content (and the MCP
|
|
266
|
+
Apps sandbox proxy needs a second origin anyway); building it now keeps the served
|
|
267
|
+
modes from being designed in the abstract.
|
|
268
|
+
**Rejected.** Holding it to phase 2 (P005).
|
|
269
|
+
**Revisit when** browsers' storage partitioning or third-party cookie rules change
|
|
270
|
+
how a framed origin keeps state.
|
|
271
|
+
|
|
272
|
+
## P017 · 2026-10-11 · Leak-suite gate: Chromium and WebKit first, Firefox right after · Accepted; completed by P035
|
|
273
|
+
|
|
274
|
+
**Decision.** Amends P008's engine order. A hardening milestone is done when its
|
|
275
|
+
probes pass on Chromium and WebKit; Firefox joins the gate as the next milestone,
|
|
276
|
+
not later in the phase. The suite selects engines with `PORTHOLE_LEAK_ENGINES`;
|
|
277
|
+
where an engine's system libraries are missing (WebKit on the shared build machine),
|
|
278
|
+
it runs on a machine that has them.
|
|
279
|
+
**Why.** Chromium and WebKit cover the first host's web and iOS users and differ
|
|
280
|
+
most on the directives that matter (`webrtc`, `frame-src` for `srcdoc`); Firefox
|
|
281
|
+
follows quickly so no engine's gaps go unmeasured for long (PRINCIPLE 6).
|
|
282
|
+
**Rejected.** Waiting for all three before any milestone lands; dropping Firefox
|
|
283
|
+
from phase 1.
|
|
284
|
+
**Revisit when** an engine is added or dropped.
|
|
285
|
+
|
|
286
|
+
## P018 · 2026-10-11 · Upstream contributions once code and tests exist · Accepted
|
|
287
|
+
|
|
288
|
+
**Decision.** Issues and proposals to the MCP Apps spec and SDK (the conformance
|
|
289
|
+
runner, the framing note, the frame-escape findings) go upstream once Porthole has
|
|
290
|
+
the code and tests that back them, not before.
|
|
291
|
+
**Why.** Evidence makes a proposal actionable; a design without a runnable suite is
|
|
292
|
+
an opinion.
|
|
293
|
+
**Rejected.** Filing now from the design alone.
|
|
294
|
+
**Revisit when** the leak suite and the MCP Apps adapter pass on two engines.
|
|
295
|
+
|
|
296
|
+
## P019 · 2026-10-11 · The React binding ships a default, replaceable confirmation dialog · Accepted
|
|
297
|
+
|
|
298
|
+
**Decision.** `@botiverse/porthole/react` draws a default confirmation dialog
|
|
299
|
+
(outside every frame) for `confirm`-gated calls: the whole effect (the prompt's full
|
|
300
|
+
text, the link's full URL), the display's title, Cancel focused, and the accept
|
|
301
|
+
button ignoring presses for a moment after it opens. A host replaces it by passing
|
|
302
|
+
its own `confirm` function. The core never draws anything; `confirm` there is only a
|
|
303
|
+
function, and without one a gated call is refused.
|
|
304
|
+
**Why.** "Out of the box" needs a working confirmation; PRINCIPLE 8 rules out UI the
|
|
305
|
+
host can't control, and this one the host replaces with one prop.
|
|
306
|
+
**Rejected.** No default (every host must build a dialog before anything works);
|
|
307
|
+
a dialog in the core (couples the core to a UI framework).
|
|
308
|
+
**Revisit when** hosts routinely restyle rather than replace it (then expose its
|
|
309
|
+
parts).
|
|
310
|
+
|
|
311
|
+
## P020 · 2026-10-11 · URL mode is phase 1: frame, isolation and access auth only · Accepted
|
|
312
|
+
|
|
313
|
+
**Decision.** Supersedes P012's exclusion of URL mode: the first host's dev-server
|
|
314
|
+
preview pane must dogfood Porthole. URL mode frames a running app by URL with
|
|
315
|
+
isolation and access auth only: no shell, no capabilities, no manual, no egress
|
|
316
|
+
control (the app is someone else's, on its own origin). Its config is the `url`
|
|
317
|
+
preset (`mode: "url"`, `isolation: "external"`, its `contentOrigins`); the embedding
|
|
318
|
+
page's `frame-src` lists those origins; access goes through the same ticket → cookie
|
|
319
|
+
exchange as domain mode. The frame's baseline sandbox is `allow-scripts
|
|
320
|
+
allow-same-origin allow-forms`: no popups, downloads, modals or top navigation.
|
|
321
|
+
**Threat it re-opens.** The framed app runs with its own origin's powers and can
|
|
322
|
+
submit forms. Bounded by: its origin must be a separate site from the host's (as in
|
|
323
|
+
P016); it gets no host function and nothing of the host's; the embedding page frames
|
|
324
|
+
only the declared content origins. The served-modes work ships the probes for this
|
|
325
|
+
(no reach into the host; no top navigation; no popup) and refines the sandbox tokens
|
|
326
|
+
with a decision entry if a real app needs more.
|
|
327
|
+
**Why.** A preview pane is "show content the host didn't write" too, and building it
|
|
328
|
+
on Porthole keeps one audited framing path in the host.
|
|
329
|
+
**Rejected.** Leaving the preview on the host's own code (two framing paths to
|
|
330
|
+
audit); giving URL mode a shell or egress control (it is a live app, not content
|
|
331
|
+
Porthole draws).
|
|
332
|
+
**Revisit when** a host needs URL-mode frames to talk to it (then that is a
|
|
333
|
+
protocol, through an adapter).
|
|
334
|
+
|
|
335
|
+
## P021 · 2026-10-11 · Gates are a list that all must pass, safe by default, failing closed · Accepted
|
|
336
|
+
|
|
337
|
+
**Decision.** A capability's gates are a list (`user-activation`, `confirm`,
|
|
338
|
+
`{ rateLimit }`); every one must pass. A config that names no gates gets the
|
|
339
|
+
vocabulary's defaults (`sendPrompt` and `openLink`: a gesture and a confirmation;
|
|
340
|
+
`callTool`: a confirmation; reads and context updates: rate limits); `[]` is an
|
|
341
|
+
explicit "ungated". `confirm` is enforced by the framework calling the host's
|
|
342
|
+
`confirm` function; with none, the call is refused. `user-activation` is checked by
|
|
343
|
+
the shell and, where the engine passes a frame's gesture on to its ancestors
|
|
344
|
+
(measured by the leak suite), by the host too; the shell's check shares the
|
|
345
|
+
content's realm, so it stops calls made on load or on a timer, not a determined
|
|
346
|
+
author: the confirmation is the wall.
|
|
347
|
+
**Why.** The first design's single `gate` could not say "a gesture and a
|
|
348
|
+
confirmation", which the first host already requires for prompts. Defaults that are
|
|
349
|
+
safe and a refusal without a `confirm` function mean a host that forgets something
|
|
350
|
+
gets a call that doesn't run, not one that runs unasked (PRINCIPLE 1).
|
|
351
|
+
**Rejected.** One gate per capability; treating the shell's activation check as a
|
|
352
|
+
wall.
|
|
353
|
+
**Revisit when** a gate needs to depend on the call's arguments (then the host's
|
|
354
|
+
`confirm` decides, as it does for tool calls today).
|
|
355
|
+
|
|
356
|
+
## P022 · 2026-10-11 · Every preset starts with egress closed; library CDNs are a one-line opt-in · Accepted
|
|
357
|
+
|
|
358
|
+
**Decision.** Every preset's base egress is empty: nothing loads from outside and
|
|
359
|
+
every fetch fails. `LIBRARY_CDNS` (cdnjs, jsDelivr, unpkg, esm.sh for scripts,
|
|
360
|
+
styles, fonts, images and data; Google Fonts) is exported for a host to open with
|
|
361
|
+
one line, and the manual teaches libraries (and pinning them) only when scripts may
|
|
362
|
+
load. The frame's CSP never allows `'self'`: an opaque frame has nothing of its own
|
|
363
|
+
to load, and its `'self'` may be the embedding page's origin.
|
|
364
|
+
**Why.** P006 says egress opens only to what a config declares; a preset that opened
|
|
365
|
+
CDNs by default would be a default the host never chose. The CDNs are read-only
|
|
366
|
+
package caches (reviewed by the first host), so opening them is a reasonable host
|
|
367
|
+
choice, but it is the host's.
|
|
368
|
+
**Rejected.** CDNs on by default (the first host's previous posture); per-path CDN
|
|
369
|
+
allowlists (break ESM imports and data fetches for no egress gain).
|
|
370
|
+
**Revisit when** most hosts open the CDNs anyway (then consider a preset variant,
|
|
371
|
+
by a decision that names the residual supply-chain risk).
|
|
372
|
+
|
|
373
|
+
## P023 · 2026-10-11 · No `readState`/`writeState` capabilities · Accepted
|
|
374
|
+
|
|
375
|
+
**Decision.** The vocabulary drops `readState` and `writeState`. An opaque frame
|
|
376
|
+
keeps an in-memory `localStorage` for its life; content that needs storage that
|
|
377
|
+
persists runs in domain mode (P016), where `localStorage` is real and scoped by the
|
|
378
|
+
host's storage scope.
|
|
379
|
+
**Why.** Domain mode is now phase 1 and gives real storage without a protocol of our
|
|
380
|
+
own (MCP Apps has no state method, so these would have been Porthole-only methods).
|
|
381
|
+
**Rejected.** Own-prefixed state methods backed by the host.
|
|
382
|
+
**Revisit when** a host needs persisted state for opaque frames.
|
|
383
|
+
|
|
384
|
+
## P024 · 2026-10-11 · The shell reports errors with MCP's log notification; its theme event is named by the config · Accepted
|
|
385
|
+
|
|
386
|
+
**Decision.** The shell reports what content throws, and what the sandbox blocks, as
|
|
387
|
+
the standard `notifications/message` (level `error`, logger `porthole`), which the
|
|
388
|
+
host's adapter turns into the `reportError` capability. The window event the shell
|
|
389
|
+
fires after a theme change is `config.theme.event` (default `porthole:theme`), and the
|
|
390
|
+
manual teaches that same name. The shell's content arrives as `tool-input(-partial)`
|
|
391
|
+
with `{ html }`.
|
|
392
|
+
**Why.** MCP already has a logging notification, so errors need no private method
|
|
393
|
+
(PRINCIPLE 2). A host migrating onto Porthole may already have content listening for
|
|
394
|
+
its own event name; naming it in the config keeps that content working, and the
|
|
395
|
+
agreement test keeps the shell and the manual on the same name.
|
|
396
|
+
**Rejected.** An own-prefixed error method; a fixed event name.
|
|
397
|
+
**Revisit when** MCP Apps standardizes error reporting or theme-change events for
|
|
398
|
+
views.
|
|
399
|
+
|
|
400
|
+
## P025 · 2026-10-11 · A served document comes with its ticket's answer; the cookie serves what follows · Accepted
|
|
401
|
+
|
|
402
|
+
**Decision.** Refines P016's ticket flow. `/server` names each storage scope's origin
|
|
403
|
+
`<label>.<content domain>` (24 base32 characters of an HMAC of the scope, keyed from the
|
|
404
|
+
server's secret), and each instance a key (16 characters, an HMAC of the instance): a served
|
|
405
|
+
document (shell, artifact version, sandbox proxy) lives at `/__porthole/d/<key>`, and `issue`
|
|
406
|
+
returns that path with a one-time ticket (`?ticket=`, 60 s by default, stored as its hash). The
|
|
407
|
+
request that takes the ticket is answered **with the document itself**, CSP and `frame-ancestors`
|
|
408
|
+
in headers, plus a cookie of that origin and instance only: `__Host-porthole-<key>`, `Secure`,
|
|
409
|
+
`HttpOnly`, `Path=/`, `SameSite=None; Partitioned` in a frame (`Sec-Fetch-Dest` other than
|
|
410
|
+
`document`), `SameSite=Lax` on a tab of its own; signed, carrying the instance, the viewer and its
|
|
411
|
+
end (an hour by default, `Grant.sessionSeconds`). The cookie serves what follows: a reload, and
|
|
412
|
+
every request of a url-mode app, whose ticket is taken at `/__porthole/enter` and redirects to the
|
|
413
|
+
app's own landing path. Over http (loopback dev only) the cookie drops `__Host-`, and `Secure` on
|
|
414
|
+
a tab. Host origins on, or possibly same-site with, the content domain are refused when the server
|
|
415
|
+
is created; a mount checks a located frame the same way (`checkFrameLocation`). Without the
|
|
416
|
+
public-suffix list the same-site check errs toward "same site" (it refuses hosts sharing the last
|
|
417
|
+
two labels, so `a.github.io` and `b.github.io` are refused though browsers would allow them).
|
|
418
|
+
**Measured** (`.dev` probes, Playwright 1.63: Chromium 153, WebKit 26.6). WebKit keeps **no**
|
|
419
|
+
cookie in a cross-site frame: not `SameSite=None; Secure`, not `Partitioned`, not `__Host-`, over
|
|
420
|
+
http or https (`document.hasStorageAccess()` is false); on a tab of its own it keeps them over
|
|
421
|
+
https. Chromium keeps partitioned cookies in frames, including `Secure` ones over http on
|
|
422
|
+
`*.localhost`. Framed `localStorage` persists across reloads in both. So a redirect after the ticket
|
|
423
|
+
(the first design) shows WebKit nothing, while the document in the ticket's own answer shows it
|
|
424
|
+
everywhere; and a url-mode app, which needs its cookie for every subresource, cannot run framed in
|
|
425
|
+
WebKit: a host shows it on a tab there. The leak suite's `served.url.framed` asserts the WebKit
|
|
426
|
+
failure (`test.fail`), so a change in WebKit shows up as a failing case.
|
|
427
|
+
**Why.** The ticket must work on the weakest engine we support (PRINCIPLE 6), and a used ticket
|
|
428
|
+
left in the frame's URL is worthless (taken atomically, once), while the frame sends no referrer.
|
|
429
|
+
Per-instance cookies let several instances of one scope (one origin) reload side by side.
|
|
430
|
+
**Rejected.** Redirecting to a ticket-free URL (WebKit shows the 401 page); a token in the
|
|
431
|
+
document's path (a credential in every URL the content can read and log); a service worker that
|
|
432
|
+
adds the session to an app's requests (fragile beside an app's own worker, and no WebSockets).
|
|
433
|
+
**Revisit when** WebKit keeps partitioned cookies in frames (then `served.url.framed` fails as
|
|
434
|
+
"passed unexpectedly": framed url mode works there too), or a host needs revocation before a
|
|
435
|
+
cookie's end (then a check on every request).
|
|
436
|
+
|
|
437
|
+
## P026 · 2026-10-11 · The MCP Apps sandbox proxy becomes its view · Accepted
|
|
438
|
+
|
|
439
|
+
**Decision.** The sandbox proxy `/server` serves (`ServedDocument` `mcp-proxy`) speaks the
|
|
440
|
+
spec's host-facing protocol, `sandbox-proxy-ready` until the host answers
|
|
441
|
+
`sandbox-resource-ready { html }`, then writes the shell's hardening head and the view into
|
|
442
|
+
**its own** document rather than into a nested same-origin frame. Writing a document erases the
|
|
443
|
+
window's listeners, so nothing of the proxy remains and the view talks to its host directly; the
|
|
444
|
+
spec's relay is the identity. The view's CSP goes out in the proxy's headers before the HTML
|
|
445
|
+
arrives: `issue` takes the view's declared egress (`_meta.ui.csp`, mapped to Porthole's
|
|
446
|
+
vocabulary by `/mcp`) in the instance and signs it into the ticket and cookie, opened on top of
|
|
447
|
+
the config's (closed by default, so a view gets exactly what it declares). The proxy takes the HTML
|
|
448
|
+
only from its parent, from a host origin, once; it ignores the message's `csp`, `sandbox` and
|
|
449
|
+
`permissions` (the policy is the headers'; the frame's sandbox and `allow` are the host's). Leak
|
|
450
|
+
cases `served.proxy.view`, `.parent-only` and `.egress` prove it on Chromium and WebKit.
|
|
451
|
+
**Threat it re-opens.** None beyond P016's: the view runs with `allow-same-origin` on a scope's
|
|
452
|
+
origin, as any domain-mode content.
|
|
453
|
+
**Why.** A view nested in a same-origin frame can script its proxy: a second realm where WebRTC
|
|
454
|
+
and frame creation are intact (outside the shell's hardening) and whose fetches run under the
|
|
455
|
+
proxy's own policy, so the proxy would need the view's hardening a second time. Writing in place
|
|
456
|
+
leaves one realm, hardened once, under one policy. The host and the view see the protocol the
|
|
457
|
+
spec describes.
|
|
458
|
+
**Rejected.** The reference host's nested inner frame (`document.write` into a same-origin child,
|
|
459
|
+
CSP from a `?csp=` query); a `<meta>` CSP written with the view (the header is in force before any
|
|
460
|
+
HTML arrives and can't be edited by it).
|
|
461
|
+
**Revisit when** the spec makes the relay observable to a view, or a host needs to send a proxy a
|
|
462
|
+
second resource; the framing note goes upstream with the leak runner (P018).
|
|
463
|
+
|
|
464
|
+
## P027 · 2026-10-11 · The MCP Apps adapter: what a view is sent, and what it may ask · Accepted
|
|
465
|
+
|
|
466
|
+
**Decision.** `@botiverse/porthole/mcp`'s `mcpAppsAdapter` (P007) answers `ui/initialize` with
|
|
467
|
+
the spec's protocol version, the host's name, the capabilities the config declares (`openLinks`
|
|
468
|
+
for `openLink`, `serverTools` for `callTool`, `message` for `sendPrompt`, …, plus the egress it
|
|
469
|
+
opens as `sandbox.csp`) and the host context; it sends a view nothing else before the view says
|
|
470
|
+
`initialized` (queued tool input, result and cancellation go then, in order, the drafts collapsed
|
|
471
|
+
to the latest). It carries `ui/message`, `ui/open-link`, `ui/download-file`, `tools/call`,
|
|
472
|
+
`resources/read` and `ui/update-model-context` into the bridge with the host page's user
|
|
473
|
+
activation; a refusal answers with JSON-RPC error `-32000` (`-32601` when not declared), a host
|
|
474
|
+
function that ran but couldn't with `{ isError: true }`. Sizes go to `resize`, `notifications/message`
|
|
475
|
+
at error levels and above to `reportError`. Further:
|
|
476
|
+
- **Style variables are cut to the standard's 76 names.** Measured: the official App SDK (2.0.3,
|
|
477
|
+
zod 4) refuses a host context naming any other, so a view never initializes; Porthole's own
|
|
478
|
+
names (`--color-accent`, the chart palette) stay with its shell. `MCP_APPS_STYLE_VARIABLES` is
|
|
479
|
+
exported (a test checks it against the SDK's list).
|
|
480
|
+
- **`ui/request-display-mode` keeps the current mode**, answering with it (the spec's answer
|
|
481
|
+
when a request isn't granted): how a display is laid out is the host's, and the vocabulary has
|
|
482
|
+
no capability that could gate it.
|
|
483
|
+
- **A view's `frameDomains` and `baseUriDomains` open nothing** (`viewEgress`): the shell removes
|
|
484
|
+
nested frames (P006) and `base-uri` stays `'none'`; `connectDomains` and `resourceDomains` that
|
|
485
|
+
are exact origins (or `https://*.`) become egress, anything else is dropped.
|
|
486
|
+
- **Tool visibility is the host's policy:** its `callTool` handler refuses tools the view may not
|
|
487
|
+
call (`_meta.ui.visibility`).
|
|
488
|
+
The leak suite runs the official examples as published (`server-basic-vanillajs`,
|
|
489
|
+
`server-basic-react`, `server-threejs`, pinned dev dependencies, unmodified) through the adapter, in
|
|
490
|
+
an opaque frame and behind the sandbox proxy: tool input (streamed) and result, `tools/call`,
|
|
491
|
+
`ui/message`, `ui/open-link`, theme change, size, teardown and model context; all pass on Chromium
|
|
492
|
+
and WebKit. **Measured:** a click in a view, opaque or served, activates the host page on both
|
|
493
|
+
engines (each clicked call reached the bridge with `userActivated: true`).
|
|
494
|
+
**Why.** Interop is decided by the real SDK and examples, not by reading the spec (PRINCIPLE 6);
|
|
495
|
+
everything a view can do beyond drawing stays a declared, gated host function (PRINCIPLE 4).
|
|
496
|
+
**Rejected.** Sending all theme variables (breaks every SDK view); a host callback for display
|
|
497
|
+
modes outside the bridge (an ungated power); honoring `frameDomains` (re-opens P006's nested-realm
|
|
498
|
+
channel).
|
|
499
|
+
**Revisit when** the SDK accepts unknown style keys or the spec adds names (then widen the list),
|
|
500
|
+
or a host needs fullscreen views (then a `displayMode` capability with a gate).
|
|
501
|
+
|
|
502
|
+
## P028 · 2026-10-11 · The host context carries only the spec's style names; Porthole's own go in `porthole/styles` · Accepted
|
|
503
|
+
|
|
504
|
+
**Decision.** In `ui/initialize` and `host-context-changed`, `styles.variables` holds
|
|
505
|
+
exactly the names MCP Apps defines (`MCP_APPS_STYLE_VARIABLES`, in the core, re-exported
|
|
506
|
+
by `/mcp`). The theme's other variables (`--color-accent`, the chart palette, a host's
|
|
507
|
+
own) travel in the host context's own-prefixed field `porthole/styles: { variables }`;
|
|
508
|
+
the shell applies both, filtered by the config's names. The shell numbers its own
|
|
509
|
+
requests with strings (`porthole-shell-<n>`) so content using the App SDK in the same
|
|
510
|
+
window never takes the shell's answers, nor the shell the SDK's.
|
|
511
|
+
**Why.** Measured: the official App SDK (ext-apps 2.0.3) validates the host context
|
|
512
|
+
strictly and fails the whole handshake on an unknown style name ("Invalid result for
|
|
513
|
+
ui/initialize: hostContext.styles.variables: Unrecognized keys"), while it keeps
|
|
514
|
+
unknown top-level fields. The `0.app-sdk-*` probes connect the official SDK from content
|
|
515
|
+
under the inline, artifact and MCP Apps presets; the `style-split` negative control
|
|
516
|
+
(every variable in `styles`) makes all three fail with that error.
|
|
517
|
+
**Rejected.** Dropping the extra variables (the manual teaches them); defaults baked
|
|
518
|
+
into the shell's CSS only (a host's live values for them would never arrive).
|
|
519
|
+
**Revisit when** the spec's style list grows (move names across) or it gains an
|
|
520
|
+
extension point for host-specific variables (use it, and take ours upstream).
|
|
521
|
+
|
|
522
|
+
## P029 · 2026-10-11 · Documents are rendered inert and imported; content can't rewrite its document or build declarative shadow roots · Accepted
|
|
523
|
+
|
|
524
|
+
**Decision.** A whole document (an artifact page, an MCP Apps view in an opaque frame,
|
|
525
|
+
a served page, the sandbox proxy's view) is never handed to the page's parser: it
|
|
526
|
+
travels as data (the shell's plan, or the protocol's tool input), is parsed with
|
|
527
|
+
`DOMParser` (no scripts run, no declarative shadow roots built), guarded, imported in
|
|
528
|
+
place of the shell's, and its scripts run in order, as a fragment's are, followed by
|
|
529
|
+
`DOMContentLoaded` and `load`. Content can't rewrite its document
|
|
530
|
+
(`document.open`/`write`/`writeln` throw), and the entry points that would build a
|
|
531
|
+
declarative shadow root (`setHTMLUnsafe`, `Document.parseHTMLUnsafe`) get fragment
|
|
532
|
+
parsing's behavior. The sandbox proxy (P026) writes the shell's own document with the
|
|
533
|
+
view in its plan, not the view's markup.
|
|
534
|
+
**Why.** The frame guard (P006) reaches every shadow root `attachShadow` makes, but not
|
|
535
|
+
a closed declarative one: the parser builds it without any script seeing it, and a frame
|
|
536
|
+
inside it runs in a fresh realm. Measured on Chromium: with the `setHTMLUnsafe` block
|
|
537
|
+
taken out (`declarative-shadow` control) a child frame in a closed declarative root
|
|
538
|
+
runs; with `document.write` allowed (`parser-entry-points`) a rewritten document's child
|
|
539
|
+
runs. Rendering by import has no such path, and the re-serialization alternative
|
|
540
|
+
(parse, strip, serialize, write) reopens it through markup that parses differently the
|
|
541
|
+
second time.
|
|
542
|
+
**Rejected.** Writing documents with `document.write` (the first host's way); a string
|
|
543
|
+
sanitizer; letting content rewrite its document.
|
|
544
|
+
**Cost.** Content that calls `document.write` fails (reported as an error); declarative
|
|
545
|
+
shadow DOM in content is inert; a page's scripts see `document.readyState` "complete"
|
|
546
|
+
(their load events still fire, once).
|
|
547
|
+
**Revisit when** engines let a script observe or refuse declarative shadow roots (then
|
|
548
|
+
allow them), or a host needs `document.write` content.
|
|
549
|
+
|
|
550
|
+
## P030 · 2026-10-11 · Guarded elements are refused synchronously, where they are connected or changed · Accepted
|
|
551
|
+
|
|
552
|
+
**Decision.** Besides the mutation observer (which runs at the next microtask), the shell
|
|
553
|
+
wraps every DOM insertion API (`appendChild`, `insertBefore`, `replaceChild`,
|
|
554
|
+
`append`/`prepend`/`replaceChildren`, `before`/`after`/`replaceWith`,
|
|
555
|
+
`insertAdjacentElement`, `Range.insertNode`/`surroundContents`) to drop frame-like
|
|
556
|
+
elements and resource hints before they are connected; parses markup set through
|
|
557
|
+
`innerHTML`, `outerHTML`, `insertAdjacentHTML` and `setHTML` in a detached element of the
|
|
558
|
+
same kind, guards it, then moves it in; refuses `execCommand("insertHTML")`; and removes a
|
|
559
|
+
connected `<link>` or `<meta>` from the document before any change (attributes, IDL
|
|
560
|
+
setters, `relList`, attribute nodes and maps) that would make it a guarded one. Each
|
|
561
|
+
wrapper is fixed in place (non-configurable).
|
|
562
|
+
**Why.** Measured on WebKit: a `rel=preconnect` link that script inserts, or turns into a
|
|
563
|
+
preconnect while connected, opens a TCP connection at once, outside the CSP, before any
|
|
564
|
+
observer runs (`8.preconnect` failed with 3 connections; it passes with these guards).
|
|
565
|
+
Chromium measured no preconnect from a frame here, but harden for the weakest engine
|
|
566
|
+
(PRINCIPLE 6). The `sync-guard` negative control makes `8.preconnect` fail on WebKit.
|
|
567
|
+
**Rejected.** Accepting the leak on WebKit; relying on the observer alone.
|
|
568
|
+
**Revisit when** WebKit applies the CSP to preconnect (then the observer suffices), or a
|
|
569
|
+
DOM insertion API is added (wrap it, and add its probe).
|
|
570
|
+
|
|
571
|
+
## P031 · 2026-10-11 · A display whose content navigates its frame is started again, at most three times · Accepted
|
|
572
|
+
|
|
573
|
+
**Decision.** The web platform counts its frame's loads; any after the first means the
|
|
574
|
+
content navigated the frame. The display is started again from its content (an event
|
|
575
|
+
`navigated`), at most three times per run, then stopped with an error. The wall against
|
|
576
|
+
navigating out is the embedding page's `frame-src` (P006); a document the frame
|
|
577
|
+
navigates to must never run, and the leak suite asserts that, not the restart.
|
|
578
|
+
**Why.** Measured on Chromium and WebKit: a self-navigation the embedding page's
|
|
579
|
+
`frame-src` blocks still replaces the frame's document (an error page fires `load`), so
|
|
580
|
+
the content is gone either way; starting again keeps the display showing. Measured on
|
|
581
|
+
Chromium with the embedding CSP taken out (`embedder-csp` control): a `blob:` or `data:`
|
|
582
|
+
document the frame navigates to runs, and only the restart's timing kept it from
|
|
583
|
+
reaching the STUN oracle, which is a race, not a wall; so `5.blob`/`5.data` assert, with
|
|
584
|
+
a witness origin, that such a document never runs.
|
|
585
|
+
**Rejected.** Ignoring navigations (a blank display); restarting forever (content that
|
|
586
|
+
navigates on load would loop).
|
|
587
|
+
**Revisit when** an engine keeps the frame's document on a blocked navigation.
|
|
588
|
+
|
|
589
|
+
## P032 · 2026-10-11 · Every hardening item has a negative control · Accepted
|
|
590
|
+
|
|
591
|
+
**Decision.** The leak suite's server can serve the core with one defense taken out
|
|
592
|
+
(`PORTHOLE_LEAK_WEAKEN=<name>`: `frame-guard`, `webrtc`, `attach-shadow`,
|
|
593
|
+
`parser-entry-points`, `declarative-shadow`, `sync-guard`, `connect-src`, `self`,
|
|
594
|
+
`sandbox`, `base-uri`, `style-split`, `embedder-csp`; later also `embedder-self`,
|
|
595
|
+
`confirm-gate`, `host-gesture`, `shell-gesture`, `link-scheme`, `draft-inert`, `document-theme`). Each names the exact text it
|
|
596
|
+
removes, and the server refuses to start if that text is gone, so a control can't
|
|
597
|
+
silently weaken nothing. DESIGN.md records, per engine, which probes each control makes
|
|
598
|
+
fail. Every probe that asserts "nothing left" also has a positive control (an allowed
|
|
599
|
+
origin, an unsandboxed peer connection, an unsandboxed hint) or says it has none.
|
|
600
|
+
**Why.** PRINCIPLE 6: a wall never seen to fail proves nothing. Running the controls found
|
|
601
|
+
what the passing suite could not: that `5.blob` passed by a race (P031), that two walls
|
|
602
|
+
stand behind form submission (`sandbox` alone doesn't fail `6.submit`: `form-action`
|
|
603
|
+
holds), and that an opaque frame's `'self'` doesn't reach the host on Chromium.
|
|
604
|
+
**Rejected.** Trusting a green suite.
|
|
605
|
+
**Revisit when** a defense is added (add its control) or a control stops failing its
|
|
606
|
+
probes (the probe has gone blind: fix it first).
|
|
607
|
+
|
|
608
|
+
## P033 · 2026-10-11 · The embedding page frames nothing by default: `frame-src 'none'` for opaque content · Accepted
|
|
609
|
+
|
|
610
|
+
**Decision.** `embedderCsp` no longer includes `'self'`: for opaque displays it is
|
|
611
|
+
`frame-src 'none'; child-src 'none'`; served and external displays add their content
|
|
612
|
+
origins, and a host its own frames through `embedder.frameSrc`, where `'self'` is
|
|
613
|
+
accepted only by name. Resolves DESIGN's open question on `'self'`.
|
|
614
|
+
**Why.** Measured on Chromium and WebKit: a `srcdoc` frame loads and runs under its
|
|
615
|
+
embedding page's `frame-src 'none'`, so `'self'` bought opaque content nothing but a way
|
|
616
|
+
to navigate its frame to the host's own origin, a request carrying whatever the content
|
|
617
|
+
puts in its URL, with any of the host's `SameSite=None` cookies. Probe `5.host-origin`
|
|
618
|
+
asserts no such request; the `embedder-self` control makes it fail. This tightens a
|
|
619
|
+
default (PRINCIPLE 1 allows that freely); a host that frames its own pages names `'self'`
|
|
620
|
+
and accepts that navigation.
|
|
621
|
+
**Rejected.** Keeping `'self'` (the first host's policy) for compatibility.
|
|
622
|
+
**Revisit when** an engine stops letting `srcdoc` frames load under `frame-src 'none'`
|
|
623
|
+
(the opaque-origin probes would fail at once).
|
|
624
|
+
|
|
625
|
+
## P034 · 2026-10-11 · The React binding owns lifecycle and page state; the core owns everything else · Accepted
|
|
626
|
+
|
|
627
|
+
**Decision.** `@botiverse/porthole/react` is mounting and lifecycle only: a
|
|
628
|
+
`PortholeProvider` per page holds the live-frame registry (`admitFrame`, the first host's
|
|
629
|
+
policy: frames off screen longest stop first, a stopped one keeps its height and offers
|
|
630
|
+
"Run again"), the heights displays reported (by display id), and the default dialog
|
|
631
|
+
queue; a `PortholeFrame` per display mounts the core's `createHost` when it nears the
|
|
632
|
+
screen, feeds it `content`/`partial`/`scheme` (and its id and title), and makes a new
|
|
633
|
+
display only for a new `config`, `document` or `instance` (or `generation`). It is
|
|
634
|
+
written in TSX (the React lint rules only understand JSX; a host's bundler or the
|
|
635
|
+
package's build compiles it). Handlers are read
|
|
636
|
+
from the latest props. The cap is each config's `limits.maxLiveFrames`. Without a
|
|
637
|
+
provider, frames run against a page-wide registry and no one confirms (gated calls are
|
|
638
|
+
refused). The shell reports no size before it has drawn something.
|
|
639
|
+
**Why.** These are the first host's widget-frame behaviors (OBJECTIVE 4: its code
|
|
640
|
+
reduced to config plus callbacks), and they are page state a framework binding owns,
|
|
641
|
+
not isolation. Measured while testing: the first host's shell reported an empty
|
|
642
|
+
shell's height at its handshake, which overwrote a display's remembered height and
|
|
643
|
+
made a re-mounted frame jump; `11.size` now asserts the first size is the content's.
|
|
644
|
+
**Rejected.** A binding with its own frame or protocol code; a default with no
|
|
645
|
+
provider that silently auto-confirms.
|
|
646
|
+
**Revisit when** a second framework needs a binding (then move the registry and
|
|
647
|
+
heights into a framework-free module both use: `live.ts` already is).
|
|
648
|
+
|
|
649
|
+
## P035 · 2026-10-11 · Firefox joins the gate; its differences are measured and need no new wall · Accepted
|
|
650
|
+
|
|
651
|
+
**Decision.** Completes P017: every Playwright suite (leak, served, MCP examples, React)
|
|
652
|
+
runs on Chromium, WebKit and Firefox, and every negative control was run on Firefox.
|
|
653
|
+
All leak probes passed on Firefox as they stood; no defense was added for it. Its
|
|
654
|
+
differences, each measured (Firefox 155, Playwright 1.63):
|
|
655
|
+
- A navigation the embedding page's `frame-src` blocks **keeps** the frame's document
|
|
656
|
+
(Chromium and WebKit replace it with an error page, so their displays restart, P031).
|
|
657
|
+
The React binding's recovery test therefore navigates to `about:blank`, which every
|
|
658
|
+
engine follows.
|
|
659
|
+
- A frame without `allow-popups` gets an exception from `window.open` (the others get
|
|
660
|
+
`null`): no window either way. The served `no-popups` cases accept both.
|
|
661
|
+
- Firefox sends no STUN to a loopback server by default, which left `3.webrtc` without
|
|
662
|
+
its positive control; the suite's Firefox project sets
|
|
663
|
+
`media.peerconnection.ice.loopback` (test environment only), and an unsandboxed peer
|
|
664
|
+
connection then reaches the oracle while the sandboxed one, without constructors, can't.
|
|
665
|
+
- Without the embedding CSP, Firefox still refuses a frame's own navigation to `data:`
|
|
666
|
+
(`5.data` holds under the `embedder-csp` control); the other navigations out fail, as
|
|
667
|
+
on the other engines.
|
|
668
|
+
- Like Chromium, Firefox opened no preconnect even unsandboxed (`sync-guard` alone fails
|
|
669
|
+
nothing on it); WebKit stays the weakest engine there (P030).
|
|
670
|
+
- A click in the frame reaches the host page's user activation; a new child frame's
|
|
671
|
+
realm is unreachable in the task that made it (a `TypeError`, not a `SecurityError`).
|
|
672
|
+
- Headless Firefox has no WebGL on a machine without a GPU (Mesa installed or not): the
|
|
673
|
+
threejs example cases skip there with that reason; they are about the machine, not
|
|
674
|
+
Porthole. A Playwright worker crashed once (SIGSEGV) and never again on rerun.
|
|
675
|
+
- Waiting on script in a frame that `frame-ancestors` refused never settles on Firefox;
|
|
676
|
+
the served `frame-ancestors` case bounds that wait.
|
|
677
|
+
**Why.** PRINCIPLE 6: engines differ, so each is measured and the weakest decides. Firefox
|
|
678
|
+
is weaker nowhere the suite can see.
|
|
679
|
+
**Revisit when** an engine version changes any of these (the probes and controls will say
|
|
680
|
+
which), or Firefox gains a synchronous hint path like WebKit's.
|
|
681
|
+
|
|
682
|
+
## P036 · 2026-10-11 · A host's origin may have any scheme, still exact · Accepted
|
|
683
|
+
|
|
684
|
+
**Decision.** The origins the shell takes messages from, and `/server`'s `hostOrigins`, are
|
|
685
|
+
exact origins of any scheme (`scheme://host[:port]`, lowercase, no path): a desktop app's own
|
|
686
|
+
registered scheme (`ferry://app`, `app://local`, `tauri://localhost`) works like `https://`.
|
|
687
|
+
`"null"` is refused with a message saying why (an opaque origin isn't one origin; `file:` pages
|
|
688
|
+
and unregistered custom schemes have one), as are wildcards, paths and uppercase. A custom-scheme
|
|
689
|
+
host is never the same site as web content, so the same-site check doesn't apply to it. Errors on
|
|
690
|
+
origins say what to do: http and ws are allowed on loopback names only ("use https"), and a
|
|
691
|
+
same-site content origin is refused with the host and the rule named. Item 0 (and 10) of 0.1.1.
|
|
692
|
+
**Why.** The first host's packaged desktop app serves its UI from `ferry://app`, and nothing
|
|
693
|
+
mounted there; the checks were written for the web. The check that matters (the sender is the
|
|
694
|
+
frame's parent, from exactly this origin) is unchanged.
|
|
695
|
+
**Rejected.** Accepting `"null"` (any opaque page would pass the origin check).
|
|
696
|
+
**Revisit when** a host must run from an opaque origin (then it needs another channel to prove
|
|
697
|
+
it is the parent).
|
|
698
|
+
|
|
699
|
+
## P037 · 2026-10-11 · A url-mode frame may opt into popups, modals and downloads · Accepted
|
|
700
|
+
|
|
701
|
+
**Decision.** `presets.url({ sandbox: [...] })` adds tokens from a documented set
|
|
702
|
+
(`URL_SANDBOX_EXTRAS`: `allow-popups`, `allow-popups-to-escape-sandbox`, `allow-modals`,
|
|
703
|
+
`allow-downloads`) to url mode's baseline (`allow-scripts allow-same-origin allow-forms`). Nothing
|
|
704
|
+
else, never `allow-top-navigation*` (refused by name), and only in url mode. The safe default
|
|
705
|
+
stays: no extras. Item 7 of 0.1.1.
|
|
706
|
+
**Threat it re-opens.** The framed app (the person's own running app, on its own origin) may
|
|
707
|
+
open windows (which, with `-to-escape-sandbox`, are unsandboxed top-level pages of its origin),
|
|
708
|
+
show modal dialogs over the host, and start downloads. Bounded by: its origin is a separate site
|
|
709
|
+
from the host's (`checkFrameLocation`, P020), so a window it opens can't reach the host; it still
|
|
710
|
+
can't navigate the host's page; it is the host's policy to opt in (PRINCIPLE 8), for an app the
|
|
711
|
+
person runs. The React url-mode test shows the default refuses a popup and top navigation, and
|
|
712
|
+
the opt-in allows the popup and still refuses top navigation, on all three engines.
|
|
713
|
+
**Rejected.** A free-form token list; extras for agent-made content (inline, artifact, views).
|
|
714
|
+
**Revisit when** a host needs another token (add it to the set, with its test).
|
|
715
|
+
|
|
716
|
+
## P038 · 2026-10-11 · The embedding page's CSP restricts frames only (no child-src) · Accepted
|
|
717
|
+
|
|
718
|
+
**Decision.** `embedderCsp` is `frame-src …` alone; it no longer repeats the sources in
|
|
719
|
+
`child-src`. Item 4 of 0.1.1.
|
|
720
|
+
**Why.** Measured on Chromium, WebKit and Firefox: under 0.1.0's `child-src 'none'` the host's
|
|
721
|
+
own workers (blob and file) were blocked, since `worker-src` falls back to `child-src`. All three
|
|
722
|
+
engines read frames from `frame-src` (the item-5 probes pass without `child-src`), so the repeat
|
|
723
|
+
protected nothing and broke hosts. With no `child-src` (and no `default-src`), Porthole's policy
|
|
724
|
+
says nothing about workers at all, whatever the host's own policy says.
|
|
725
|
+
**Rejected.** Emitting `worker-src 'self' blob:` (it would restrict hosts that load workers from
|
|
726
|
+
elsewhere).
|
|
727
|
+
**Revisit when** an engine Porthole supports reads frames only from `child-src`.
|
|
728
|
+
|
|
729
|
+
## P039 · 2026-10-11 · A whole document takes the person's scheme only when it declares it · Accepted
|
|
730
|
+
|
|
731
|
+
**Decision.** An artifact page (or any whole document the shell renders) shows as its author
|
|
732
|
+
styled it, in light, unless it declares it supports both schemes
|
|
733
|
+
(`<meta name="color-scheme" content="light dark">`); then it takes the person's scheme
|
|
734
|
+
(`color-scheme`, `data-theme`) like a widget does. The theme variables are set either way (they
|
|
735
|
+
change nothing a page doesn't use). A host may set `theme.documents: "always"`. The manual says
|
|
736
|
+
so for artifact pages. Fragments (widgets) are unchanged. Item 12 of 0.1.1.
|
|
737
|
+
**Why.** A shown HTML file is the author's own page: forcing a dark `color-scheme` on a page
|
|
738
|
+
written for light turns its default text and canvas dark under its own light colors, and the
|
|
739
|
+
first host had to force light. The `11.document-theme` probe and its `document-theme` control
|
|
740
|
+
show both cases on every engine.
|
|
741
|
+
**Rejected.** Theming every document; never theming documents (pages that support dark should
|
|
742
|
+
follow the person).
|
|
743
|
+
**Revisit when** a host needs per-page control beyond the meta tag.
|
|
744
|
+
|
|
745
|
+
## P040 · 2026-10-11 · A display reports how its first document went · Accepted
|
|
746
|
+
|
|
747
|
+
**Decision.** Every display emits `outcome` once per run: `loaded`, `blocked` (with a reason:
|
|
748
|
+
`framing`, `cookies`, `session`, `upstream`, `not-found`, `error`, and the HTTP status where
|
|
749
|
+
there is one), or `timeout` (`limits.loadTimeoutMs`, 15 s by default). A shell or view is loaded
|
|
750
|
+
when its handshake completes. A url-mode frame, which has no handshake, is ready (`initialized`)
|
|
751
|
+
on its first load and fills its container (at least `minHeightPx`), since an app reports no size.
|
|
752
|
+
Pages of Porthole's `/server` tell their host how it went with an own-prefixed notification,
|
|
753
|
+
`porthole/notifications/frame-status`, posted to the host origins only: every status page says
|
|
754
|
+
`blocked` and why; a framed url-mode app is entered through a small page (not a redirect) that
|
|
755
|
+
says `entering`, checks the session cookie its own answer set was kept (`/__porthole/session`),
|
|
756
|
+
then goes on to the app, or says `blocked: cookies`. `issue` marks its locations `reports: true`,
|
|
757
|
+
so a load from which nothing spoke is a page that never ran (`blocked: framing`). A host's own
|
|
758
|
+
gateway may send the same notification. Items 1 and 13 of 0.1.1.
|
|
759
|
+
**Why.** A host needs to show its own fallback ("Open in a new tab") without pages of its own,
|
|
760
|
+
and a cross-origin frame can't be looked into: the only honest signals are the frame's loads,
|
|
761
|
+
its words, and time. Measured on all three engines (`leak/served/outcome.spec.ts`): a framed app
|
|
762
|
+
loads on Chromium and Firefox and is `blocked: cookies` on WebKit (P025); a host the server
|
|
763
|
+
doesn't allow gets `blocked: framing`; a used ticket `blocked: session`; a hanging load a
|
|
764
|
+
timeout; a served shell `loaded`, and `blocked: framing` from a foreign host.
|
|
765
|
+
**Limits.** Without `reports`, a frame the browser refused (its own policy, or the embedding
|
|
766
|
+
page's) still fires `load`, so it reads as loaded: only Porthole's pages (or a gateway that
|
|
767
|
+
speaks the notification) can say otherwise. An app's own `frame-ancestors` after entry likewise.
|
|
768
|
+
**Rejected.** Injecting a script into an app's pages (rewrites someone else's HTML, breaks under
|
|
769
|
+
its CSP); guessing from timing.
|
|
770
|
+
**Revisit when** browsers expose why a cross-origin frame failed, or MCP Apps defines a status
|
|
771
|
+
message (then speak it).
|
|
772
|
+
|
|
773
|
+
## P041 · 2026-10-11 · Porthole declares no test-only dependency; hosts register the conformance tests · Accepted
|
|
774
|
+
|
|
775
|
+
**Decision.** The package declares no dependencies, and React only as an optional peer.
|
|
776
|
+
`@playwright/test` and `@modelcontextprotocol/ext-apps` are no longer peers (optional or not):
|
|
777
|
+
`/conformance` runs on the host's own Playwright (and, for the App SDK probes, its own SDK,
|
|
778
|
+
skipped if absent). The tests are exported as entries (`leakTests`, `servedTests`) that a host
|
|
779
|
+
registers with its own `test()` in its own spec file, so they are located there and filtered by
|
|
780
|
+
that file or a probe id (`--grep "\[1\.connect\]"`); `defineLeakSuite`/`defineServedSuite`
|
|
781
|
+
remain for hosts that don't filter. Items 5 and 6 of 0.1.1.
|
|
782
|
+
**Why.** Some installs added the optional peers anyway (the first host's server image got
|
|
783
|
+
Playwright). A conformance runner is a host's test tool, not part of what it ships; and tests
|
|
784
|
+
registered inside the library were located in `node_modules`, so a host couldn't select them.
|
|
785
|
+
`test/package.test.ts` and `test/conformance-listing.test.ts` keep both true.
|
|
786
|
+
**Rejected.** A separate `@botiverse/porthole-conformance` package (a second package to publish
|
|
787
|
+
and version in lockstep, for one import); a CLI wrapper (the host's Playwright config already
|
|
788
|
+
runs its spec).
|
|
789
|
+
**Revisit when** a host's install layout can't resolve its own Playwright from the package (no
|
|
790
|
+
hoisting, Plug'n'Play): then split the package.
|
|
791
|
+
|
|
792
|
+
## P042 · 2026-10-11 · Confirm says accepted, declined or redirected, and may run the effect in its click · Accepted
|
|
793
|
+
|
|
794
|
+
**Decision.** A host's `confirm(request, proceed)` resolves `true`/`"accepted"`,
|
|
795
|
+
`false`/`"declined"`, or `"redirected"` (the person took it elsewhere, "Edit first": not sent,
|
|
796
|
+
refusal `redirected`, neither counted nor resetting). Only declines **in a row** mute a display;
|
|
797
|
+
an accepted call resets the count. `proceed()` runs the call's effect at once, inside the
|
|
798
|
+
caller's own gesture, and counts as accepted; the default dialog's yes calls it in its click, so a
|
|
799
|
+
handler that opens a window does so within the person's click. Items 8 and 11 of 0.1.1.
|
|
800
|
+
**Why.** The first host's "Edit first" counted as a decline and three of any declines muted a
|
|
801
|
+
widget, against its own original rule. An effect that needs a gesture (a window) can't wait for
|
|
802
|
+
a promise to settle on engines that end the gesture on the next task. Measured in Playwright on
|
|
803
|
+
all three engines: a yes given 1.5 s later, without `proceed`, still opened a window there, so
|
|
804
|
+
the tests show `proceed` works rather than that it is needed; real browsers' popup blockers are
|
|
805
|
+
stricter than headless ones.
|
|
806
|
+
**Rejected.** Running the effect in the dialog itself (the effect is the host's).
|
|
807
|
+
**Revisit when** a host needs more answers than these three.
|
|
808
|
+
|
|
809
|
+
## P043 · 2026-10-11 · Theme reads wait for the host's restyle; heights follow a renamed display; defaults can be dropped · Accepted
|
|
810
|
+
|
|
811
|
+
**Decision.** `setTheme` reads the host's values two animation frames after the flip (a host
|
|
812
|
+
that restyles its page on its next frame has by then); `refreshTheme()` (React: a new `themeKey`)
|
|
813
|
+
reads them again without a flip. A display renamed while it runs keeps its remembered height
|
|
814
|
+
under its new id, and `PortholeFrame`'s `aliases` find it under ids it had for a frame that
|
|
815
|
+
mounts anew. A host drops default theme variables with `null` in a preset's theme. Items 2, 3
|
|
816
|
+
and 9 of 0.1.1.
|
|
817
|
+
**Why.** The first host's design system swaps tokens a frame after its scheme flips, so a frame
|
|
818
|
+
read stale colors; its streamed widgets changed id and jumped when they mounted again; its
|
|
819
|
+
palette needs none of Porthole's chart colors. Each has a React test in a real browser (an async
|
|
820
|
+
restyle, a rename then a remount, an alias) and the agreement test covers a theme without the
|
|
821
|
+
chart colors and the accent.
|
|
822
|
+
**Rejected.** A fixed delay in milliseconds (frames are what a restyle waits on).
|
|
823
|
+
**Revisit when** a host restyles later than its next frame without being able to signal it.
|
|
824
|
+
|
|
825
|
+
## P044 · 2026-10-11 · `/server` runs on any web-standard runtime: its hashing is plain JavaScript · Accepted
|
|
826
|
+
|
|
827
|
+
**Decision.** `/server` loads no Node module. SHA-256 and HMAC-SHA-256 are plain JavaScript
|
|
828
|
+
(`src/server/crypto.ts`, FIPS 180-4 and RFC 2104), randomness comes from
|
|
829
|
+
`crypto.getRandomValues`, and base64url and the constant-time compare are its own. It runs on
|
|
830
|
+
Cloudflare Workers without `nodejs_compat`, and on Node, Deno and Bun. Its outputs are byte for
|
|
831
|
+
byte those of 0.1.1's `node:crypto`, so labels, tickets and cookies issued before still hold.
|
|
832
|
+
Item 1 of 0.1.2.
|
|
833
|
+
**Why.** Web Crypto's HMAC is asynchronous, and `originFor` is synchronous (a host computes an
|
|
834
|
+
origin while it renders): async hashing would have changed the API. The inputs are a few dozen
|
|
835
|
+
bytes (a scope, a ticket, a cookie), so speed doesn't matter. `nodejs_compat` is a flag a host
|
|
836
|
+
may not want, and it pulls in polyfills. Tested three ways:
|
|
837
|
+
- the standard vectors (NIST, RFC 4231) and agreement with `node:crypto` (`test/crypto.test.ts`);
|
|
838
|
+
- a static check that nothing `/server` loads imports a `node:` module or uses `Buffer`,
|
|
839
|
+
`process` or `require`;
|
|
840
|
+
- a Worker bundled from `/server`, run in workerd (Miniflare) with no compatibility flags and a
|
|
841
|
+
real D1 database (`test/workers.test.ts`).
|
|
842
|
+
|
|
843
|
+
The Worker test covers the flat names, a ticket once, the cookie on a reload, a race, an
|
|
844
|
+
anonymous link, a revocation, and the reserved namespaces.
|
|
845
|
+
**Rejected.** `nodejs_compat` (the host's flag to set, not ours to require); Web Crypto (async);
|
|
846
|
+
a hashing dependency (P041: none).
|
|
847
|
+
**Revisit when** Web Crypto has a synchronous HMAC, or a profile shows the hashing costs.
|
|
848
|
+
|
|
849
|
+
## P045 · 2026-10-11 · On Workers, tickets live in D1, taken by one `DELETE … RETURNING`; never in KV · Accepted
|
|
850
|
+
|
|
851
|
+
**Decision.** `d1TicketStore(db)` keeps tickets in a D1 table (`d1TicketSchema()`, applied once as
|
|
852
|
+
a migration). A ticket is taken by a single statement, `DELETE … WHERE id = ? RETURNING`, so two
|
|
853
|
+
requests racing for one ticket can't both get it. Expired tickets are swept as new ones go in.
|
|
854
|
+
The rows hold the ticket's hash only, as in memory. Sessions stay signed cookies with no store;
|
|
855
|
+
the host revokes them through its per-request check (P050). The in-memory store remains the
|
|
856
|
+
default. Item 2 of 0.1.2.
|
|
857
|
+
**Why.** A ticket must open its content once, across every isolate of a Worker. D1 runs writes
|
|
858
|
+
on its single primary, one statement at a time. Workers KV is eventually consistent (a write or a
|
|
859
|
+
delete may take up to a minute to be seen elsewhere) and has no atomic read-and-delete, so a
|
|
860
|
+
ticket read in two places could be used twice. Measured on real D1 in workerd: two requests
|
|
861
|
+
racing for a ticket get one 200 and one 401. Unit tests run the same SQL on SQLite (expiry, the
|
|
862
|
+
sweep, table names).
|
|
863
|
+
**Rejected.** KV (above). A Durable Object store would be just as strict, but each host would
|
|
864
|
+
need a class binding and its migrations; the host this was built for already has D1. A session
|
|
865
|
+
store (a cookie is enough; revocation is the host's check).
|
|
866
|
+
**Revisit when** a host uses D1 read replicas through the Sessions API. A `DELETE` is a write and
|
|
867
|
+
goes to the primary, but a store that read first would not be safe.
|
|
868
|
+
|
|
869
|
+
## P046 · 2026-10-11 · Anonymous grants for public links · Accepted
|
|
870
|
+
|
|
871
|
+
**Decision.** `issue(instance, { anonymous: true, subject? })` mints a ticket that names no
|
|
872
|
+
viewer, for public links. Only documents qualify (a shell, an artifact version, the MCP Apps
|
|
873
|
+
sandbox proxy), never a url-mode app. Every other rule is the same as for a named viewer:
|
|
874
|
+
- the ticket works once, for a short time, and is bound to one instance (for an artifact, one
|
|
875
|
+
version);
|
|
876
|
+
- the cookie it buys is `__Host-`, `HttpOnly`, of that origin and that instance only;
|
|
877
|
+
- only the host origins may frame the content.
|
|
878
|
+
|
|
879
|
+
The host's check sees `anonymous: true` and an empty viewer. A grant naming both a viewer and
|
|
880
|
+
`anonymous` is refused. Item 3 of 0.1.2.
|
|
881
|
+
**Risk it re-opens.** Whoever reaches the host's public route gets the content. That route is
|
|
882
|
+
the only gate, because Porthole can't tell a link's holder from anyone else. A cookie bought
|
|
883
|
+
anonymously keeps opening that instance until its `sessionSeconds` end. Unsharing takes effect
|
|
884
|
+
at once only if the host's check looks at the share: give the grant the share id as its
|
|
885
|
+
`subject`, and deny it once unshared (P050). Anyone can make the public route mint tickets, so
|
|
886
|
+
the host rate-limits it (tickets expire and are swept).
|
|
887
|
+
**Bounded by** the server tests (once per ticket, never another instance, never an app, a viewer
|
|
888
|
+
and `anonymous` together refused) and the Worker test.
|
|
889
|
+
**Rejected.**
|
|
890
|
+
- A long-lived public URL without a ticket: a reusable bearer URL that shows up in logs and
|
|
891
|
+
referrers and never expires.
|
|
892
|
+
- Public content without a cookie: a reload would show nothing.
|
|
893
|
+
- Anonymous url-mode apps: every request to someone's running app, with its data and sessions,
|
|
894
|
+
would be open.
|
|
895
|
+
|
|
896
|
+
**Revisit when** a host needs anonymous access to an app (then a decision of its own).
|
|
897
|
+
|
|
898
|
+
## P047 · 2026-10-11 · A host may name content origins itself; other servers' namespaces are reserved · Accepted
|
|
899
|
+
|
|
900
|
+
**Decision.** `content.name(scope)` gives the label a scope is served on, and Porthole checks the
|
|
901
|
+
answer:
|
|
902
|
+
- it is one lowercase DNS label (`a-z0-9-`, at most 63, no hyphen at either end);
|
|
903
|
+
- it is not in an IDNA form (`xn--`, any `??--`);
|
|
904
|
+
- it is not `reserved`.
|
|
905
|
+
|
|
906
|
+
`prefixedName("a-")` is the one-to-one naming most hosts want (`a-<scope>`, scopes already
|
|
907
|
+
label-safe, anything else throws rather than fold into another scope's name). `content.reserved`
|
|
908
|
+
lists labels that belong to other servers under the same domain: `f`, for a server whose domain
|
|
909
|
+
is `f.<domain>` and whose origins are `<label>.f.<domain>`, or `www`. This server never names
|
|
910
|
+
them, and a request for one, or for anything under one, gets null and goes to the host's routes.
|
|
911
|
+
Any other deeper name still gets this server's 404. The keyed label (24 base32 characters) stays
|
|
912
|
+
the default. A keyed label never has a hyphen and a prefixed one always does, so the two
|
|
913
|
+
namespaces never meet. Item 4 of 0.1.2.
|
|
914
|
+
**Why.** A host with ids of its own wants stable, readable origins (`a-<id>.example-usercontent.com`)
|
|
915
|
+
next to another host's under the same content domain (`<instance>.f.example-usercontent.com`).
|
|
916
|
+
**Risks.**
|
|
917
|
+
- A host's names are guessable. Nothing opens without a ticket or a cookie, but the keyed
|
|
918
|
+
default also hides which scopes exist.
|
|
919
|
+
- A naming that maps two scopes to one label merges their origins (storage, cookies). The helper
|
|
920
|
+
is one-to-one; a host's own function must be too.
|
|
921
|
+
- Every label under one registrable domain is same-site with every other. `__Host-` cookies
|
|
922
|
+
can't be set across labels, but SameSite doesn't separate them. Put the content domain on the
|
|
923
|
+
Public Suffix List, and each nested namespace with it (`example-usercontent.com` and
|
|
924
|
+
`f.example-usercontent.com`), so each origin is a site of its own.
|
|
925
|
+
|
|
926
|
+
**Rejected.** Free-form names (dots would nest namespaces, uppercase and IDNA forms would make
|
|
927
|
+
names that read as something else); a registry of names in Porthole (the host's ids are the
|
|
928
|
+
host's).
|
|
929
|
+
**Revisit when** the Public Suffix List entry lands: then the same-site note above can go.
|
|
930
|
+
|
|
931
|
+
## P048 · 2026-10-11 · Artifact pages reach library CDNs only by the host's config · Accepted
|
|
932
|
+
|
|
933
|
+
**Decision.** For artifact pages to load libraries, a host passes `egress: LIBRARY_CDNS` to
|
|
934
|
+
`presets.artifact`. For served pages it does so under a config name of its own that the
|
|
935
|
+
instance's `document.preset` selects (the harness's `artifactCdn`). Nothing else changes. The
|
|
936
|
+
default artifact preset stays closed (P022). Item 5 of 0.1.2.
|
|
937
|
+
**Why.** Agent-made pages often load a chart or diagram library. Opening the CDNs is a fair
|
|
938
|
+
choice for a host to make, but it is the host's (P022). Tested on all three engines, both opaque
|
|
939
|
+
and served in domain mode (`leak/cdn.spec.ts`):
|
|
940
|
+
- a page's `<script src>` from a listed CDN loads;
|
|
941
|
+
- one from any other origin doesn't reach the network;
|
|
942
|
+
- under the default preset, nothing does.
|
|
943
|
+
|
|
944
|
+
The CDN is routed inside the test, so nothing leaves the machine, and a request reaches the
|
|
945
|
+
route only if the frame's CSP let it out.
|
|
946
|
+
**Risk (the host's, as P022 says).** Code from the CDNs is code the host doesn't control (a
|
|
947
|
+
compromised or typo-squatted package runs in the frame). It still runs inside the sandbox and
|
|
948
|
+
egress, but a path requested from an open CDN is a narrow write channel whose reader is the CDN's
|
|
949
|
+
operator. The manual teaches pinning versions.
|
|
950
|
+
**Revisit when** hosts want subresource integrity required on CDN scripts (the manual could
|
|
951
|
+
demand it, and the shell could check it).
|
|
952
|
+
|
|
953
|
+
## P049 · 2026-10-11 · A url-mode frame may be delegated clipboard writes and full screen · Accepted
|
|
954
|
+
|
|
955
|
+
**Decision.** `presets.url({ permissions: ["clipboard-write", "fullscreen"] })` (config
|
|
956
|
+
`permissions`, url mode only, from `URL_PERMISSIONS`) sets the frame's `allow` attribute. By
|
|
957
|
+
default it delegates nothing (`allow=""`), and the other modes always delegate nothing. Item 6 of
|
|
958
|
+
0.1.2 (the first host's backlog).
|
|
959
|
+
**Risk it re-opens.**
|
|
960
|
+
- Clipboard writes: the app can put any text on the clipboard in a click (pastejacking: a
|
|
961
|
+
command that looks like one thing and is another).
|
|
962
|
+
- Full screen: the app can cover the whole screen and draw a fake host or OS. The browser shows
|
|
963
|
+
its own notice, and Esc leaves.
|
|
964
|
+
|
|
965
|
+
Both need the person's click. A url-mode app is a running app the host chose to show, on an
|
|
966
|
+
origin of its own.
|
|
967
|
+
**Measured** (`leak/served/permissions.spec.ts`, with the clipboard permission granted, so the
|
|
968
|
+
delegation alone stands in the way):
|
|
969
|
+
- Full screen follows the delegation on Chromium, WebKit and Firefox (`fullscreenEnabled`, and
|
|
970
|
+
`requestFullscreen` in a click).
|
|
971
|
+
- Clipboard writes follow it only on Chromium. WebKit and Firefox let any frame write the
|
|
972
|
+
clipboard in a click, delegated or not, so there default-off is no wall.
|
|
973
|
+
|
|
974
|
+
Negative control: `url-permissions` (delegate both by default) fails the default case on all
|
|
975
|
+
three engines.
|
|
976
|
+
**Rejected.** `clipboard-read` (reads what the person copied anywhere); camera, microphone,
|
|
977
|
+
location and the rest (a decision each, if ever); delegation in opaque or domain mode
|
|
978
|
+
(agent-written HTML).
|
|
979
|
+
**Revisit when** WebKit or Firefox enforce the clipboard-write policy feature (then the
|
|
980
|
+
measurement changes), or a host needs another feature.
|
|
981
|
+
|
|
982
|
+
## P050 · 2026-10-11 · The host's check runs on every request; Porthole carries the host's decision and never makes it · Accepted
|
|
983
|
+
|
|
984
|
+
**Decision.** `ServerOptions.authorize = { check(request), ttlSeconds? }`. Porthole calls it after
|
|
985
|
+
a ticket or cookie verifies and before anything of the content is served: a document's first
|
|
986
|
+
load and its reloads, a url-mode app's entry and its every request, `/__porthole/session`, and
|
|
987
|
+
`access()` (a host's WebSocket path).
|
|
988
|
+
- **Input.** The scope, the instance, the viewer (or `anonymous`), the request, and the host's
|
|
989
|
+
own opaque `subject`. The host sets the subject at `issue` (512 characters at most); it is
|
|
990
|
+
signed into the ticket and the cookie and never interpreted.
|
|
991
|
+
- **Answer.** `{ allow: true }` or `{ allow: false, code? }`. A deny, or a check that throws, is
|
|
992
|
+
a 403 status page with reason `denied` and the host's code (`[a-z0-9-]{1,40}`, dropped
|
|
993
|
+
otherwise). The page tells the display, whose `outcome` is `blocked: denied` with the code,
|
|
994
|
+
even after `loaded`.
|
|
995
|
+
- **Caching.** None by default. `ttlSeconds` (at most 300) reuses an answer for the same
|
|
996
|
+
instance, viewer, subject and session.
|
|
997
|
+
|
|
998
|
+
The scope line, now in PRINCIPLE 8 and ARCHITECTURE: authentication and authorization *policy*
|
|
999
|
+
are the host's; carrying the host's decision safely onto the content domain is Porthole's
|
|
1000
|
+
*mechanism*. Item 7 of 0.1.2.
|
|
1001
|
+
**Why.** A session cookie good for an hour outlived a revoked share or a member who left.
|
|
1002
|
+
Sessions are stateless (P045), so revocation needed either a session store in Porthole (and with
|
|
1003
|
+
it a notion of who someone is) or the host's own check. The check lets the host apply its own
|
|
1004
|
+
policy (shares, membership, deletion) while Porthole knows nobody.
|
|
1005
|
+
**Measured** on all three engines (`leak/served/authorize.spec.ts`):
|
|
1006
|
+
- A framed url-mode app mid-session: its very next request is a 403, its reload shows the denied
|
|
1007
|
+
page, and the host gets `[loaded, blocked: denied (revoked)]`. This runs on Chromium and
|
|
1008
|
+
Firefox; WebKit keeps no cookie in a cross-site frame (P025), so a framed app never has a
|
|
1009
|
+
session there.
|
|
1010
|
+
- A framed page's next load (WebKit: `session`, for the same reason).
|
|
1011
|
+
- A revoked subject's ticket, framed: denied on all three.
|
|
1012
|
+
- In a tab of its own, an app's next request and a page's reload: denied on all three.
|
|
1013
|
+
|
|
1014
|
+
Negative control: `authorize` (the harness's server without the check) fails every case except
|
|
1015
|
+
WebKit's framed page, which expects `session` either way.
|
|
1016
|
+
**Limits.**
|
|
1017
|
+
- What is already loaded keeps running until its next request. An app's own fetches get a 403
|
|
1018
|
+
the app must handle; the host hears of it at the next load.
|
|
1019
|
+
- A `ttlSeconds` delays revocation by up to that long.
|
|
1020
|
+
- The check's latency is on every request (a host caches its own lookups, or sets a short TTL).
|
|
1021
|
+
|
|
1022
|
+
Found on the way: the url-mode entry page could leave for the app before its own load, and then
|
|
1023
|
+
the host never counted that load and never decided `loaded`. It happened reliably in the full
|
|
1024
|
+
suite on Chromium under load. The entry page now leaves only once it has loaded.
|
|
1025
|
+
|
|
1026
|
+
**Rejected.** Identity, sessions or revocation lists inside Porthole (PRINCIPLE 8); short
|
|
1027
|
+
sessions as revocation (still a window, and more tickets).
|
|
1028
|
+
**Revisit when** a host needs Porthole to push a revocation into a loaded frame (it can't today:
|
|
1029
|
+
a cross-origin frame is the content's).
|
|
1030
|
+
|
|
1031
|
+
## P051 · 2026-10-11 · Content never writes the clipboard · Accepted
|
|
1032
|
+
|
|
1033
|
+
**Decision.** Content cannot write to the clipboard by itself, on any engine. The first host's
|
|
1034
|
+
owner decided this.
|
|
1035
|
+
|
|
1036
|
+
The walls are:
|
|
1037
|
+
- The frame's `allow` denies `clipboard-read` and `clipboard-write` by name.
|
|
1038
|
+
- The shell makes the async Clipboard API refuse (`write`, `writeText`, `read`, `readText`).
|
|
1039
|
+
- `execCommand` refuses `copy`, `cut` and `paste`.
|
|
1040
|
+
- Copy and cut events are stopped at the window before any listener of the content's runs. A
|
|
1041
|
+
copy the person makes still copies their selection as the browser makes it.
|
|
1042
|
+
|
|
1043
|
+
Copy buttons go through the host instead (P052). Url mode is unchanged: an app is someone's own
|
|
1044
|
+
running app, and only the host can delegate clipboard writes to it (P049). Item 1 of 0.1.3.
|
|
1045
|
+
|
|
1046
|
+
**Why.** Measured on 0.1.2: a click in an opaque widget wrote the clipboard on all three engines.
|
|
1047
|
+
- WebKit and Firefox allowed the async API.
|
|
1048
|
+
- All three allowed the copy command and copy listeners that swap what the person copied.
|
|
1049
|
+
|
|
1050
|
+
Pastejacking into a terminal is a real risk for coding-agent hosts. A "copy this command" button
|
|
1051
|
+
that copies something else needs only a click.
|
|
1052
|
+
|
|
1053
|
+
**Measured** (`13.clipboard-api`, `13.clipboard-command`, `13.clipboard-event`; the oracle is the
|
|
1054
|
+
person's own clipboard, written and pasted on the host's page): every wall holds on Chromium,
|
|
1055
|
+
WebKit and Firefox. The negative control `clipboard` takes out the shell's walls:
|
|
1056
|
+
- The async API then writes on WebKit and Firefox. Chromium still refuses it through the `allow`
|
|
1057
|
+
denial.
|
|
1058
|
+
- The copy command and copy listeners then write on all three.
|
|
1059
|
+
|
|
1060
|
+
Moving the person's selection on their copy keystroke changed nothing on any engine, so no wall
|
|
1061
|
+
is claimed for it.
|
|
1062
|
+
|
|
1063
|
+
**What stays open.** Text hidden by CSS inside what the person selects goes with their copy. That
|
|
1064
|
+
copy is the browser's own, which the shell doesn't rewrite. Hosts point people at copy buttons,
|
|
1065
|
+
whose toast shows exactly what was copied.
|
|
1066
|
+
|
|
1067
|
+
**Rejected.**
|
|
1068
|
+
- A per-widget opt-in for direct writes: any widget would opt in.
|
|
1069
|
+
- Rewriting the person's own copy to "visible text only": an arms race with CSS, and it breaks
|
|
1070
|
+
honest copies.
|
|
1071
|
+
|
|
1072
|
+
**Revisit when** browsers let a page veto a cross-origin frame's clipboard writes (then the shell
|
|
1073
|
+
wall is a second one), or the person's copy can be shown before it lands.
|
|
1074
|
+
|
|
1075
|
+
## P052 · 2026-10-11 · Copy buttons call `copyText`: the host copies, and shows what · Accepted
|
|
1076
|
+
|
|
1077
|
+
**Decision.** A new capability, `copyText({ text })`:
|
|
1078
|
+
- **Gates:** a click (`user-activation`, which the shell and the host check), and 30 a minute.
|
|
1079
|
+
- **Limit:** at most `MAX_COPY_CHARS` (20,000) characters.
|
|
1080
|
+
- **Served by the host:** its handler writes the clipboard on the host's own page (the core's
|
|
1081
|
+
`writeClipboard` helper tries the async API, then the copy command) and shows the person what
|
|
1082
|
+
was copied.
|
|
1083
|
+
- **Reached by:** the shell global `copyText(text)` in inline widgets and artifact pages, or
|
|
1084
|
+
Porthole's own request `porthole/copy-text` (MCP Apps has no clipboard request).
|
|
1085
|
+
|
|
1086
|
+
The React binding serves it by default. The provider's handler writes the clipboard and shows a
|
|
1087
|
+
small toast (`CopyToast`) with the text, clipped to four lines and 280 characters, plus its length
|
|
1088
|
+
when clipped. If the write failed, the toast says so and offers a Copy button: a click on the
|
|
1089
|
+
host's own page, which writes it. A host's own `copyText` handler replaces it.
|
|
1090
|
+
|
|
1091
|
+
The manual tells authors to use `copyText` for every copy button; the widget topic says the
|
|
1092
|
+
clipboard is closed to them otherwise. Item 1 of 0.1.3.
|
|
1093
|
+
|
|
1094
|
+
**Why.**
|
|
1095
|
+
- Copy buttons are common in agent-made content.
|
|
1096
|
+
- The host's page can write the clipboard in the person's click, because a click in a frame gives
|
|
1097
|
+
its ancestors user activation (measured on all three engines in 0.1.2, and the copy itself in
|
|
1098
|
+
0.1.3).
|
|
1099
|
+
- Showing the text lets the person see what they are about to paste.
|
|
1100
|
+
- No confirmation dialog: a copy has no effect until the person pastes, and the toast shows it
|
|
1101
|
+
then.
|
|
1102
|
+
|
|
1103
|
+
**Measured:**
|
|
1104
|
+
- `12.copy-text`: a call on load is refused, and the whole text reaches the handler from a click.
|
|
1105
|
+
- `leak/react.spec.ts`: the toast shows the clipped text, the person's clipboard holds the whole
|
|
1106
|
+
text, and a failed write is recovered by the toast's own button. All on three engines.
|
|
1107
|
+
|
|
1108
|
+
**Rejected.** A confirm gate by default (too heavy for a copy; a host can add one, and its dialog
|
|
1109
|
+
shows the text); writing from inside the frame (P051).
|
|
1110
|
+
**Revisit when** MCP Apps defines a clipboard request (then the adapter maps it).
|
|
1111
|
+
|
|
1112
|
+
## P053 · 2026-10-11 · Annotations are mechanism: the host's comments, anchored in content it can't trust · Accepted
|
|
1113
|
+
|
|
1114
|
+
**Decision.** A new optional capability, `annotate`, declared by the host. It is for inline widgets
|
|
1115
|
+
and artifact pages, and for MCP Apps views where the host chooses. With it, the host can:
|
|
1116
|
+
- let a person pick an element or select text to comment on;
|
|
1117
|
+
- mark existing anchors over the content;
|
|
1118
|
+
- bring one into view;
|
|
1119
|
+
- learn where each anchor is now, or that it is orphaned.
|
|
1120
|
+
|
|
1121
|
+
Policy (who may comment, where comments are stored, what they look like next to the frame) stays
|
|
1122
|
+
the host's (PRINCIPLE 8). The runtime is spliced into the shell only where `annotate` is declared.
|
|
1123
|
+
|
|
1124
|
+
**Messages.** Porthole's own (P002):
|
|
1125
|
+
- Host to frame: `porthole/annotate.start | stop | show | focus | clear`.
|
|
1126
|
+
- Frame to host: `selected` (an anchor plus a rect in the frame's viewport), `unselected`,
|
|
1127
|
+
`cancelled` and `anchored`.
|
|
1128
|
+
|
|
1129
|
+
**Anchors.** W3C Web Annotation selectors, so an anchor can be found again after the content
|
|
1130
|
+
changes: `CssSelector`, `TextQuoteSelector` (with prefix and suffix) and `TextPositionSelector`,
|
|
1131
|
+
at most one of each. Finding one again is best-effort, with an explicit `orphaned`.
|
|
1132
|
+
|
|
1133
|
+
**Item.** 2 of 0.1.3. The host this matches (an artifact viewer with its own injected bridge) can
|
|
1134
|
+
drop its bridge. Its features are covered:
|
|
1135
|
+
- element grab with a hover outline and a name tag;
|
|
1136
|
+
- selection to comment, with a host-drawn affordance at the reported rect;
|
|
1137
|
+
- jump with a flash;
|
|
1138
|
+
- live updates.
|
|
1139
|
+
|
|
1140
|
+
It also gains persistent marks and re-anchoring across versions, which it lacked.
|
|
1141
|
+
|
|
1142
|
+
**Trust.** The shell shares the content's realm, so content can interfere or fake any report. The
|
|
1143
|
+
host therefore:
|
|
1144
|
+
- takes a selection only while it asked for that kind, a cancel only while picking, and results
|
|
1145
|
+
only for its own marks;
|
|
1146
|
+
- rebuilds every report from known fields within `ANNOTATION_LIMITS` and drops the rest;
|
|
1147
|
+
- rate-limits reports at 120 a minute through the bridge.
|
|
1148
|
+
|
|
1149
|
+
What a frame reports is a UI hint, never a permission. It decides where a comment is drawn,
|
|
1150
|
+
never who may see or do anything.
|
|
1151
|
+
|
|
1152
|
+
**Measured** (`leak/annotate.spec.ts`, on Chromium, WebKit and Firefox):
|
|
1153
|
+
- picking, cancelling, selecting, and both together;
|
|
1154
|
+
- marks, focus and clear;
|
|
1155
|
+
- re-anchoring on a revised page (by id, by moved words, by respaced words, and a deleted one
|
|
1156
|
+
orphaned);
|
|
1157
|
+
- live changes reported only when they change;
|
|
1158
|
+
- an MCP Apps view;
|
|
1159
|
+
- a hostile page faking every kind of report. The negative control `annotation-checks` fails it.
|
|
1160
|
+
|
|
1161
|
+
**Rejected.**
|
|
1162
|
+
- Wrapping anchors in `<mark>` elements: it rewrites the content's DOM and breaks its scripts and
|
|
1163
|
+
styles.
|
|
1164
|
+
- The CSS Custom Highlight API alone: it can't outline elements and isn't in every engine
|
|
1165
|
+
Porthole supports.
|
|
1166
|
+
- XPath selectors: brittle to the edits agents make.
|
|
1167
|
+
|
|
1168
|
+
**Revisit when** content needs to cooperate (an author-declared anchor id API), or MCP Apps defines
|
|
1169
|
+
annotations.
|
|
1170
|
+
|
|
1171
|
+
## P054 · 2026-10-11 · On a page with a nonce-based script-src, the shell carries the host's nonce · Accepted
|
|
1172
|
+
|
|
1173
|
+
**Decision.** A host whose page sends a nonce-based `script-src` (no `'unsafe-inline'`) passes its
|
|
1174
|
+
nonce to Porthole: `HostOptions.nonce` (a string, or a function read each time a frame starts),
|
|
1175
|
+
or `PortholeProvider`'s `nonce`. An opaque frame's shell document then carries it on its
|
|
1176
|
+
`<script>` and `<style>`. The shell gives it to every script it runs for the content, and to the
|
|
1177
|
+
annotation layer's style. Item 5 of 0.1.4 (the second host's friction).
|
|
1178
|
+
|
|
1179
|
+
**Why.** A `srcdoc` frame inherits the embedding page's whole policy, so a strict page blocked the
|
|
1180
|
+
shell. The host this was built for had to run the conformance suite on a stripped page. Served
|
|
1181
|
+
frames are navigations to an origin of their own, so they inherit nothing and need no nonce.
|
|
1182
|
+
|
|
1183
|
+
**Measured.** The whole leak suite runs again on a reference page whose policy is
|
|
1184
|
+
`script-src 'nonce-…'; object-src 'none'; base-uri 'none'` (`leak/strict-csp.spec.ts`). It
|
|
1185
|
+
passes on Chromium, WebKit and Firefox. Its negative control `nonce` (the shell without the
|
|
1186
|
+
nonce) fails it.
|
|
1187
|
+
|
|
1188
|
+
**Risk.** The frame learns the page's nonce:
|
|
1189
|
+
- its scripts carry it;
|
|
1190
|
+
- the shell's own script element is in its document.
|
|
1191
|
+
|
|
1192
|
+
A nonce is per response. It helps an attacker only together with a markup injection into that
|
|
1193
|
+
same response of the host's page, which the content can't make by itself. A host that also
|
|
1194
|
+
renders content-supplied HTML in its own page has a bigger problem than the nonce.
|
|
1195
|
+
|
|
1196
|
+
**Limits.** Inline event handlers in content (`onclick="…"`) stay blocked by such a policy, as they
|
|
1197
|
+
are on the page itself; scripts and `addEventListener` work. A nonce-based `style-src` would also
|
|
1198
|
+
block the content's inline styles; the shell doesn't rewrite those.
|
|
1199
|
+
|
|
1200
|
+
**Rejected.**
|
|
1201
|
+
- Hash-based sources Porthole publishes for its shell: the content's scripts would still be
|
|
1202
|
+
blocked.
|
|
1203
|
+
- `data:` or `blob:` frames to escape inheritance: they inherit too, or need `frame-src` entries.
|
|
1204
|
+
- Requiring `'strict-dynamic'`: it's the host's policy to choose.
|
|
1205
|
+
|
|
1206
|
+
**Revisit when** browsers let an embedding page give a `srcdoc` frame a policy of its own instead
|
|
1207
|
+
of its own.
|
|
1208
|
+
|
|
1209
|
+
## P055 · 2026-10-11 · What the content did wrong and what failed on the host's side are separate · Accepted
|
|
1210
|
+
|
|
1211
|
+
**Decision.** A display reports `contentError` (what the content threw, what the sandbox blocked,
|
|
1212
|
+
that it kept navigating its frame away: the agent's to hear and fix) apart from `hostError`
|
|
1213
|
+
(`{ message, kind }`). `kind` is `locate` when the host's locator rejected; in the React binding
|
|
1214
|
+
it can also be `mount` (a display that couldn't be made) or `annotations` (an annotation call
|
|
1215
|
+
refused). A failed locate also ends the display's load as `outcome: blocked, reason: locate`.
|
|
1216
|
+
|
|
1217
|
+
`error` still fires for both, now with `source: "content" | "host"`, so hosts that listened there
|
|
1218
|
+
keep working. The React binding adds `onContentError` and `onHostError`; `onError` now gets
|
|
1219
|
+
`(message, source)`. Item 1 of 0.1.4 (both hosts' friction).
|
|
1220
|
+
|
|
1221
|
+
**Why.** Both hosts forward the content's errors to the agent. A failed locate (the server down,
|
|
1222
|
+
access gone) is the host's own, and the agent would have tried to fix a widget for it. One host
|
|
1223
|
+
tagged its own messages to tell them apart; the other caught inside its locator. A failed locate
|
|
1224
|
+
also used to leave no outcome at all.
|
|
1225
|
+
|
|
1226
|
+
**Tested.** On all three engines:
|
|
1227
|
+
- core (`leak/served/outcome.spec.ts`): a failing locator gives `blocked: locate` and one host
|
|
1228
|
+
error, no content error; a throwing widget gives a content error only;
|
|
1229
|
+
- React (`leak/react.spec.ts`): a throwing widget goes to `onContentError`, a display with an
|
|
1230
|
+
unserved capability to `onHostError` (`mount`), each with its source on `onError`.
|
|
1231
|
+
|
|
1232
|
+
Found on the way: on WebKit and Firefox a content error was reported without its message, because
|
|
1233
|
+
their `error.stack` doesn't start with it the way V8's does, so the agent heard only a stack. The
|
|
1234
|
+
shell now puts the message first there. Measured: WebKit mutes an opaque (`srcdoc`) frame's script
|
|
1235
|
+
errors to "Script error.", so there the host gets a content error without its message; served
|
|
1236
|
+
frames carry it on all three engines.
|
|
1237
|
+
**Rejected.** Dropping host failures from `error` (it would break hosts that relied on seeing a
|
|
1238
|
+
failed locate there).
|
|
1239
|
+
**Revisit when** `error` can be removed (a major version).
|
|
1240
|
+
|
|
1241
|
+
## P056 · 2026-10-11 · `/server` routing a host can rely on · Accepted
|
|
1242
|
+
|
|
1243
|
+
**Decision.** Items 3, 4 and 8 of 0.1.4, and the second host's headers:
|
|
1244
|
+
- **Reserved labels.** `content.reserved` takes whole labels, prefixes ending in `*` (`p-*`), or a
|
|
1245
|
+
function of the label. A function that throws reserves the label, so the request goes to the
|
|
1246
|
+
host's routes rather than being served as content. Requests in a reserved namespace return
|
|
1247
|
+
null, so a host routes its own `p-`/`w-` labels without wrapping `handle`. A host's naming may
|
|
1248
|
+
never produce a reserved label.
|
|
1249
|
+
- **Host header.** The label is read from the request URL. If the Host header names another host
|
|
1250
|
+
and either of the two is under the content domain, the answer is a 400 page. A Host that is a
|
|
1251
|
+
local address (an IP literal, `localhost`, as Miniflare's `dispatchFetch` or a dev proxy sends)
|
|
1252
|
+
is not a name and isn't compared.
|
|
1253
|
+
- **No upstream.** A server without an `upstream` serves only Porthole's own paths
|
|
1254
|
+
(`/__porthole/…`). Any other path gets a plain 404, not url mode's 401 "Open this from the
|
|
1255
|
+
app".
|
|
1256
|
+
- **Indexing.** Every answer of Porthole's own says `x-robots-tag: noindex, nofollow`. Content
|
|
1257
|
+
origins are behind tickets.
|
|
1258
|
+
- **Frame sources.** `embedderFrameSourcesOf(configs)` returns the embedding page's `frame-src`
|
|
1259
|
+
sources as a list, for a host that writes its own policy around them.
|
|
1260
|
+
|
|
1261
|
+
**Why.**
|
|
1262
|
+
- One host shares its content domain with its own preview gateway on `p-…` labels. It had to
|
|
1263
|
+
route by label before `handle`.
|
|
1264
|
+
- A Node adapter (Hono's `getRequestListener`) builds the URL from an absolute-form target
|
|
1265
|
+
(`GET http://other/…`) while the host, and any cache in front of it, routes by Host. A crafted
|
|
1266
|
+
request could get content served under another host's name.
|
|
1267
|
+
- The second host rewrote every non-Porthole path to `/__porthole/not-found` to avoid the
|
|
1268
|
+
misleading 401, and sliced `embedderCsp`'s string to merge it.
|
|
1269
|
+
|
|
1270
|
+
**Tested.** Server tests cover:
|
|
1271
|
+
- prefixes, a predicate, and a throwing predicate;
|
|
1272
|
+
- names that would be reserved;
|
|
1273
|
+
- a crafted Host, an absolute-form target, an agreeing Host with a default port, and a local
|
|
1274
|
+
address;
|
|
1275
|
+
- the 404 without an upstream;
|
|
1276
|
+
- the noindex header.
|
|
1277
|
+
|
|
1278
|
+
The Workers test (where Miniflare sends a local Host) still passes.
|
|
1279
|
+
**Rejected.** Reading the label from the Host header instead: on Workers the URL is the routed
|
|
1280
|
+
truth, and test runners send a local Host. Refusing every mismatch: it would refuse those runners.
|
|
1281
|
+
**Revisit when** a host needs Porthole behind a proxy that rewrites Host (then
|
|
1282
|
+
`X-Forwarded-Host`, by a decision).
|
|
1283
|
+
|
|
1284
|
+
## P057 · 2026-10-11 · Hosts lay out, identify and annotate displays as they write them · Accepted
|
|
1285
|
+
|
|
1286
|
+
**Decision.** Items 2, 6, 7, 9 and 10 of 0.1.4:
|
|
1287
|
+
- **Shared storage, in the manual.** A config may say what the displays sharing an origin share
|
|
1288
|
+
(`storage: { shared: "conversation" }`, domain isolation only). The manual then says storage is
|
|
1289
|
+
shared across that scope and that keys need a prefix of the display's own.
|
|
1290
|
+
- **Fill.** `MountOptions.fill` (React: `fill`) makes the frame fill its container, with the content
|
|
1291
|
+
scrolling inside, instead of growing with the content. Sizes are still reported.
|
|
1292
|
+
- **Identity in React.** `config`, `instance`, `annotations` and the provider's `hostInfo` are
|
|
1293
|
+
compared by value. The provider's `locator` (like `confirm`) is read when used. An inline object
|
|
1294
|
+
no longer starts a running frame again.
|
|
1295
|
+
- **Annotations settle.** Nothing is reported before the content is in place, nor while a run of
|
|
1296
|
+
changes settles. A `focus` asked for meanwhile waits, then scrolls and reports, so a focus right
|
|
1297
|
+
after `show` says where the mark is, never `orphaned` first. React's `focusedAnnotation` takes
|
|
1298
|
+
`{ id, key }` to focus the same mark again.
|
|
1299
|
+
- **Served shells in the conformance suite.** `ServedConformanceHost.show` gets `options.content`
|
|
1300
|
+
for each shell, and a host that speaks to the shell sends it. A shell draws nothing, and reports
|
|
1301
|
+
no height, until it has content.
|
|
1302
|
+
|
|
1303
|
+
**Why.**
|
|
1304
|
+
- One host scopes storage per conversation, so its widgets overwrote each other's keys untold.
|
|
1305
|
+
- The second host needed `!important` CSS on Porthole's internal attributes to make an artifact
|
|
1306
|
+
fill its viewer.
|
|
1307
|
+
- Hosts memoized config, instance, hostInfo and marks by hand, and one compared marks as JSON
|
|
1308
|
+
itself.
|
|
1309
|
+
- Both hosts worked around a focus that said `orphaned` first.
|
|
1310
|
+
- The first host's conformance host had to give shells content by itself.
|
|
1311
|
+
|
|
1312
|
+
**Tested.** On all three engines:
|
|
1313
|
+
- React: inline objects across re-renders keep one display; an artifact fills its 300 px box and
|
|
1314
|
+
scrolls inside, and without `fill` grows to its cap.
|
|
1315
|
+
- Annotations: a focus right after `show` gives two reports, both attached, the focused mark in
|
|
1316
|
+
view; marks shown before the content are reported once it arrives.
|
|
1317
|
+
- The served shell cases pass against a `createHost` host whose frames take their content's height.
|
|
1318
|
+
Without content they time out clicking (checked by hand).
|
|
1319
|
+
|
|
1320
|
+
Unit tests cover the storage note and its validation.
|
|
1321
|
+
|
|
1322
|
+
**Rejected.**
|
|
1323
|
+
- A dev-only warning for unstable props: comparing by value removes the need.
|
|
1324
|
+
- `fill` in the config: it is the host's layout, per mount.
|
|
1325
|
+
|
|
1326
|
+
**Revisit when** a host's config or instance carries something that isn't plain data (then
|
|
1327
|
+
compare it otherwise).
|
|
1328
|
+
|
|
1329
|
+
## P058 · 2026-10-11 · An opaque frame is as closed as its page; the suite says when a case can't be observed · Accepted
|
|
1330
|
+
|
|
1331
|
+
**Decision.** This is now documented in ARCHITECTURE (the network boundary) and DESIGN: a `srcdoc`
|
|
1332
|
+
frame inherits the embedding page's whole policy along with its own. On a page with
|
|
1333
|
+
`default-src 'none'` and a tight `connect-src` or `img-src`, opaque-mode content is more closed
|
|
1334
|
+
than its config says. On such a page, hosts should prefer domain mode, whose frames inherit nothing.
|
|
1335
|
+
|
|
1336
|
+
The conformance suite detects this. When a positive control (`1.connect`'s allowed fetch,
|
|
1337
|
+
`2.resources`'s allowed image) fails, the probe loads from the allowed origin once more and
|
|
1338
|
+
watches for a CSP violation. A violation there can't come from the frame's own policy, which
|
|
1339
|
+
allows that origin, so it is the page's. The probe then reports itself **inapplicable on this
|
|
1340
|
+
host** (skipped, with the reason) instead of failing.
|
|
1341
|
+
|
|
1342
|
+
`12.no-confirm` does the same on a host that always has a confirm function (a default dialog), so
|
|
1343
|
+
a display without one can't be shown. When it is asked anyway, it answers no and reports
|
|
1344
|
+
inapplicable.
|
|
1345
|
+
|
|
1346
|
+
Probes get `context.inapplicable(reason)`. Item 3 of 0.1.5 (the second host's adoption).
|
|
1347
|
+
|
|
1348
|
+
**Why.** The second host's real page sends `default-src 'none'` and `connect-src 'self'`. Its
|
|
1349
|
+
suite failed two positive controls that prove nothing about its isolation, and failed one case
|
|
1350
|
+
that its React host can't set up.
|
|
1351
|
+
|
|
1352
|
+
**Tested.** `leak/inherited-policy.spec.ts` runs the three cases against a reference page closed
|
|
1353
|
+
like the second host's (with its nonce, P054) and against a host that always confirms. Each
|
|
1354
|
+
reports inapplicable with its reason. Against the plain reference host they still run and pass.
|
|
1355
|
+
|
|
1356
|
+
**Rejected.** Skipping the positive controls by host configuration (a host would turn them off
|
|
1357
|
+
everywhere); parsing the page's policy from its headers (frames see what applies, headers don't
|
|
1358
|
+
show meta policies).
|
|
1359
|
+
**Revisit when** a browser lets an embedding page give a `srcdoc` frame a policy of its own.
|
|
1360
|
+
|
|
1361
|
+
## P059 · 2026-10-11 · Passive content may load from any https site, by the host's explicit choice · Accepted
|
|
1362
|
+
|
|
1363
|
+
**Decision.** `egress.anyHttps` lists the passive kinds that may load from any https site:
|
|
1364
|
+
`img`, `media` and `font`. Those CSP directives get `https:`. Scripts, styles, data (`connect`),
|
|
1365
|
+
workers and frames never get it, and a config naming any other kind is refused. The manual and
|
|
1366
|
+
the tool description say "images … from any https site". Nothing is opened by default. The MCP
|
|
1367
|
+
sandbox proxy keeps the host config's choice; a view can't ask for it. Item 2 of 0.1.5.
|
|
1368
|
+
|
|
1369
|
+
**Risk it re-opens.**
|
|
1370
|
+
- Any site the content names learns that someone viewed it, and from which address.
|
|
1371
|
+
- The content can send data out in an image URL to a server of its choosing. Egress closes this
|
|
1372
|
+
channel by default (P006); this flag reopens it for these kinds.
|
|
1373
|
+
- An attacker who controls an image can show anything in the frame. The frame is still the
|
|
1374
|
+
content's own; nothing reaches the host.
|
|
1375
|
+
|
|
1376
|
+
**Why.** A host showing existing reports needs their hotlinked images to work.
|
|
1377
|
+
|
|
1378
|
+
**Tested** (`leak/cdn.spec.ts`, all three engines): with `anyHttps: ["img", "media"]`, a hotlinked
|
|
1379
|
+
image from a routed stranger's site loads. A script, a stylesheet and a fetch to the same site
|
|
1380
|
+
never reach it. Without the flag, the image doesn't either. The agreement test checks that only
|
|
1381
|
+
passive directives carry `https:` and that the manual says "from any https site" exactly when the
|
|
1382
|
+
CSP does.
|
|
1383
|
+
|
|
1384
|
+
**Rejected.**
|
|
1385
|
+
- `*` or `http:` (cleartext, and loopback/LAN reach).
|
|
1386
|
+
- Allowing it for styles: CSS can probe and leak through selectors.
|
|
1387
|
+
- Per-site lists: the host doesn't know the hotlinked sites.
|
|
1388
|
+
|
|
1389
|
+
**Revisit when** a host needs it for a kind that runs code: never by this flag.
|
|
1390
|
+
|
|
1391
|
+
## P060 · 2026-10-11 · A wildcard over a public suffix is refused · Accepted
|
|
1392
|
+
|
|
1393
|
+
**Decision.** An origin source `scheme://*.<base>` is refused when `<base>` is a single label
|
|
1394
|
+
(`*.com`, `*.dev`; except `localhost`, for loopback development). It is also refused when
|
|
1395
|
+
`<base>` is on a built-in list of common public suffixes:
|
|
1396
|
+
- registries' second levels (`co.uk`, `com.au`, `co.jp`, …);
|
|
1397
|
+
- hosting platforms where anyone gets a name (`github.io`, `pages.dev`, `workers.dev`,
|
|
1398
|
+
`vercel.app`, `netlify.app`, `amazonaws.com`, `cloudfront.net`, …).
|
|
1399
|
+
|
|
1400
|
+
The refusal says why and what to write instead. A wildcard over a domain under such a suffix
|
|
1401
|
+
(`*.example.co.uk`, `*.me.github.io`) is fine. This applies to egress, content origins, and an
|
|
1402
|
+
MCP view's declared domains (dropped there). Item 1 of 0.1.5 (a bug: `https://*.com` was
|
|
1403
|
+
accepted).
|
|
1404
|
+
|
|
1405
|
+
**Why.** A wildcard over a public suffix opens every stranger's site, which is what egress control
|
|
1406
|
+
exists to prevent.
|
|
1407
|
+
|
|
1408
|
+
**Tested** in unit tests: the refusals, the reason, wildcards accepted under such suffixes, and a
|
|
1409
|
+
preset refusing a content origin.
|
|
1410
|
+
|
|
1411
|
+
**Rejected.** Shipping the whole Public Suffix List (about 200 KB, and a moving target). A wildcard
|
|
1412
|
+
should name the host's own domain; the list covers the common ways it wouldn't.
|
|
1413
|
+
**Revisit when** a host hits a suffix the list lacks (add it).
|
|
1414
|
+
|
|
1415
|
+
## P061 · 2026-10-11 · A theme event means the theme changed · Accepted
|
|
1416
|
+
|
|
1417
|
+
**Decision.** The shell fires the config's theme event only when the scheme or an applied value
|
|
1418
|
+
changed since the content was last told:
|
|
1419
|
+
- never for the theme a display starts with, at mount;
|
|
1420
|
+
- never for a host context that repeats what the content has.
|
|
1421
|
+
|
|
1422
|
+
The React binding no longer calls `setTheme` or `refreshTheme` on mount: it does so only when
|
|
1423
|
+
`scheme` or `themeKey` differ from what the display was made with. Item 4 of 0.1.5
|
|
1424
|
+
(botiverse/porthole#3).
|
|
1425
|
+
|
|
1426
|
+
**Why.** Under the React binding, every mount called `setTheme` with the scheme the display
|
|
1427
|
+
already had, two animation frames later. When the handshake and the content's scripts finished
|
|
1428
|
+
within those frames, the content heard a "flip" to its own scheme. `[11.theme-flip]` failed about
|
|
1429
|
+
1 run in 5 on the first host's React conformance host.
|
|
1430
|
+
|
|
1431
|
+
**Tested:**
|
|
1432
|
+
- `11.theme-flip` now asserts no event at mount and none for a `setTheme` to the same scheme,
|
|
1433
|
+
before it flips (all three engines, repeated).
|
|
1434
|
+
- A React test mounts a display listening for theme events, re-renders it unchanged, then flips
|
|
1435
|
+
it: exactly one event.
|
|
1436
|
+
|
|
1437
|
+
**Revisit when** content needs an event for values that change while the scheme doesn't (it gets
|
|
1438
|
+
one now; the rule is "changed", not "flipped").
|
|
1439
|
+
|
|
1440
|
+
## P062 · 2026-10-11 · `served.domain.egress` holds a host to its own config's egress · Accepted
|
|
1441
|
+
|
|
1442
|
+
**Decision.** The served egress case no longer assumes images are closed. It tries a fetch, an
|
|
1443
|
+
image, an audio element, a script, a stylesheet and a nested frame toward the host's canaries. It
|
|
1444
|
+
reads the policy that applied to the document from the violations those attempts raise.
|
|
1445
|
+
- **Always.** Scripts, styles, fetches and frames never reach the canary, and none of their
|
|
1446
|
+
directives (nor `default-src`) opens a scheme.
|
|
1447
|
+
- **Images and media** must stay closed, unless the config opened them to any https site
|
|
1448
|
+
(`anyHttps`, P059). Then, with an https canary, the image must reach it. The case reports this
|
|
1449
|
+
as expected (an annotation naming P059), not as a failure, the way P058 reports a page's own
|
|
1450
|
+
policy. Media is measured, not asserted (engines differ on preloading).
|
|
1451
|
+
|
|
1452
|
+
Follow-up to 0.1.5 (the second host's adoption).
|
|
1453
|
+
**Why.** A host that opts into `anyHttps` had to skip the built-in case and write its own
|
|
1454
|
+
(`served.domain.egress-any-https`). Its logic is folded in here, so the host can drop it.
|
|
1455
|
+
**Tested.**
|
|
1456
|
+
- The case passes against the reference serving and a `createHost` host, both closed.
|
|
1457
|
+
- It passes against a host whose shells open images and media (`leak/served/any-https.spec.ts`,
|
|
1458
|
+
with its canaries on a routed https site). The image and the audio reached the site; the fetch,
|
|
1459
|
+
script, stylesheet and frame didn't.
|
|
1460
|
+
|
|
1461
|
+
## P064 · 2026-10-11 · The manual states every sandbox limit content can trip over, and each claim has a browser check · Accepted
|
|
1462
|
+
|
|
1463
|
+
**Decision.** The `widget` topic's "The sandbox:" list replaces the old "Not allowed" line. Each
|
|
1464
|
+
line comes from `manualLimits(config)`, which derives every limit from the config: the sandbox
|
|
1465
|
+
tokens, declared capabilities and globals, egress, and isolation. Every limit has an id, and
|
|
1466
|
+
`LIMIT_CHECKS` names the browser checks that prove it. These are new item-14 probes (`14.<id>`)
|
|
1467
|
+
and served cases (`served.domain.address`, `served.domain.storage-scope`).
|
|
1468
|
+
|
|
1469
|
+
`test/limits.test.ts` fails in three cases:
|
|
1470
|
+
- a limit names no check;
|
|
1471
|
+
- a limit names a check no suite defines;
|
|
1472
|
+
- the list holds a line that isn't a limit.
|
|
1473
|
+
|
|
1474
|
+
So a new line can't go untested.
|
|
1475
|
+
|
|
1476
|
+
The limits:
|
|
1477
|
+
- **Failure:** what's blocked fails silently (worded by `errors` since 0.1.8: P067).
|
|
1478
|
+
- **Loading:** the network; assets inline.
|
|
1479
|
+
- **Shell-less behavior:** dialogs (confirm false, prompt null); print; downloads; forms;
|
|
1480
|
+
mailto:/tel:/sms: links; windows.
|
|
1481
|
+
- **Platform:** devices denied without asking (file inputs work); blob: workers (no service
|
|
1482
|
+
workers, no WebRTC); sound only from a click.
|
|
1483
|
+
- **Context:** the frame's address; the shell's own removals.
|
|
1484
|
+
- **Storage** (in the interactive topic): it can throw in domain mode.
|
|
1485
|
+
|
|
1486
|
+
Item 1 of 0.1.7 (the owner's benchmark against another artifact host's page contract).
|
|
1487
|
+
|
|
1488
|
+
**Why.** The manual named only some limits ("Not allowed: …"), and several silently change
|
|
1489
|
+
behavior. The advice to handle forms on submit would have failed: Chromium and Firefox fire no
|
|
1490
|
+
submit event in a sandbox without `allow-forms`.
|
|
1491
|
+
|
|
1492
|
+
**Measured** on Chromium, WebKit and Firefox, in opaque frames and on the served origin. Each
|
|
1493
|
+
difference is stated as the behavior safe on all three:
|
|
1494
|
+
- submit events fire on WebKit only;
|
|
1495
|
+
- `window.open` returns null on two engines and throws on Firefox;
|
|
1496
|
+
- an opaque frame's `navigator.serviceWorker` throws on reading on Chromium and WebKit, and
|
|
1497
|
+
`register` rejects on Firefox;
|
|
1498
|
+
- `play()` without a click is refused on Chromium and WebKit and allowed by Playwright's Firefox;
|
|
1499
|
+
- getUserMedia rejects with `NotFoundError` on headless Chromium and `NotAllowedError` elsewhere;
|
|
1500
|
+
- Web Share is absent on all three.
|
|
1501
|
+
|
|
1502
|
+
**Limits.** "Reads and writes can throw" (domain storage) and "sound only from a click" are
|
|
1503
|
+
browser policy that the suite can't force everywhere. Their checks prove the half that is
|
|
1504
|
+
observable (storage persists per scope; `play()` in a click plays). On a page whose own policy
|
|
1505
|
+
closes blob: workers, `14.workers` reports itself inapplicable (P058).
|
|
1506
|
+
**Rejected.** A hand-written list of limits (it drifts from the config); stating per-engine
|
|
1507
|
+
behavior (the manual is read once, for every engine).
|
|
1508
|
+
**Revisit when** a sandbox token or capability changes what content can do: add its limit and a
|
|
1509
|
+
check, or the unit test fails.
|
|
1510
|
+
|
|
1511
|
+
## P065 · 2026-10-11 · A config says a whole-page viewer's shape, and the manual and behavior follow · Accepted
|
|
1512
|
+
|
|
1513
|
+
**Decision.** The first artifact-page host's served config made the manual say false things. New
|
|
1514
|
+
config fields express its shape, and each drives the manual and the behavior it implies:
|
|
1515
|
+
- **`layout.kind: "fill"`** (default `"flow"`):
|
|
1516
|
+
- Behavior: every mount fills its container (`MountOptions.fill`, React `fill`), and the
|
|
1517
|
+
content scrolls itself.
|
|
1518
|
+
- Manual: "about N px wide on a phone and as wide as the window on a desktop"; "as tall as the
|
|
1519
|
+
window: the page scrolls itself, and position: fixed and sticky work"; read the window's
|
|
1520
|
+
width. The card's height cap and the "No position: fixed" line go. The tool description
|
|
1521
|
+
says it fills the viewer.
|
|
1522
|
+
- **`theme.documents: "never"`** (pages only; refused for fragments, which take the theme they
|
|
1523
|
+
are drawn in):
|
|
1524
|
+
- Behavior: the frame always gets the light values and a light `color-scheme`, and the shell
|
|
1525
|
+
never themes the page even if it declares light and dark. `setTheme` doesn't reach it, so it
|
|
1526
|
+
hears no theme event.
|
|
1527
|
+
- Manual: it always shows in light; style it yourself; `prefers-color-scheme` may report the
|
|
1528
|
+
reader's system setting, so don't rely on it. The theme-change and redraw sentences go.
|
|
1529
|
+
- **`limits.maxBytes`** is stated in KB, or MB from a megabyte up ("Keep it under 10 MB.").
|
|
1530
|
+
- **`storage.scope`** (domain only, exclusive with `storage.shared`) names what one display's
|
|
1531
|
+
storage is kept for ("this artifact, across its versions"). It adds no prefix advice, since
|
|
1532
|
+
only one display uses the origin at a time.
|
|
1533
|
+
- **`errors: "agent" | "host" | "none"`** says where the content's errors go:
|
|
1534
|
+
- `agent`: "comes back to you, so you can fix it";
|
|
1535
|
+
- `host`: "goes to the app, not to you: test what you write";
|
|
1536
|
+
- `none`: no `reportError` (the preset drops it; validation keeps the two consistent), and
|
|
1537
|
+
"nothing reports what your code throws".
|
|
1538
|
+
|
|
1539
|
+
Absent, the old "reported to the app" stays.
|
|
1540
|
+
|
|
1541
|
+
Item 2 of 0.1.7.
|
|
1542
|
+
|
|
1543
|
+
**Measured** with the person on dark: inside a page's frame whose element says light,
|
|
1544
|
+
`prefers-color-scheme` reports light on WebKit and Firefox and dark on Chromium. Hence the
|
|
1545
|
+
manual's "may report the reader's system setting".
|
|
1546
|
+
|
|
1547
|
+
**Tested.**
|
|
1548
|
+
- Browser checks (all three engines, in `LIMIT_CHECKS` under `layout`, `theme` and `errors`):
|
|
1549
|
+
- `14.fill`: the frame keeps its box's 400 px, the page scrolls, a fixed bar stays at the top;
|
|
1550
|
+
- `14.theme-never`: light values, no `data-theme`, a light frame element, no theme event after
|
|
1551
|
+
a flip to dark;
|
|
1552
|
+
- `14.errors`: a throw reaches the host with `reportError`, and nothing reaches it with
|
|
1553
|
+
`errors: "none"`.
|
|
1554
|
+
- Unit tests: the manual and tool description for the viewer's config, every field's
|
|
1555
|
+
validation, and an agreement case.
|
|
1556
|
+
|
|
1557
|
+
**Rejected.**
|
|
1558
|
+
- Host-side patches to the manual's text: the config is the source.
|
|
1559
|
+
- Making `"never"` the default for pages: hosts that theme pages would lose it.
|
|
1560
|
+
**Revisit when** a host needs a theme of its own that isn't light (then a fixed scheme value).
|
|
1561
|
+
|
|
1562
|
+
## P066 · 2026-10-12 · The host kit: an app writes its policy, Porthole the machinery around `/server` · Accepted
|
|
1563
|
+
|
|
1564
|
+
**Decision.** Porthole 0.2.0 takes in the host-side machinery both hosts wrote around `/server`
|
|
1565
|
+
(DESIGN § "The host kit"):
|
|
1566
|
+
- `createContentHost`: the routing middleware (fetch/Workers; reserved labels handed back), the
|
|
1567
|
+
issue endpoint, `meta`, `frameSources`, the domain helpers and their guards, and an optional
|
|
1568
|
+
health gate with an opaque fallback;
|
|
1569
|
+
- `@botiverse/porthole/node`: the Node adapter, WebSocket upgrades included, and the health
|
|
1570
|
+
probe;
|
|
1571
|
+
- the client's `httpLocator` and `servedOrOpaque`;
|
|
1572
|
+
- a framework-free conformance runner an app points at its real page (`defineHostConformance`);
|
|
1573
|
+
- a prod-shaped docker profile (`ops/prod-shape`) and CI on every push.
|
|
1574
|
+
|
|
1575
|
+
The app keeps four callbacks (`instance`, `access`, `authorize`, `source`), plus `entry.at` with
|
|
1576
|
+
redirect entry (P068), and its settings. Answers to the questions left open by the proposal:
|
|
1577
|
+
1. **Clashes.** Porthole never serves a content domain that holds an app origin or may share
|
|
1578
|
+
its site. With the health gate (the opaque fallback) on, such settings are a lasting
|
|
1579
|
+
"unhealthy: misconfigured, <reason>": logged once with a stable event name
|
|
1580
|
+
(`porthole.content.misconfigured`, its `code`), `meta().served` false, frames opaque.
|
|
1581
|
+
Without it, `contentDomain`/`createContentHost` throw a `ContentDomainError` at startup.
|
|
1582
|
+
Fail closed either way; no separate switch.
|
|
1583
|
+
2. **The issue body** is always `{ instance }`, the `InstanceRef` wire shape. The app's
|
|
1584
|
+
`instance` callback treats it as a proposal: it validates and normalizes it, derives the scope
|
|
1585
|
+
from what it names, and refuses anything else. App ids travel in the document's fields. The
|
|
1586
|
+
client never chooses an origin.
|
|
1587
|
+
3. **The conformance harness has no React.** It is framework-free, on the core's `createHost`,
|
|
1588
|
+
loaded on the app's page as a script from the page's own origin under its nonce. An app's own
|
|
1589
|
+
frame components are covered by its own e2e; Porthole's React binding by Porthole's tests.
|
|
1590
|
+
|
|
1591
|
+
The Workers path uses no Node API (tested under workerd with D1); the health probe is optional
|
|
1592
|
+
there.
|
|
1593
|
+
**Why.**
|
|
1594
|
+
- The first host wrote 1,738 lines (domain, displays, health, the content router, the client's
|
|
1595
|
+
served frames, its conformance host); the second 2,305 (content service, content mapping, the
|
|
1596
|
+
frame API, the client frame, its conformance tools). Most of it is the same code.
|
|
1597
|
+
- Their copies already disagreed: the scheme from a public URL or from `*.localhost`; a clash
|
|
1598
|
+
logged and disabled, or failed closed. Neither was tested over TLS or on same-site siblings.
|
|
1599
|
+
|
|
1600
|
+
**Measured** in the prod shape (https, Caddy's internal CA, `*.content.test` siblings):
|
|
1601
|
+
- the whole leak suite and the served cases pass on Chromium, WebKit and Firefox, as on
|
|
1602
|
+
loopback;
|
|
1603
|
+
- a `Domain=content.test` cookie set by a sibling label reaches every content origin (same site
|
|
1604
|
+
without a Public Suffix List entry, P047); `__Host-` cookies can't be set that way, and nothing
|
|
1605
|
+
a sibling plants opens content;
|
|
1606
|
+
- Playwright's Firefox reads neither the system's trust store nor an enterprise policy, so its
|
|
1607
|
+
prod-shape project overrides certificate errors (pages stay https, secure contexts); Chromium
|
|
1608
|
+
and WebKit check the chain.
|
|
1609
|
+
|
|
1610
|
+
**Rejected.** A separate switch for clash handling (two ways to fail, one of them open); the
|
|
1611
|
+
app's own body fields on the wire (each app's client would choose what the server trusts);
|
|
1612
|
+
bundling React in the harness (it would test Porthole's binding on the app's page, not the app).
|
|
1613
|
+
**Revisit when** a third host needs something the four callbacks can't say.
|
|
1614
|
+
|
|
1615
|
+
## P067 · 2026-10-12 · The manual's silent line says where a block is reported, by `errors` · Accepted
|
|
1616
|
+
|
|
1617
|
+
**Decision.** The `silent` limit (P064) follows the config's `errors` (P065), as the Errors line
|
|
1618
|
+
does:
|
|
1619
|
+
- no `reportError` (`errors: "none"`, or none declared): "fails silently … and nothing shows an
|
|
1620
|
+
error", as before;
|
|
1621
|
+
- `agent`: a block shows nothing on the page, and comes back to the agent as an error, so it
|
|
1622
|
+
checks them;
|
|
1623
|
+
- `host`: the app is told of each block, the agent isn't;
|
|
1624
|
+
- unset with `reportError` declared: each block is reported to the app.
|
|
1625
|
+
|
|
1626
|
+
`14.silent` now checks both halves: nothing is shown and nothing leaves, and the host hears of a
|
|
1627
|
+
block where `reportError` is declared and nothing where it isn't (`errors: "none"`). Issue #4,
|
|
1628
|
+
0.1.8.
|
|
1629
|
+
**Why.** The shell reports every CSP violation through `reportError` ("Blocked by the sandbox:
|
|
1630
|
+
…"). For a host with `errors: "agent"` (Ferry), "nothing shows an error" was false, and it
|
|
1631
|
+
contradicted the manual's own Errors line. The check asserted only that no dialog opened, so it
|
|
1632
|
+
passed while the line claimed more than it proved.
|
|
1633
|
+
**Rejected.** Ending the line at "a blocked fetch rejects": the agent then isn't told that blocks
|
|
1634
|
+
come back to it, which is what makes it check them.
|
|
1635
|
+
**Revisit when** the shell reports blocks other than through `reportError`.
|
|
1636
|
+
|
|
1637
|
+
## P068 · 2026-10-12 · Redirect entry: every instance has a clean address that opens directly · Accepted
|
|
1638
|
+
|
|
1639
|
+
**Decision.** With `entry` set, every instance has a clean, stable address (`urlOf(scope,
|
|
1640
|
+
path)`, `https://a-<id>.content.example.com/v/3`) that works when opened directly: a new tab, a
|
|
1641
|
+
reload, a bookmark, a link sent to someone with access.
|
|
1642
|
+
- A request on a clean address without a cookie for it is sent (302) to the app's authorize
|
|
1643
|
+
route (`ContentHost.authorize`, on an app origin) with a signed state: the label, the path, a
|
|
1644
|
+
nonce, five minutes. The content origin sets a cookie holding the nonce, in the session
|
|
1645
|
+
cookie's context (partitioned in a frame, `Lax` on a tab).
|
|
1646
|
+
- The route asks the app: `entry.at(scope, path)` (the path names the instance; the query never
|
|
1647
|
+
does) and `access`. Not signed in: on a tab, the app's own sign-in (`signIn(returnTo)`), which
|
|
1648
|
+
comes back to the route; in a frame, never the sign-in, a plain page asking for a new tab
|
|
1649
|
+
(`blocked: sign-in`). No access: the app's refusal page on a tab (`entry.refusal`), a plain
|
|
1650
|
+
page in a frame; never content.
|
|
1651
|
+
- Granted: a 302 to `/__porthole/entry?code=…` on the instance's own origin. The code is a
|
|
1652
|
+
one-time ticket (60 s) bound to the label, the path and the nonce. It is redeemed once, only on
|
|
1653
|
+
its label, only with the nonce's cookie; the session it buys is bound to the path; a 303 goes
|
|
1654
|
+
to the clean address.
|
|
1655
|
+
- A browser that keeps no cookie in a frame (WebKit, P025) arrives at the redeem without the
|
|
1656
|
+
nonce: the "open in a new tab" outcome (`blocked: cookies`). A path whose session still didn't
|
|
1657
|
+
come back after a redeem is caught on the next bounce (the nonce cookie marks it entered for
|
|
1658
|
+
60 s): the same outcome, no loop.
|
|
1659
|
+
- The redirect pages on the app origin are Porthole's (no app chrome), framable only by the app
|
|
1660
|
+
origins, their one script inline under its hash; the frame transport hears frame status, and
|
|
1661
|
+
nothing else, from the host's own origin.
|
|
1662
|
+
- Tickets stay: frames should use them (no extra round trip; no dependence on the app's cookie
|
|
1663
|
+
inside a frame). Clean addresses are for tabs, links, bookmarks and reloads: a host's "open in a
|
|
1664
|
+
new tab" and "copy link" use `urlOf`. Redirect entry needs a naming that says a label's scope
|
|
1665
|
+
(`prefixedName` does; keyed labels are one-way).
|
|
1666
|
+
|
|
1667
|
+
**Security.**
|
|
1668
|
+
- *Open redirects.* The authorize route redirects only to `/__porthole/entry` on the label in a
|
|
1669
|
+
state it verified; the redeem redirects only to its own origin plus the path from that state,
|
|
1670
|
+
as an absolute URL (`//evil.example/x` stays a path on the instance's origin). The app's
|
|
1671
|
+
`signIn` gets the authorize URL as `returnTo` and must come back only to its own paths.
|
|
1672
|
+
- *CSRF and state binding.* The state is signed (HMAC under the server's key, typed apart from
|
|
1673
|
+
session cookies) and expires in five minutes. The code is bound to the state's nonce, whose
|
|
1674
|
+
cookie lives only in the browser that began: a code sent to someone else's browser (login
|
|
1675
|
+
CSRF, session fixation) opens nothing. A code is bound to its label: taken to another
|
|
1676
|
+
instance's origin, it is refused and spent.
|
|
1677
|
+
- *The code.* It lives in an address for one hop (a redirect, so never in history), every
|
|
1678
|
+
redirect answer sends `Referrer-Policy: no-referrer` and `Cache-Control: no-store`, and
|
|
1679
|
+
Porthole never logs it (an edge's access log may: keep codes out of logs there, or log paths
|
|
1680
|
+
without queries).
|
|
1681
|
+
- *The new opening.* A request without a cookie used to be refused; it is now handed to the
|
|
1682
|
+
app, whose session decides. The app's sign-in and refusal never render in a frame. The host's
|
|
1683
|
+
own origin may now report a frame's status (only that: a protocol message from there is
|
|
1684
|
+
dropped, `test/transport.test.ts`).
|
|
1685
|
+
|
|
1686
|
+
**Measured** (the `served.entry.*` cases, on loopback and in the prod shape): a clean address
|
|
1687
|
+
opens directly signed in, signed out through the app's sign-in, and gives the app's refusal
|
|
1688
|
+
without access, on all three engines; a replayed code and a code taken to another instance's
|
|
1689
|
+
origin open nothing. In a frame, the app's `SameSite=Lax` cookie reaches its authorize route
|
|
1690
|
+
after the cross-site redirect from the content origin on Chromium (the frame shows the content),
|
|
1691
|
+
and not on Firefox or WebKit (the frame asks for a tab to sign in). That is why frames should use
|
|
1692
|
+
tickets.
|
|
1693
|
+
|
|
1694
|
+
**Rejected.**
|
|
1695
|
+
- A `?ticket=` address for tabs and links: not stable, not shareable, and spent once.
|
|
1696
|
+
- An entry page that checks the cookie with a script: the nonce cookie at the redeem tells the
|
|
1697
|
+
same thing on the server, before anything renders, with no script on either origin.
|
|
1698
|
+
- Binding the session to the query too: two queries on one path would take two cookies for one
|
|
1699
|
+
document.
|
|
1700
|
+
|
|
1701
|
+
**Revisit when** a host needs clean addresses for keyed labels (a lookup from label to scope),
|
|
1702
|
+
or a browser starts sending `Lax` cookies on framed redirects everywhere (then frames could use
|
|
1703
|
+
clean addresses too).
|
|
1704
|
+
|
|
1705
|
+
## P069 · 2026-10-12 · The manual says what the page host means: call the guide tool, fill the viewer, no theme lines when unthemed; 14.sound reports an inherited block · Accepted
|
|
1706
|
+
|
|
1707
|
+
**Decision.** From the first artifact-page host reading its generated manual (0.2.1):
|
|
1708
|
+
- With `guide.guideToolName`, the tool description says to *call* the guide tool ("Call
|
|
1709
|
+
read_page_guide once before your first page"): it is a tool, not a document.
|
|
1710
|
+
- With `layout.kind: "fill"`, the frame fills the app's viewer, which is often smaller than the
|
|
1711
|
+
window (toolbars, side panels): the manual says "fills the app's viewer", "as tall as the
|
|
1712
|
+
viewer, not the window", and to read the frame's own width; it keeps "design for the phone's
|
|
1713
|
+
width first". This supersedes P065's "as tall as the window" wording.
|
|
1714
|
+
- With `theme.documents: "never"`, the manual has no theme line but "always light: style it
|
|
1715
|
+
yourself" and the `prefers-color-scheme` caveat: the example's "cards that read in both
|
|
1716
|
+
themes" and "the app's light palette" go.
|
|
1717
|
+
- `14.sound` detects that the page's own CSP (which an opaque frame inherits) blocks its `data:`
|
|
1718
|
+
media (`media-src`) and reports itself inapplicable, as `1.connect`, `2.resources`,
|
|
1719
|
+
`12.no-confirm` and `14.workers` do (P058); `leak/inherited-policy.spec.ts` proves it on a
|
|
1720
|
+
closed page on all three engines.
|
|
1721
|
+
|
|
1722
|
+
**Why.** Each line was false or misleading for that host (a document to read, the window's size,
|
|
1723
|
+
both themes), and the host skipped `14.sound` by hand on a page whose `media-src 'self'` the
|
|
1724
|
+
frame inherits.
|
|
1725
|
+
**Revisit when** a host's viewer is the whole window (the wording is still true there).
|
|
1726
|
+
|
|
1727
|
+
## P070 · 2026-10-12 · The host kit, from its first two adopters: unknown addresses 404, pages that talk past a desktop scheme, streams that stop · Accepted
|
|
1728
|
+
|
|
1729
|
+
**Decision.** 0.2.2 fixes what Ferry and Raft Artifacts hit adopting the kit (#5, #6), so neither
|
|
1730
|
+
needs a workaround. Additions only; the API is otherwise the same.
|
|
1731
|
+
1. **An address that names nothing is a 404 on the content domain.** A label the naming never
|
|
1732
|
+
makes (`www`, `f`, garbage), the bare domain, and a clean address whose `entry.at` (asked on
|
|
1733
|
+
the content origin with no viewer) gives null or undefined get Porthole's 404 there. Only an
|
|
1734
|
+
instance that exists and lacks a cookie goes to the app's authorize route. `/server` takes it
|
|
1735
|
+
as `entry.named(label, path)`; a naming's `scopeOf` already refuses labels it never makes.
|
|
1736
|
+
2. **`reserved` is for labels the app itself serves** on the content domain (Ferry's `p-*`
|
|
1737
|
+
previews); a label nothing serves needs no entry (item 1).
|
|
1738
|
+
3. **The entry hooks get the address decoded** (`EntryContext`: label, scope, path, clean URL,
|
|
1739
|
+
instance, framed), and `refusal` may return nothing to use Porthole's page.
|
|
1740
|
+
4. **`/server` exports the types an app's callbacks use**: `InstanceRef`, `FrameLocation`,
|
|
1741
|
+
`ServedDocument`, `PortholeConfig`, `EntryContext`.
|
|
1742
|
+
5. **The runner:** `instances` and the entry addresses may be async (a page published on demand,
|
|
1743
|
+
its real instance then asked of the issue endpoint); `entry.unknown` adds
|
|
1744
|
+
`served.entry.unknown`; tests are located at the app's `defineHostConformance` call (compiled
|
|
1745
|
+
there with `vm`'s file name and line), so `playwright test <spec>` selects them.
|
|
1746
|
+
6. **The pages' frame status survives a desktop scheme among the host origins (#5).** Each web
|
|
1747
|
+
host origin is told in its own `try`; where the host origins hold another scheme
|
|
1748
|
+
(`ferry://app`, which no browser takes as a target origin) and the parent isn't known to be a
|
|
1749
|
+
web host origin (`location.ancestorOrigins`), the word goes to the parent as `*`. Before, one
|
|
1750
|
+
throwing `postMessage` stopped the entry page before its cookie check, so every framed
|
|
1751
|
+
url-mode app stayed on "Opening the app…".
|
|
1752
|
+
7. **`nodeHost` streams (#6):** the request carries a signal aborted when the client leaves
|
|
1753
|
+
(`proxyTo` passes it to the relay); the body is written as the socket drains and cancelled
|
|
1754
|
+
when the client leaves; an error mid-body destroys the response.
|
|
1755
|
+
8. **`health.onChange(view)`** is told each change of the gate's state.
|
|
1756
|
+
9. **The npm package carries the docs** (`docs/DESIGN.md`, `DECISIONS.md` and the rest).
|
|
1757
|
+
|
|
1758
|
+
**Risks.**
|
|
1759
|
+
- Item 1 tells anyone whether an address names something (404, or a trip to the app). Labels
|
|
1760
|
+
under `prefixedName` were guessable already; an app that must not say so can answer `at` for
|
|
1761
|
+
every well-formed address and let `access` refuse.
|
|
1762
|
+
- Item 6 sends the frame status to `*` in one case. The pages that send it are framable only by
|
|
1763
|
+
the host origins (`frame-ancestors`), so their parent is one, and what they say (entering,
|
|
1764
|
+
blocked and why) is no secret. Bounded by `test/pages.test.ts` and the kit's
|
|
1765
|
+
`served.url.framed`, which runs with a desktop scheme among the host origins on every engine.
|
|
1766
|
+
|
|
1767
|
+
**Why.** Before 0.2.0 these labels were 404s on the content domain, and production said so. The
|
|
1768
|
+
other items are each a workaround an adopter wrote: the content-origin state re-parsed in its
|
|
1769
|
+
hooks, labels left out of `reserved` (which looked like the way to keep them unanswered), http
|
|
1770
|
+
origins only so the entry page wouldn't hang, its own streaming relay before `nodeHost`, its
|
|
1771
|
+
own served flag in the `log` callback, a `-g` filter to find its tests.
|
|
1772
|
+
**Revisit when** an app needs an address that exists but must not be told apart from one that
|
|
1773
|
+
doesn't (then `at` grows a way to say "ask the app").
|
|
1774
|
+
|