@botiverse/porthole 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/conformance/entry.d.ts +8 -2
- package/dist/conformance/entry.js +23 -8
- package/dist/conformance/host-kit.d.ts +8 -3
- package/dist/conformance/host-kit.js +58 -23
- package/dist/conformance/served.d.ts +4 -4
- package/dist/conformance/served.js +26 -16
- package/dist/node/index.d.ts +11 -3
- package/dist/node/index.js +70 -10
- package/dist/server/host.d.ts +42 -8
- package/dist/server/host.js +35 -5
- package/dist/server/index.d.ts +3 -1
- package/dist/server/pages.js +15 -1
- package/dist/server/proxy.js +2 -0
- package/dist/server/server.js +9 -2
- package/dist/server/types.d.ts +15 -6
- package/docs/ARCHITECTURE.md +277 -0
- package/docs/DECISIONS.md +1774 -0
- package/docs/DESIGN.md +1067 -0
- package/docs/OBJECTIVES.md +116 -0
- package/docs/PRINCIPLES.md +155 -0
- package/package.json +4 -3
- package/src/conformance/entry.ts +39 -10
- package/src/conformance/host-kit.ts +71 -28
- package/src/conformance/served.ts +34 -21
- package/src/node/index.ts +73 -10
- package/src/server/host.ts +84 -14
- package/src/server/index.ts +4 -0
- package/src/server/pages.ts +15 -1
- package/src/server/proxy.ts +2 -0
- package/src/server/server.ts +11 -2
- package/src/server/types.ts +18 -7
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Objectives
|
|
2
|
+
|
|
3
|
+
## What Porthole is for
|
|
4
|
+
|
|
5
|
+
Any app that wants the "show content made by an agent" ability — like the inline
|
|
6
|
+
visuals and standalone pages in ChatGPT and the Claude app — should be able to
|
|
7
|
+
embed Porthole and get a working, safe display out of the box, plus the manual
|
|
8
|
+
that teaches the agent to write for it.
|
|
9
|
+
|
|
10
|
+
The library has two layers:
|
|
11
|
+
|
|
12
|
+
- a **framework layer** of mechanisms (isolation, egress hardening, the in-frame
|
|
13
|
+
shell, streaming, sizing, theming, the host-function bridge, the guide
|
|
14
|
+
generator); and
|
|
15
|
+
- a **preset policy layer** of ready-made bundles a host adopts with near-zero
|
|
16
|
+
config: **inline widget**, **artifact** (a standalone page), **MCP Apps** (the
|
|
17
|
+
standard protocol), and **URL** (a running app framed by URL, such as a dev
|
|
18
|
+
server's preview).
|
|
19
|
+
|
|
20
|
+
The subject, stated generally, is **HTML drawn by an agent that may optionally
|
|
21
|
+
interact with the agent** — and that interaction is just one host function among
|
|
22
|
+
several. "Out of the box" means: a host picks a preset, passes theme tokens and
|
|
23
|
+
callbacks, and receives a safe display plus a generated agent manual.
|
|
24
|
+
|
|
25
|
+
## Objectives and success criteria
|
|
26
|
+
|
|
27
|
+
1. **Show untrusted agent HTML without putting the host at risk.**
|
|
28
|
+
- *Success:* the conformance/leak suite passes on Chromium, WebKit and
|
|
29
|
+
Firefox — every egress and escape probe attempts its channel and observes
|
|
30
|
+
nothing; the content cannot reach the host origin, its storage, its DOM, or
|
|
31
|
+
its credentials.
|
|
32
|
+
|
|
33
|
+
2. **One config generates the sandbox, the shell, and the manual, in agreement.**
|
|
34
|
+
- *Success:* for every preset, a test derives the CSP, the shell's wired host
|
|
35
|
+
functions, and the generated guide from the same config and asserts they
|
|
36
|
+
match — the guide names only variables the shell injects and only
|
|
37
|
+
capabilities the CSP and bridge allow; changing the config changes all
|
|
38
|
+
three together.
|
|
39
|
+
|
|
40
|
+
3. **Speak the MCP Apps standard, proven against the reference implementation.**
|
|
41
|
+
- *Success:* the MCP Apps preset renders and interacts with the official
|
|
42
|
+
ext-apps example servers **unmodified** (initialize handshake, tool input
|
|
43
|
+
and result, size and theme changes, open-link and message), driven in the
|
|
44
|
+
leak suite.
|
|
45
|
+
|
|
46
|
+
4. **Embed with near-zero config across the modes.**
|
|
47
|
+
- *Success:* a host mounts an inline widget, an artifact page, an MCP Apps
|
|
48
|
+
view and a URL frame each in a few lines using only a preset, theme tokens
|
|
49
|
+
and callbacks; the framework-agnostic core and the React binding both do
|
|
50
|
+
this; no host-specific code lives in the core.
|
|
51
|
+
|
|
52
|
+
5. **Ship the agent manual as a first-class output.**
|
|
53
|
+
- *Success:* each preset produces an agent-facing guide and tool description
|
|
54
|
+
from its config; a real agent that reads the guide produces content that
|
|
55
|
+
renders within the sandbox and reads correctly at the target widths in light
|
|
56
|
+
and dark, checked by rendering the guide's own examples in the leak suite.
|
|
57
|
+
|
|
58
|
+
## Non-goals
|
|
59
|
+
|
|
60
|
+
- **Host-drawn DSL rendering.** Rendering a JSON/JSX/remote-DOM UI description
|
|
61
|
+
with the host's own native components (A2UI, json-render, remote-dom, JSX
|
|
62
|
+
preview) is a separate concern and a different project. Porthole is the HTML
|
|
63
|
+
sandbox only.
|
|
64
|
+
- **A component vocabulary inside the sandbox.** Content is plain HTML and SVG,
|
|
65
|
+
taught by the generated manual; a consistent look comes from the injected theme
|
|
66
|
+
variables and, at most, plain base CSS for native elements, never from tags and
|
|
67
|
+
props an agent must learn (P015). Nor is Porthole a design-system library.
|
|
68
|
+
- **A new widget-side protocol.** The contract is MCP Apps; we do not define an
|
|
69
|
+
alternative (PRINCIPLE 2).
|
|
70
|
+
- **A shell or egress control for URL mode.** A URL frame is someone else's
|
|
71
|
+
running app: Porthole gives it isolation and access auth, nothing more (P020).
|
|
72
|
+
- **Operating infrastructure.** The serving part (`/server`) is a request handler a
|
|
73
|
+
host mounts in its own backend; Porthole runs no service, domain or certificate
|
|
74
|
+
of its own.
|
|
75
|
+
- **Being an agent framework or an MCP server.** Porthole is host-side rendering
|
|
76
|
+
and isolation. It does not run models, implement an MCP server, or decide
|
|
77
|
+
product policy (PRINCIPLE 8).
|
|
78
|
+
|
|
79
|
+
## Who the users are
|
|
80
|
+
|
|
81
|
+
- **Host app developers** who want to show agent-made HTML in a chat or canvas UI
|
|
82
|
+
and do not want to build or audit an isolation layer themselves. They are the
|
|
83
|
+
primary users: they embed the library and pick a preset.
|
|
84
|
+
- **Agents (and the people prompting them)** who write the HTML. They are served
|
|
85
|
+
indirectly: the generated manual is written for them, and the sandbox is the
|
|
86
|
+
environment their code runs in.
|
|
87
|
+
- **MCP App authors** who build standard MCP Apps and want them to render in any
|
|
88
|
+
Porthole-based host unmodified.
|
|
89
|
+
|
|
90
|
+
The first adopter is a normal user of the public API, with no privileged path
|
|
91
|
+
(PRINCIPLE 9).
|
|
92
|
+
|
|
93
|
+
## What "done" means for phase 1
|
|
94
|
+
|
|
95
|
+
Phase 1 is done when:
|
|
96
|
+
|
|
97
|
+
- the framework-agnostic web core and the React binding (with its default,
|
|
98
|
+
replaceable confirmation dialog) exist, with opaque-origin isolation, egress
|
|
99
|
+
hardening, the shell runtime, streaming render, auto-sizing, theming, and the
|
|
100
|
+
host-function bridge;
|
|
101
|
+
- the served modes exist: domain mode (a stable origin per storage scope, CSP in
|
|
102
|
+
headers, ticket → cookie for private content) with its `/server` request
|
|
103
|
+
handler, the artifact page on it, and URL mode (frame, isolation and access
|
|
104
|
+
auth only);
|
|
105
|
+
- the presets exist (inline widget, artifact page, MCP Apps view, URL), each
|
|
106
|
+
generating its CSP and frame policy, and all but URL its shell and manual, from
|
|
107
|
+
one config;
|
|
108
|
+
- the MCP Apps adapter runs the official ext-apps examples unmodified;
|
|
109
|
+
- the leak/conformance suite passes on Chromium and WebKit, then Firefox, and is
|
|
110
|
+
runnable by any host; and
|
|
111
|
+
- the first host has migrated onto it (dogfooding), every one of its features
|
|
112
|
+
that shows agent-made or foreign HTML (widgets, shown HTML files, the dev-server
|
|
113
|
+
preview) going through Porthole, with its own code reduced to configuration
|
|
114
|
+
plus callbacks.
|
|
115
|
+
|
|
116
|
+
Native hosts (WKWebView, maybe Android) are phase 2.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Principles
|
|
2
|
+
|
|
3
|
+
These are the rules we use to make decisions quickly. When a choice is unclear,
|
|
4
|
+
pick the option that best satisfies these, write it down in
|
|
5
|
+
[DECISIONS.md](DECISIONS.md), and move on. Principles change only through a
|
|
6
|
+
decision entry.
|
|
7
|
+
|
|
8
|
+
Each principle gives its reason and what it rules out.
|
|
9
|
+
|
|
10
|
+
## 1. Security defaults cannot be weakened silently
|
|
11
|
+
|
|
12
|
+
Isolation is on, egress is closed, host functions are off until declared, and
|
|
13
|
+
their gates are on — by default. Any change that loosens a default (widens an
|
|
14
|
+
allowlist, drops a sandbox flag, relaxes the CSP, removes a gate) happens only
|
|
15
|
+
through a decision entry that names the threat it re-opens and ships the test
|
|
16
|
+
that bounds the new risk.
|
|
17
|
+
|
|
18
|
+
- **Why.** The whole point of the library is that a host can hand it untrusted
|
|
19
|
+
HTML without reasoning about browser internals. A quiet loosening moves risk
|
|
20
|
+
onto hosts who never agreed to it.
|
|
21
|
+
- **Rules out.** Convenience flags that trade safety for a smaller config;
|
|
22
|
+
"temporary" allowlist entries without an owner; a default that is safe only if
|
|
23
|
+
the host remembers to also set something.
|
|
24
|
+
|
|
25
|
+
## 2. The external contract is the standard; we do not invent a widget protocol
|
|
26
|
+
|
|
27
|
+
What the agent-written HTML speaks to its host is **MCP Apps**
|
|
28
|
+
(`io.modelcontextprotocol/ui`). Gaps are filled through the spec's own extension
|
|
29
|
+
points (experimental capabilities, own-prefixed methods), and anything general is
|
|
30
|
+
taken upstream to the spec, not kept as a private dialect.
|
|
31
|
+
|
|
32
|
+
- **Why.** The value of standard agent-UI is that one widget runs in many hosts
|
|
33
|
+
and one host renders many widgets. A bespoke protocol forfeits that and makes
|
|
34
|
+
every author integrate with us specifically.
|
|
35
|
+
- **Rules out.** A new wire format for widgets; forking the protocol to ship a
|
|
36
|
+
feature faster; host-specific globals the widget must detect.
|
|
37
|
+
|
|
38
|
+
## 3. One config is the single source for the sandbox, the shell, and the manual
|
|
39
|
+
|
|
40
|
+
From one config Porthole derives the CSP, the iframe sandbox and `allow`
|
|
41
|
+
attributes, the in-frame shell, the host-function bridge, **and** the
|
|
42
|
+
agent-facing guide and tool description. They are generated together, never
|
|
43
|
+
edited apart.
|
|
44
|
+
|
|
45
|
+
- **Why.** These four artifacts must agree to be safe and usable: a manual that
|
|
46
|
+
promises a capability the CSP blocks wastes the agent's turns; a CSP that
|
|
47
|
+
allows more than the manual teaches is unaudited surface. Drift between them is
|
|
48
|
+
the most common defect in hand-built sandboxes.
|
|
49
|
+
- **Rules out.** A guide maintained by hand; a CSP string literal that no test
|
|
50
|
+
ties back to the capability list; teaching a variable or function the shell
|
|
51
|
+
does not inject.
|
|
52
|
+
|
|
53
|
+
## 4. Everything the HTML can do beyond drawing is a declared host function
|
|
54
|
+
|
|
55
|
+
Drawing pixels is free; every other power — send a prompt to the agent, open a
|
|
56
|
+
link, read or write state, call a tool, read data, report an error — is a
|
|
57
|
+
**host function (capability)** named in the config. Each declares its gate (needs
|
|
58
|
+
a user gesture, needs a person's confirmation, is rate-limited), its egress
|
|
59
|
+
needs, and its guide entry. Talking to the agent is just one host function among
|
|
60
|
+
these.
|
|
61
|
+
|
|
62
|
+
- **Why.** This is the core abstraction: "agent-drawn HTML + host functions." It
|
|
63
|
+
gives one list that drives the bridge, the CSP and the manual at once (see
|
|
64
|
+
PRINCIPLE 3), and it makes the question "what can this content do?" answerable
|
|
65
|
+
by reading the config.
|
|
66
|
+
- **Rules out.** Ambient powers the HTML gets without the host opting in; a
|
|
67
|
+
capability wired into the bridge but absent from the CSP or the manual; a
|
|
68
|
+
gate decided ad hoc per call site instead of declared once.
|
|
69
|
+
|
|
70
|
+
## 5. Every hardening item ships with a test that proves it
|
|
71
|
+
|
|
72
|
+
A claim that the sandbox blocks something is backed by a test in the conformance
|
|
73
|
+
suite that attempts it and observes nothing. A hardening item without a passing
|
|
74
|
+
test does not exist.
|
|
75
|
+
|
|
76
|
+
- **Why.** Browser behavior changes across versions and engines, and egress
|
|
77
|
+
paths are easy to miss by inspection. Only an executed probe shows the wall is
|
|
78
|
+
really there, and keeps it there as browsers move.
|
|
79
|
+
- **Rules out.** "We set the CSP, so it's blocked" without a probe; hardening
|
|
80
|
+
described in a doc but not exercised; a fix landed without first writing the
|
|
81
|
+
test that failed.
|
|
82
|
+
|
|
83
|
+
## 6. Measured over assumed
|
|
84
|
+
|
|
85
|
+
Browser isolation facts are decided by running the probe on the engines we
|
|
86
|
+
support, not by reading a spec or assuming parity across engines. Where engines
|
|
87
|
+
differ, we harden for the weakest and record the measurement.
|
|
88
|
+
|
|
89
|
+
- **Why.** CSP directives are honored unevenly, and sandbox and frame rules have
|
|
90
|
+
engine-specific gaps. An assumption that is wrong in one engine is a hole in
|
|
91
|
+
production.
|
|
92
|
+
- **Rules out.** Relying on a single directive that one engine ignores; trusting
|
|
93
|
+
that two engines behave the same; shipping a hardening item we have not seen
|
|
94
|
+
pass and fail.
|
|
95
|
+
|
|
96
|
+
## 7. Host-agnostic outside, protocol-pluggable inside
|
|
97
|
+
|
|
98
|
+
The core knows nothing about any particular host app or vendor. It exposes
|
|
99
|
+
mechanisms and takes a config; the host supplies theme tokens, callbacks and
|
|
100
|
+
policy. Inside, the protocol an adapter speaks is swappable behind a thin seam;
|
|
101
|
+
MCP Apps is the first and default adapter.
|
|
102
|
+
|
|
103
|
+
- **Why.** The library is meant to be embedded by many apps. Baking one app's
|
|
104
|
+
assumptions, or one protocol's object model, into the core makes it that app's
|
|
105
|
+
library, not everyone's.
|
|
106
|
+
- **Rules out.** Importing a host's types into the core; a core that only works
|
|
107
|
+
with one framework; tying the core to one protocol SDK's class hierarchy.
|
|
108
|
+
|
|
109
|
+
## 8. The host owns product policy; the surface stays small
|
|
110
|
+
|
|
111
|
+
Porthole decides mechanism (how isolation and gating work); the host decides
|
|
112
|
+
policy (whether to confirm an action, what the confirmation says, which origins
|
|
113
|
+
are allowed, what the theme is). We prefer deleting options to adding them, and
|
|
114
|
+
presets over knobs.
|
|
115
|
+
|
|
116
|
+
Authentication and authorization *policy* are the host's; carrying the host's
|
|
117
|
+
decision safely onto the content domain is Porthole's *mechanism*. Porthole never
|
|
118
|
+
knows who someone is or what they may see: the host decides before it issues a
|
|
119
|
+
ticket and on every request after (`authorize`), and Porthole carries that decision
|
|
120
|
+
in tickets and cookies, opaque to it (P046, P050).
|
|
121
|
+
|
|
122
|
+
- **Why.** Policy is product-specific and changes faster than mechanism. A small,
|
|
123
|
+
opinionated surface with safe presets is easier to audit and harder to misuse
|
|
124
|
+
than a large configurable one.
|
|
125
|
+
- **Rules out.** Drawing our own confirmation UI the host can't control; config
|
|
126
|
+
options that exist only to avoid a decision; a second way to do something the
|
|
127
|
+
presets already cover; users, roles, share lists or revocation lists inside
|
|
128
|
+
Porthole.
|
|
129
|
+
|
|
130
|
+
## 9. No special first user
|
|
131
|
+
|
|
132
|
+
Porthole is built as a general library. Its first adopter is a normal consumer of
|
|
133
|
+
the public API, not a privileged case with private hooks. If the first user needs
|
|
134
|
+
something, it goes into the config or an extension point that any host could use.
|
|
135
|
+
|
|
136
|
+
- **Why.** A library shaped around one caller's internals is not reusable, and
|
|
137
|
+
the shortcuts leak that caller's assumptions into the core (against PRINCIPLE
|
|
138
|
+
7).
|
|
139
|
+
- **Rules out.** An escape hatch only the first host uses; branching on who the
|
|
140
|
+
caller is; features reachable only through internal, undocumented API.
|
|
141
|
+
|
|
142
|
+
## 10. The agent manual is a first-class output, from day one
|
|
143
|
+
|
|
144
|
+
The shell is designed together with the manual that teaches an agent to write for
|
|
145
|
+
it. Guide generation is part of the framework layer, not an afterthought: every
|
|
146
|
+
preset ships an agent-facing manual and a tool description generated from the
|
|
147
|
+
same config as the sandbox and the shell.
|
|
148
|
+
|
|
149
|
+
- **Why.** Untrusted HTML is only as good as what the author was told — the sizes
|
|
150
|
+
that read on each screen, the theme variables that exist, what the sandbox
|
|
151
|
+
forbids. A shell without a matching manual produces content that is almost
|
|
152
|
+
right, and the manual must track the shell automatically to stay true.
|
|
153
|
+
- **Rules out.** Shipping a shell and leaving the manual to each host; a manual
|
|
154
|
+
that lists variables or limits the shell does not actually enforce; prose
|
|
155
|
+
guidance that no test checks against real renders.
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botiverse/porthole",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "A host-side sandbox for agent-made HTML: isolation, egress hardening, a themed shell, gated host functions, and the agent manual, from one config.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"files": [
|
|
7
7
|
"src",
|
|
8
|
-
"dist"
|
|
8
|
+
"dist",
|
|
9
|
+
"docs"
|
|
9
10
|
],
|
|
10
11
|
"type": "module",
|
|
11
12
|
"sideEffects": false,
|
|
@@ -62,7 +63,7 @@
|
|
|
62
63
|
"node": ">=24"
|
|
63
64
|
},
|
|
64
65
|
"scripts": {
|
|
65
|
-
"build": "tsc -p tsconfig.build.json && node scripts/build-harness.ts",
|
|
66
|
+
"build": "tsc -p tsconfig.build.json && node scripts/build-harness.ts && node scripts/copy-docs.ts",
|
|
66
67
|
"test:leak": "playwright test -c leak/playwright.config.ts"
|
|
67
68
|
}
|
|
68
69
|
}
|
package/src/conformance/entry.ts
CHANGED
|
@@ -17,9 +17,15 @@ export interface EntryConformanceHost {
|
|
|
17
17
|
* The clean address of a stored page the signed-in viewer may open (`ContentHost.urlOf`), for
|
|
18
18
|
* `scope`: it draws `<main id="artifact">version 1</main>`.
|
|
19
19
|
*/
|
|
20
|
-
url(scope: string): string
|
|
20
|
+
url(scope: string): string | Promise<string>;
|
|
21
21
|
/** A clean address the signed-in viewer may not open. */
|
|
22
|
-
denied(scope: string): string
|
|
22
|
+
denied(scope: string): string | Promise<string>;
|
|
23
|
+
/**
|
|
24
|
+
* Addresses on the content domain that name nothing (a label the app never assigns, the bare
|
|
25
|
+
* domain, a path no instance has): each must be Porthole's 404 there, never the app's sign-in
|
|
26
|
+
* (P070). Absent: the case is skipped.
|
|
27
|
+
*/
|
|
28
|
+
unknown?(): readonly string[] | Promise<readonly string[]>;
|
|
23
29
|
/** Whether `page` shows the app's own sign-in. */
|
|
24
30
|
atSignIn(page: Page): Promise<boolean>;
|
|
25
31
|
/** On the app's sign-in page: signs in, as a person does (and the app goes back). */
|
|
@@ -86,7 +92,7 @@ export function entryTests(
|
|
|
86
92
|
"a clean address opens directly for a signed-in viewer, and stays clean; a reload rides on its cookie",
|
|
87
93
|
async ({ page }) => {
|
|
88
94
|
await host.signIn(page);
|
|
89
|
-
const url = host.url("entry-direct");
|
|
95
|
+
const url = await host.url("entry-direct");
|
|
90
96
|
await page.goto(url);
|
|
91
97
|
await expect(page.locator("#artifact")).toHaveText("version 1");
|
|
92
98
|
expect(page.url(), "the address bar shows the clean address, no code").toBe(url);
|
|
@@ -106,7 +112,7 @@ export function entryTests(
|
|
|
106
112
|
"served.entry.signed-out",
|
|
107
113
|
"signed out, a clean address goes to the app's own sign-in, then back to the content",
|
|
108
114
|
async ({ page }) => {
|
|
109
|
-
const url = host.url("entry-signed-out");
|
|
115
|
+
const url = await host.url("entry-signed-out");
|
|
110
116
|
await page.goto(url);
|
|
111
117
|
expect(await host.atSignIn(page), "the app's sign-in").toBe(true);
|
|
112
118
|
expect(await shows(page)).toBe(false);
|
|
@@ -121,7 +127,7 @@ export function entryTests(
|
|
|
121
127
|
"a viewer without access gets the app's refusal, never the content",
|
|
122
128
|
async ({ page }) => {
|
|
123
129
|
await host.signIn(page);
|
|
124
|
-
const url = host.denied("entry-denied");
|
|
130
|
+
const url = await host.denied("entry-denied");
|
|
125
131
|
const answer = await page.goto(url);
|
|
126
132
|
expect(answer?.status()).toBe(403);
|
|
127
133
|
expect(await shows(page)).toBe(false);
|
|
@@ -129,12 +135,35 @@ export function entryTests(
|
|
|
129
135
|
},
|
|
130
136
|
);
|
|
131
137
|
|
|
138
|
+
define(
|
|
139
|
+
"served.entry.unknown",
|
|
140
|
+
"an address that names nothing is a 404 on the content domain: never a trip to the app's sign-in",
|
|
141
|
+
async ({ page }, info) => {
|
|
142
|
+
info.skip(
|
|
143
|
+
host.unknown === undefined,
|
|
144
|
+
"the host gave no addresses that name nothing (unknown)",
|
|
145
|
+
);
|
|
146
|
+
const request = page.context().request;
|
|
147
|
+
for (const url of (await host.unknown?.()) ?? []) {
|
|
148
|
+
const answer = await request.get(url, {
|
|
149
|
+
maxRedirects: 0,
|
|
150
|
+
headers: { "sec-fetch-dest": "document" },
|
|
151
|
+
});
|
|
152
|
+
expect(answer.status(), url).toBe(404);
|
|
153
|
+
expect(answer.headers().location, url).toBeUndefined();
|
|
154
|
+
const opened = await page.goto(url);
|
|
155
|
+
expect(opened?.status(), url).toBe(404);
|
|
156
|
+
expect(new URL(page.url()).origin, url).toBe(new URL(url).origin);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
|
|
132
161
|
define(
|
|
133
162
|
"served.entry.code-once",
|
|
134
163
|
"an entry code works once: replayed, it opens nothing",
|
|
135
164
|
async ({ page }) => {
|
|
136
165
|
await host.signIn(page);
|
|
137
|
-
const code = await codeFor(page, host.url("entry-once"));
|
|
166
|
+
const code = await codeFor(page, await host.url("entry-once"));
|
|
138
167
|
const request = page.context().request;
|
|
139
168
|
const top = { "sec-fetch-dest": "document" };
|
|
140
169
|
const first = await request.get(code, { maxRedirects: 0, headers: top });
|
|
@@ -150,8 +179,8 @@ export function entryTests(
|
|
|
150
179
|
"an entry code taken to another instance's origin opens nothing there",
|
|
151
180
|
async ({ page }) => {
|
|
152
181
|
await host.signIn(page);
|
|
153
|
-
const code = new URL(await codeFor(page, host.url("entry-mine")));
|
|
154
|
-
const other = new URL(host.url("entry-theirs"));
|
|
182
|
+
const code = new URL(await codeFor(page, await host.url("entry-mine")));
|
|
183
|
+
const other = new URL(await host.url("entry-theirs"));
|
|
155
184
|
const request = page.context().request;
|
|
156
185
|
const answer = await request.get(`${other.origin}${code.pathname}${code.search}`, {
|
|
157
186
|
maxRedirects: 0,
|
|
@@ -167,7 +196,7 @@ export function entryTests(
|
|
|
167
196
|
"a clean address in a frame shows the content where the browser keeps the cookies, else asks for a tab: never the app's sign-in",
|
|
168
197
|
async ({ page, browserName }, info) => {
|
|
169
198
|
await host.signIn(page);
|
|
170
|
-
const shown = await host.frame(page, host.url("entry-framed"));
|
|
199
|
+
const shown = await host.frame(page, await host.url("entry-framed"));
|
|
171
200
|
const outcome = await shown.outcome();
|
|
172
201
|
const frame = await shown.frame();
|
|
173
202
|
expect(await host.atSignIn(page)).toBe(false);
|
|
@@ -194,7 +223,7 @@ export function entryTests(
|
|
|
194
223
|
await host.signIn(page);
|
|
195
224
|
// The app's page stays; the browser forgets every sign-in.
|
|
196
225
|
await page.context().clearCookies();
|
|
197
|
-
const shown = await host.frame(page, host.url("entry-framed-out"));
|
|
226
|
+
const shown = await host.frame(page, await host.url("entry-framed-out"));
|
|
198
227
|
expect(await shown.outcome()).toMatchObject({ state: "blocked", reason: "sign-in" });
|
|
199
228
|
const frame = await shown.frame();
|
|
200
229
|
await expect(frame.locator("h1")).toHaveText("Open this in a new tab to sign in");
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import {
|
|
3
|
+
import { runInThisContext } from "node:vm";
|
|
4
|
+
import { expect, type Frame, type Page, type TestInfo, type TestType } from "@playwright/test";
|
|
4
5
|
import type { PortholeConfig } from "../config/schema.ts";
|
|
5
6
|
import type { FrameLocation, InstanceRef } from "../isolation/types.ts";
|
|
6
7
|
import { entryTests, type EntryConformanceHost } from "./entry.ts";
|
|
7
8
|
import type { ConformanceHarness } from "./harness/page.ts";
|
|
8
9
|
import type { FrameOutcome } from "../host/types.ts";
|
|
9
10
|
import type { ConformanceHost, LaunchedDisplay, LaunchSpec } from "./index.ts";
|
|
10
|
-
import { leakTests, type ConformanceFixtures } from "./probes.ts";
|
|
11
|
+
import { leakTests, type ConformanceFixtures, type ConformanceTest } from "./probes.ts";
|
|
11
12
|
import { servedTests, type ServedConformanceHost, type ServedDisplay } from "./served.ts";
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -55,14 +56,19 @@ export interface HostConformanceOptions {
|
|
|
55
56
|
* signed in and out, and in a frame.
|
|
56
57
|
*/
|
|
57
58
|
readonly entry?: {
|
|
58
|
-
/**
|
|
59
|
-
|
|
59
|
+
/**
|
|
60
|
+
* The clean address of `instances.artifact(scope, "1")` (`ContentHost.urlOf`); may publish it
|
|
61
|
+
* on demand.
|
|
62
|
+
*/
|
|
63
|
+
readonly url: (scope: string) => string | Promise<string>;
|
|
60
64
|
/** A clean address the signed-in viewer may not open. */
|
|
61
|
-
readonly denied: (scope: string) => string
|
|
65
|
+
readonly denied: (scope: string) => string | Promise<string>;
|
|
62
66
|
/** Whether `page` shows the app's own sign-in. */
|
|
63
67
|
readonly atSignIn: (page: Page) => Promise<boolean>;
|
|
64
68
|
/** On the app's sign-in page: signs in, as a person does. */
|
|
65
69
|
readonly completeSignIn: (page: Page) => Promise<void>;
|
|
70
|
+
/** Addresses on the content domain that name nothing: each a 404 there (P070). */
|
|
71
|
+
readonly unknown?: () => readonly string[] | Promise<readonly string[]>;
|
|
66
72
|
};
|
|
67
73
|
/** Case ids to skip, each with its reason (shown in the report). */
|
|
68
74
|
readonly skip?: Readonly<Record<string, string>>;
|
|
@@ -338,9 +344,10 @@ export function hostConformance(options: HostConformanceOptions): HostConformanc
|
|
|
338
344
|
denied: given.denied,
|
|
339
345
|
atSignIn: given.atSignIn,
|
|
340
346
|
completeSignIn: given.completeSignIn,
|
|
347
|
+
...(given.unknown === undefined ? {} : { unknown: given.unknown }),
|
|
341
348
|
async frame(page, url) {
|
|
342
349
|
await ready(page);
|
|
343
|
-
const instance = served.artifact("entry", "1");
|
|
350
|
+
const instance = await served.artifact("entry", "1");
|
|
344
351
|
const id = await invoke(page, "show", options.config(instance), instance, {
|
|
345
352
|
location: { url, origin: new URL(url).origin },
|
|
346
353
|
});
|
|
@@ -435,32 +442,68 @@ export function defineHostConformance(
|
|
|
435
442
|
if (options.instances.proxy === undefined) {
|
|
436
443
|
absent(PROXY_CASES, "the app serves no MCP Apps views");
|
|
437
444
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
typed(each.title, async ({ page, browserName }, info) => {
|
|
450
|
-
await hosts.begin(page);
|
|
451
|
-
await each.run({ page, browserName }, info);
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
});
|
|
455
|
-
const entry = hosts.entry;
|
|
456
|
-
if (entry !== null) {
|
|
457
|
-
typed.describe(`Porthole redirect entry (${options.name})`, () => {
|
|
458
|
-
for (const each of entryTests(entry, { skip })) {
|
|
459
|
-
typed(each.title, async ({ page, browserName }, info) => {
|
|
445
|
+
// Registered as the app's own spec file's (P070): Playwright locates a test where `test()` is
|
|
446
|
+
// called, so `playwright test <spec>` and `<spec>:<line>` select these.
|
|
447
|
+
const at = callerOf(defineHostConformance);
|
|
448
|
+
const describeOf = Reflect.get(test, "describe") as unknown as Register;
|
|
449
|
+
const describe = calledFrom(describeOf, at);
|
|
450
|
+
const register = calledFrom(typed as unknown as Register, at);
|
|
451
|
+
const suite = (title: string, tests: readonly ConformanceTest[]) => {
|
|
452
|
+
describe(title, () => {
|
|
453
|
+
for (const each of tests) {
|
|
454
|
+
// Playwright reads the fixtures a test uses from its parameter's destructuring.
|
|
455
|
+
register(each.title, async ({ page, browserName }: ConformanceFixtures, info: TestInfo) => {
|
|
460
456
|
await hosts.begin(page);
|
|
461
457
|
await each.run({ page, browserName }, info);
|
|
462
458
|
});
|
|
463
459
|
}
|
|
464
460
|
});
|
|
461
|
+
};
|
|
462
|
+
suite(`Porthole leak suite (${options.name})`, leakTests(hosts.leak, { skip }));
|
|
463
|
+
suite(`Porthole served modes (${options.name})`, servedTests(hosts.served, { skip }));
|
|
464
|
+
if (hosts.entry !== null) {
|
|
465
|
+
suite(`Porthole redirect entry (${options.name})`, entryTests(hosts.entry, { skip }));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Where a function was called from: the caller's file and line, or null. */
|
|
470
|
+
function callerOf(callee: (...args: never[]) => unknown): { file: string; line: number } | null {
|
|
471
|
+
const holder: { stack?: string } = {};
|
|
472
|
+
Error.captureStackTrace(holder, callee);
|
|
473
|
+
// The first frame is the caller's: " at file:///…/app.spec.ts:16:1" or " at fn (/…/app.spec.ts:16:1)".
|
|
474
|
+
const frame = (holder.stack ?? "")
|
|
475
|
+
.split("\n")
|
|
476
|
+
.map((line) => line.trim())
|
|
477
|
+
.find((line) => line.startsWith("at "));
|
|
478
|
+
const where = (frame ?? "").slice(3).replace(/^.*\((.*)\)$/u, "$1");
|
|
479
|
+
const found = /^(.+):(\d+):\d+$/u.exec(where);
|
|
480
|
+
if (found?.[1] === undefined || found[2] === undefined) {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
return {
|
|
485
|
+
file: found[1].startsWith("file://") ? fileURLToPath(found[1]) : found[1],
|
|
486
|
+
line: Number(found[2]),
|
|
487
|
+
};
|
|
488
|
+
} catch {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
type Register = (title: string, body: unknown) => void;
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* `register` (Playwright's `test` or `test.describe`), called from a function compiled as the
|
|
497
|
+
* caller's file and line (`vm`'s `filename` and `lineOffset`), so the tests it makes are located
|
|
498
|
+
* there. Without a caller, `register` itself.
|
|
499
|
+
*/
|
|
500
|
+
function calledFrom(register: Register, at: { file: string; line: number } | null): Register {
|
|
501
|
+
if (at === null) {
|
|
502
|
+
return (title, body) => register(title, body);
|
|
465
503
|
}
|
|
504
|
+
const call = runInThisContext("(register, title, body) => register(title, body)", {
|
|
505
|
+
filename: at.file,
|
|
506
|
+
lineOffset: Math.max(0, at.line - 1),
|
|
507
|
+
}) as (register: Register, title: string, body: unknown) => void;
|
|
508
|
+
return (title, body) => call(register, title, body);
|
|
466
509
|
}
|