@botiverse/porthole 0.1.7 → 0.2.0
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/clipboard.js +5 -2
- package/dist/conformance/entry.d.ts +37 -0
- package/dist/conformance/entry.js +120 -0
- package/dist/conformance/harness/page.d.ts +32 -0
- package/dist/conformance/harness/page.js +215 -0
- package/dist/conformance/harness.js +1662 -0
- package/dist/conformance/host-kit.d.ts +85 -0
- package/dist/conformance/host-kit.js +341 -0
- package/dist/conformance/index.d.ts +2 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/limits.js +16 -1
- package/dist/conformance/served.js +13 -3
- package/dist/guide/limits.js +9 -1
- package/dist/host/locator.d.ts +39 -0
- package/dist/host/locator.js +72 -0
- package/dist/host/outcome.js +1 -0
- package/dist/host/types.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node/index.d.ts +41 -0
- package/dist/node/index.js +160 -0
- package/dist/platform/web.js +12 -1
- package/dist/server/domain.d.ts +43 -0
- package/dist/server/domain.js +100 -0
- package/dist/server/host.d.ts +158 -0
- package/dist/server/host.js +416 -0
- package/dist/server/index.d.ts +9 -2
- package/dist/server/index.js +7 -0
- package/dist/server/naming.d.ts +12 -1
- package/dist/server/naming.js +9 -8
- package/dist/server/pages.d.ts +9 -0
- package/dist/server/pages.js +32 -0
- package/dist/server/server.js +287 -50
- package/dist/server/types.d.ts +37 -0
- package/dist/transport/post-message.d.ts +9 -1
- package/dist/transport/post-message.js +11 -2
- package/package.json +6 -2
- package/src/conformance/clipboard.ts +5 -2
- package/src/conformance/entry.ts +206 -0
- package/src/conformance/harness/page.ts +267 -0
- package/src/conformance/host-kit.ts +466 -0
- package/src/conformance/index.ts +12 -0
- package/src/conformance/limits.ts +21 -1
- package/src/conformance/served.ts +14 -3
- package/src/guide/limits.ts +9 -1
- package/src/host/locator.ts +111 -0
- package/src/host/outcome.ts +1 -0
- package/src/host/types.ts +2 -0
- package/src/index.ts +7 -0
- package/src/node/index.ts +221 -0
- package/src/platform/web.ts +19 -1
- package/src/server/domain.ts +158 -0
- package/src/server/host.ts +706 -0
- package/src/server/index.ts +27 -1
- package/src/server/naming.ts +22 -2
- package/src/server/pages.ts +44 -0
- package/src/server/server.ts +355 -63
- package/src/server/types.ts +33 -0
- package/src/transport/post-message.ts +12 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type Page, type TestType } from "@playwright/test";
|
|
2
|
+
import type { PortholeConfig } from "../config/schema.ts";
|
|
3
|
+
import type { FrameLocation, InstanceRef } from "../isolation/types.ts";
|
|
4
|
+
import { type EntryConformanceHost } from "./entry.ts";
|
|
5
|
+
import type { ConformanceHost } from "./index.ts";
|
|
6
|
+
import { type ServedConformanceHost } from "./served.ts";
|
|
7
|
+
/**
|
|
8
|
+
* The conformance runner (P066): an app points it at its real page (its CSP, its nonce, its
|
|
9
|
+
* session) and its issue endpoint; Porthole loads its own harness there (framework-free, on the
|
|
10
|
+
* core's `createHost`), routes the canaries and a foreign origin, and registers the leak and
|
|
11
|
+
* served suites in the app's Playwright project:
|
|
12
|
+
*
|
|
13
|
+
* defineHostConformance(test, {
|
|
14
|
+
* name: "my-app",
|
|
15
|
+
* open: async (page) => { await signIn(page); await page.goto("/viewer"); },
|
|
16
|
+
* issue: { url: "/api/frames" }, // the app's ContentHost.issue route
|
|
17
|
+
* config: (instance) => servedConfigs[presetOf(instance)],
|
|
18
|
+
* instances: { shell: (scope) => ({ scope, document: { kind: "shell", preset: "widget" } }) },
|
|
19
|
+
* });
|
|
20
|
+
*/
|
|
21
|
+
export interface HostConformanceOptions {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
/** Opens the app's real page in `page` as a person sees it (signed in); the harness runs there. */
|
|
24
|
+
readonly open: (page: Page) => Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* The page's script nonce, where its CSP is nonce-based. Default: the first `script[nonce]`'s.
|
|
27
|
+
* The harness loads as a script from the page's own origin, so `'self'` policies need none.
|
|
28
|
+
*/
|
|
29
|
+
readonly nonce?: (page: Page) => Promise<string | undefined>;
|
|
30
|
+
/**
|
|
31
|
+
* The app's issue endpoint (`ContentHost.issue`), relative to the page: the harness asks it from
|
|
32
|
+
* the page with the page's own cookies, as the app's client does (`httpLocator`).
|
|
33
|
+
*/
|
|
34
|
+
readonly issue?: {
|
|
35
|
+
readonly url: string;
|
|
36
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
37
|
+
};
|
|
38
|
+
/** Or the location for an instance, got the test's own way. */
|
|
39
|
+
readonly locate?: (instance: InstanceRef, page: Page) => Promise<FrameLocation>;
|
|
40
|
+
/** The config a served instance is framed under (the app's served config for its preset). */
|
|
41
|
+
readonly config: (instance: InstanceRef) => PortholeConfig;
|
|
42
|
+
/**
|
|
43
|
+
* The instances the served cases ask for, each for the scope a case names. `shell` runs the
|
|
44
|
+
* domain-mode cases; `artifact` (versions of one stored page), `app` (url mode) and `proxy` (the
|
|
45
|
+
* MCP Apps sandbox proxy) run theirs where given, and are skipped (with the reason) where not.
|
|
46
|
+
*/
|
|
47
|
+
readonly instances: Pick<ServedConformanceHost, "shell"> & Partial<Pick<ServedConformanceHost, "artifact" | "app" | "proxy">>;
|
|
48
|
+
/**
|
|
49
|
+
* Redirect entry (P068), where the app serves clean addresses: the cases open them directly,
|
|
50
|
+
* signed in and out, and in a frame.
|
|
51
|
+
*/
|
|
52
|
+
readonly entry?: {
|
|
53
|
+
/** The clean address of `instances.artifact(scope, "1")` (`ContentHost.urlOf`). */
|
|
54
|
+
readonly url: (scope: string) => string;
|
|
55
|
+
/** A clean address the signed-in viewer may not open. */
|
|
56
|
+
readonly denied: (scope: string) => string;
|
|
57
|
+
/** Whether `page` shows the app's own sign-in. */
|
|
58
|
+
readonly atSignIn: (page: Page) => Promise<boolean>;
|
|
59
|
+
/** On the app's sign-in page: signs in, as a person does. */
|
|
60
|
+
readonly completeSignIn: (page: Page) => Promise<void>;
|
|
61
|
+
};
|
|
62
|
+
/** Case ids to skip, each with its reason (shown in the report). */
|
|
63
|
+
readonly skip?: Readonly<Record<string, string>>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The harness as one script: from the package (`dist/conformance/harness.js`), or, run from the
|
|
67
|
+
* sources, bundled now (esbuild, a development dependency of Porthole's).
|
|
68
|
+
*/
|
|
69
|
+
export declare function harnessScript(): Promise<string>;
|
|
70
|
+
export interface HostConformance {
|
|
71
|
+
readonly leak: ConformanceHost;
|
|
72
|
+
readonly served: ServedConformanceHost;
|
|
73
|
+
/** Redirect entry's host, where the options give `entry`. */
|
|
74
|
+
readonly entry: EntryConformanceHost | null;
|
|
75
|
+
/** Prepares a test's page (the canaries, the foreign origin, the harness's route): call first. */
|
|
76
|
+
begin(page: Page): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
/** The leak and served hosts on an app's real page, for an app that registers the cases itself. */
|
|
79
|
+
export declare function hostConformance(options: HostConformanceOptions): HostConformance;
|
|
80
|
+
/**
|
|
81
|
+
* Registers the leak and served suites against an app's real page, in a spec file of the app's
|
|
82
|
+
* (so `-g "\[1\.connect\]"` selects one). The served cases for instances the app didn't give are
|
|
83
|
+
* skipped, each with its reason.
|
|
84
|
+
*/
|
|
85
|
+
export declare function defineHostConformance(test: TestType<object, object>, options: HostConformanceOptions): void;
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { expect } from "@playwright/test";
|
|
4
|
+
import { entryTests } from "./entry.js";
|
|
5
|
+
import { leakTests } from "./probes.js";
|
|
6
|
+
import { servedTests } from "./served.js";
|
|
7
|
+
const CANARY = "https://porthole-canary.example";
|
|
8
|
+
/** A page on no one's origin that frames what a case asks (routed by the test, like the canary). */
|
|
9
|
+
const FOREIGN = "http://porthole-foreign.example";
|
|
10
|
+
/** Where the harness loads from, on the app's own origin (routed by the test, never the app's). */
|
|
11
|
+
const HARNESS_PATH = "/__porthole/conformance/harness.js";
|
|
12
|
+
let built = null;
|
|
13
|
+
/**
|
|
14
|
+
* The harness as one script: from the package (`dist/conformance/harness.js`), or, run from the
|
|
15
|
+
* sources, bundled now (esbuild, a development dependency of Porthole's).
|
|
16
|
+
*/
|
|
17
|
+
export function harnessScript() {
|
|
18
|
+
built ??= (async () => {
|
|
19
|
+
try {
|
|
20
|
+
return await readFile(new URL("./harness.js", import.meta.url), "utf8");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
const { build } = await import("esbuild");
|
|
24
|
+
const result = await build({
|
|
25
|
+
entryPoints: [fileURLToPath(new URL("./harness/page.ts", import.meta.url))],
|
|
26
|
+
bundle: true,
|
|
27
|
+
format: "iife",
|
|
28
|
+
platform: "browser",
|
|
29
|
+
target: "es2022",
|
|
30
|
+
write: false,
|
|
31
|
+
logLevel: "silent",
|
|
32
|
+
});
|
|
33
|
+
const text = result.outputFiles[0]?.text;
|
|
34
|
+
if (text === undefined) {
|
|
35
|
+
throw new Error("the conformance harness didn't build");
|
|
36
|
+
}
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
return built;
|
|
41
|
+
}
|
|
42
|
+
/** Calls one of the harness's methods in the page. */
|
|
43
|
+
function invoke(page, method, ...args) {
|
|
44
|
+
return page.evaluate(([name, given]) => {
|
|
45
|
+
const harness = window.portholeConformance;
|
|
46
|
+
const run = harness?.[name];
|
|
47
|
+
if (run === undefined) {
|
|
48
|
+
throw new Error(`the conformance harness has no ${name}`);
|
|
49
|
+
}
|
|
50
|
+
return run(...given);
|
|
51
|
+
}, [method, args]);
|
|
52
|
+
}
|
|
53
|
+
/** The display's frame element's document, once it exists. */
|
|
54
|
+
async function frameIn(page, id) {
|
|
55
|
+
let found = null;
|
|
56
|
+
await expect
|
|
57
|
+
.poll(async () => {
|
|
58
|
+
const element = await page
|
|
59
|
+
.locator(`#porthole-display-${id} iframe`)
|
|
60
|
+
.elementHandle({ timeout: 2_000 })
|
|
61
|
+
.catch(() => null);
|
|
62
|
+
found = (await element?.contentFrame()) ?? null;
|
|
63
|
+
return found !== null;
|
|
64
|
+
})
|
|
65
|
+
.toBe(true);
|
|
66
|
+
return found;
|
|
67
|
+
}
|
|
68
|
+
/** The leak and served hosts on an app's real page, for an app that registers the cases itself. */
|
|
69
|
+
export function hostConformance(options) {
|
|
70
|
+
let current = null;
|
|
71
|
+
const opened = new WeakSet();
|
|
72
|
+
const hits = new Map();
|
|
73
|
+
const open = async (page) => {
|
|
74
|
+
await options.open(page);
|
|
75
|
+
opened.add(page);
|
|
76
|
+
};
|
|
77
|
+
/** Opens the app's page, if it isn't, and loads the harness into it under the page's policy. */
|
|
78
|
+
const ready = async (page) => {
|
|
79
|
+
const loaded = await page
|
|
80
|
+
.evaluate(() => document.documentElement.dataset.portholeConformance === "ready")
|
|
81
|
+
.catch(() => false);
|
|
82
|
+
if (loaded) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
await open(page);
|
|
86
|
+
const nonce = (await options.nonce?.(page)) ??
|
|
87
|
+
(await page.evaluate(() => document.querySelector("script[nonce]")?.nonce || undefined));
|
|
88
|
+
await page.evaluate(([path, given]) => {
|
|
89
|
+
const script = document.createElement("script");
|
|
90
|
+
if (given !== undefined) {
|
|
91
|
+
script.nonce = given;
|
|
92
|
+
}
|
|
93
|
+
script.src = path;
|
|
94
|
+
document.head.append(script);
|
|
95
|
+
}, [HARNESS_PATH, nonce]);
|
|
96
|
+
await expect
|
|
97
|
+
.poll(() => page.evaluate(() => document.documentElement.dataset.portholeConformance), {
|
|
98
|
+
message: "the harness runs on the app's page (does its CSP let a script from its own origin, or with its nonce, run?)",
|
|
99
|
+
})
|
|
100
|
+
.toBe("ready");
|
|
101
|
+
};
|
|
102
|
+
const leak = {
|
|
103
|
+
name: options.name,
|
|
104
|
+
async launch(page, spec) {
|
|
105
|
+
await ready(page);
|
|
106
|
+
const id = await invoke(page, "launch", spec);
|
|
107
|
+
return {
|
|
108
|
+
frame: () => frameIn(page, id),
|
|
109
|
+
async box() {
|
|
110
|
+
const element = await page.locator(`#porthole-display-${id} iframe`).elementHandle();
|
|
111
|
+
const area = await element?.boundingBox();
|
|
112
|
+
return { width: area?.width ?? 0, height: area?.height ?? 0 };
|
|
113
|
+
},
|
|
114
|
+
setContent: (html, given) => invoke(page, "setContent", id, html, given?.partial === true),
|
|
115
|
+
setTheme: (scheme) => invoke(page, "setTheme", id, scheme),
|
|
116
|
+
async answerNextConfirmation(accept, given) {
|
|
117
|
+
await expect
|
|
118
|
+
.poll(() => invoke(page, "pendingConfirmations", id), {
|
|
119
|
+
timeout: given?.timeoutMs ?? 5_000,
|
|
120
|
+
})
|
|
121
|
+
.toBeGreaterThan(0);
|
|
122
|
+
const request = await invoke(page, "answer", id, accept);
|
|
123
|
+
if (request === null) {
|
|
124
|
+
throw new Error("no confirmation was waiting");
|
|
125
|
+
}
|
|
126
|
+
return request;
|
|
127
|
+
},
|
|
128
|
+
calls: () => invoke(page, "calls", id),
|
|
129
|
+
errors: () => invoke(page, "errors", id),
|
|
130
|
+
events: () => invoke(page, "events", id),
|
|
131
|
+
destroy: () => invoke(page, "destroy", id),
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
const locate = async (instance) => {
|
|
136
|
+
const page = current;
|
|
137
|
+
if (page === null) {
|
|
138
|
+
throw new Error("the runner has no page: call begin(page) first");
|
|
139
|
+
}
|
|
140
|
+
if (options.locate !== undefined) {
|
|
141
|
+
return options.locate(instance, page);
|
|
142
|
+
}
|
|
143
|
+
if (options.issue === undefined) {
|
|
144
|
+
throw new Error("the runner needs the app's issue endpoint (issue) or locate");
|
|
145
|
+
}
|
|
146
|
+
if (!opened.has(page)) {
|
|
147
|
+
await open(page);
|
|
148
|
+
}
|
|
149
|
+
// Asked as the page asks it: the context's cookies, the page's origin.
|
|
150
|
+
const response = await page
|
|
151
|
+
.context()
|
|
152
|
+
.request.post(new URL(options.issue.url, page.url()).href, {
|
|
153
|
+
data: { instance },
|
|
154
|
+
headers: { origin: new URL(page.url()).origin, ...options.issue.headers },
|
|
155
|
+
maxRedirects: 0,
|
|
156
|
+
});
|
|
157
|
+
if (response.status() !== 201) {
|
|
158
|
+
throw new Error(`the app's issue endpoint answered ${response.status()}: ${(await response.text()).slice(0, 200)}`);
|
|
159
|
+
}
|
|
160
|
+
return (await response.json());
|
|
161
|
+
};
|
|
162
|
+
const { artifact, app, proxy } = options.instances;
|
|
163
|
+
const served = {
|
|
164
|
+
name: options.name,
|
|
165
|
+
async show(page, instance, given = {}) {
|
|
166
|
+
await ready(page);
|
|
167
|
+
const where = options.locate === undefined && options.issue !== undefined
|
|
168
|
+
? {
|
|
169
|
+
issue: options.issue.url,
|
|
170
|
+
...(options.issue.headers === undefined ? {} : { headers: options.issue.headers }),
|
|
171
|
+
}
|
|
172
|
+
: { location: await locate(instance) };
|
|
173
|
+
const id = await invoke(page, "show", options.config(instance), instance, where, given.content);
|
|
174
|
+
let origin = null;
|
|
175
|
+
await expect
|
|
176
|
+
.poll(async () => {
|
|
177
|
+
origin = await invoke(page, "origin", id);
|
|
178
|
+
return origin ?? (await invoke(page, "failure", id)) ?? "";
|
|
179
|
+
}, { message: "the app's issue endpoint locates the instance" })
|
|
180
|
+
.toMatch(/^https?:\/\//u);
|
|
181
|
+
const located = origin;
|
|
182
|
+
return {
|
|
183
|
+
origin: located,
|
|
184
|
+
async frame() {
|
|
185
|
+
const frame = await frameIn(page, id);
|
|
186
|
+
await frame.waitForURL((url) => url.origin === located);
|
|
187
|
+
// A url-mode app's entry page goes on to the app where the browser kept the cookie; where
|
|
188
|
+
// it didn't (WebKit, P025), it stays, saying so: the frame is that page then.
|
|
189
|
+
await frame
|
|
190
|
+
.waitForURL((url) => url.pathname !== "/__porthole/enter", { timeout: 5_000 })
|
|
191
|
+
.catch(() => undefined);
|
|
192
|
+
await frame.waitForLoadState("load");
|
|
193
|
+
return frame;
|
|
194
|
+
},
|
|
195
|
+
destroy: () => invoke(page, "destroy", id),
|
|
196
|
+
post: (message) => invoke(page, "post", id, message),
|
|
197
|
+
received: () => invoke(page, "received", id),
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
locate,
|
|
201
|
+
async frameFromForeignOrigin(page, url) {
|
|
202
|
+
await page.goto(`${FOREIGN}/?frame=${encodeURIComponent(url)}`);
|
|
203
|
+
},
|
|
204
|
+
canary: (name) => Promise.resolve(`${CANARY}/${name}`),
|
|
205
|
+
canaryHits: (name) => Promise.resolve(hits.get(name) ?? 0),
|
|
206
|
+
shell: options.instances.shell,
|
|
207
|
+
// Asked for only by cases that run where the app gave them (`defineHostConformance` skips).
|
|
208
|
+
artifact: artifact ??
|
|
209
|
+
((scope, version) => ({ scope, document: { kind: "artifact", id: "page", version } })),
|
|
210
|
+
app: app ?? ((scope) => ({ scope, document: { kind: "external", url: "http://app.test/" } })),
|
|
211
|
+
proxy: proxy ?? ((scope) => ({ scope, document: { kind: "mcp-proxy" } })),
|
|
212
|
+
};
|
|
213
|
+
const given = options.entry;
|
|
214
|
+
const entry = given === undefined
|
|
215
|
+
? null
|
|
216
|
+
: {
|
|
217
|
+
name: options.name,
|
|
218
|
+
// Signed in on the app's page, the harness there (a frame needs it).
|
|
219
|
+
signIn: async (page) => {
|
|
220
|
+
await open(page);
|
|
221
|
+
await ready(page);
|
|
222
|
+
},
|
|
223
|
+
url: given.url,
|
|
224
|
+
denied: given.denied,
|
|
225
|
+
atSignIn: given.atSignIn,
|
|
226
|
+
completeSignIn: given.completeSignIn,
|
|
227
|
+
async frame(page, url) {
|
|
228
|
+
await ready(page);
|
|
229
|
+
const instance = served.artifact("entry", "1");
|
|
230
|
+
const id = await invoke(page, "show", options.config(instance), instance, {
|
|
231
|
+
location: { url, origin: new URL(url).origin },
|
|
232
|
+
});
|
|
233
|
+
return {
|
|
234
|
+
frame: () => frameIn(page, id),
|
|
235
|
+
async outcome() {
|
|
236
|
+
let found = null;
|
|
237
|
+
await expect
|
|
238
|
+
.poll(async () => {
|
|
239
|
+
const events = await invoke(page, "events", id);
|
|
240
|
+
const each = events.find((event) => event.type === "outcome");
|
|
241
|
+
found = each === undefined ? null : each.detail;
|
|
242
|
+
return found !== null;
|
|
243
|
+
}, { message: "the display has an outcome", timeout: 15_000 })
|
|
244
|
+
.toBe(true);
|
|
245
|
+
return found;
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
leak,
|
|
252
|
+
served,
|
|
253
|
+
entry,
|
|
254
|
+
async begin(page) {
|
|
255
|
+
current = page;
|
|
256
|
+
const script = await harnessScript();
|
|
257
|
+
await page.route(`**${HARNESS_PATH}`, (route) => route.fulfill({
|
|
258
|
+
status: 200,
|
|
259
|
+
contentType: "text/javascript; charset=utf-8",
|
|
260
|
+
headers: { "cache-control": "no-store" },
|
|
261
|
+
body: script,
|
|
262
|
+
}));
|
|
263
|
+
await page.context().route(`${CANARY}/**`, (route) => {
|
|
264
|
+
const name = new URL(route.request().url()).pathname.slice(1);
|
|
265
|
+
hits.set(name, (hits.get(name) ?? 0) + 1);
|
|
266
|
+
return route.fulfill({ status: 200, contentType: "text/plain", body: "canary" });
|
|
267
|
+
});
|
|
268
|
+
await page.context().route(`${FOREIGN}/**`, (route) => {
|
|
269
|
+
const target = new URL(route.request().url()).searchParams.get("frame") ?? "";
|
|
270
|
+
const attribute = target.replace(/&/gu, "&").replace(/"/gu, """);
|
|
271
|
+
return route.fulfill({
|
|
272
|
+
status: 200,
|
|
273
|
+
contentType: "text/html",
|
|
274
|
+
body: `<!doctype html><title>Foreign</title><iframe sandbox="allow-scripts allow-same-origin" src="${attribute}"></iframe>`,
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const URL_CASES = [
|
|
281
|
+
"served.url.origin",
|
|
282
|
+
"served.url.no-reach",
|
|
283
|
+
"served.url.no-top-navigation",
|
|
284
|
+
"served.url.no-popups",
|
|
285
|
+
"served.url.ticket",
|
|
286
|
+
"served.url.tab",
|
|
287
|
+
"served.url.framed",
|
|
288
|
+
];
|
|
289
|
+
const PROXY_CASES = ["served.proxy.view", "served.proxy.parent-only", "served.proxy.egress"];
|
|
290
|
+
/**
|
|
291
|
+
* Registers the leak and served suites against an app's real page, in a spec file of the app's
|
|
292
|
+
* (so `-g "\[1\.connect\]"` selects one). The served cases for instances the app didn't give are
|
|
293
|
+
* skipped, each with its reason.
|
|
294
|
+
*/
|
|
295
|
+
export function defineHostConformance(test, options) {
|
|
296
|
+
const typed = test;
|
|
297
|
+
const hosts = hostConformance(options);
|
|
298
|
+
const skip = { ...options.skip };
|
|
299
|
+
const absent = (ids, reason) => {
|
|
300
|
+
for (const id of ids) {
|
|
301
|
+
skip[id] ??= reason;
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
if (options.instances.artifact === undefined) {
|
|
305
|
+
absent(["served.artifact.versions"], "the app serves no versions of a stored page");
|
|
306
|
+
}
|
|
307
|
+
if (options.instances.app === undefined) {
|
|
308
|
+
absent(URL_CASES, "the app serves no url-mode apps");
|
|
309
|
+
}
|
|
310
|
+
if (options.instances.proxy === undefined) {
|
|
311
|
+
absent(PROXY_CASES, "the app serves no MCP Apps views");
|
|
312
|
+
}
|
|
313
|
+
typed.describe(`Porthole leak suite (${options.name})`, () => {
|
|
314
|
+
for (const each of leakTests(hosts.leak, { skip })) {
|
|
315
|
+
// Playwright reads the fixtures a test uses from its parameter's destructuring.
|
|
316
|
+
typed(each.title, async ({ page, browserName }, info) => {
|
|
317
|
+
await hosts.begin(page);
|
|
318
|
+
await each.run({ page, browserName }, info);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
typed.describe(`Porthole served modes (${options.name})`, () => {
|
|
323
|
+
for (const each of servedTests(hosts.served, { skip })) {
|
|
324
|
+
typed(each.title, async ({ page, browserName }, info) => {
|
|
325
|
+
await hosts.begin(page);
|
|
326
|
+
await each.run({ page, browserName }, info);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
const entry = hosts.entry;
|
|
331
|
+
if (entry !== null) {
|
|
332
|
+
typed.describe(`Porthole redirect entry (${options.name})`, () => {
|
|
333
|
+
for (const each of entryTests(entry, { skip })) {
|
|
334
|
+
typed(each.title, async ({ page, browserName }, info) => {
|
|
335
|
+
await hosts.begin(page);
|
|
336
|
+
await each.run({ page, browserName }, info);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -101,3 +101,5 @@ export interface LeakSuiteOptions {
|
|
|
101
101
|
export { defineLeakSuite, leakTests, PROBES, type ConformanceFixtures, type ConformanceTest, type Probe, } from "./probes.ts";
|
|
102
102
|
export { httpOracle, settle, udpOracle, type HttpOracle, type UdpOracle } from "./oracles.ts";
|
|
103
103
|
export { defineServedSuite, servedTests, type ServedConformanceHost, type ServedDisplay, type ServedSuiteOptions, } from "./served.ts";
|
|
104
|
+
export { entryTests, type EntryConformanceHost, type EntrySuiteOptions } from "./entry.ts";
|
|
105
|
+
export { defineHostConformance, harnessScript, hostConformance, type HostConformance, type HostConformanceOptions, } from "./host-kit.ts";
|
|
@@ -2,3 +2,7 @@ export { defineLeakSuite, leakTests, PROBES, } from "./probes.js";
|
|
|
2
2
|
export { httpOracle, settle, udpOracle } from "./oracles.js";
|
|
3
3
|
// The served modes' cases (domain and url mode), with their own host interface.
|
|
4
4
|
export { defineServedSuite, servedTests, } from "./served.js";
|
|
5
|
+
// Redirect entry's cases (P068).
|
|
6
|
+
export { entryTests } from "./entry.js";
|
|
7
|
+
// The runner an app points at its real page and issue endpoint (P066).
|
|
8
|
+
export { defineHostConformance, harnessScript, hostConformance, } from "./host-kit.js";
|
|
@@ -119,7 +119,7 @@ export const LIMIT_PROBES = [
|
|
|
119
119
|
{
|
|
120
120
|
id: "14.silent",
|
|
121
121
|
item: 14,
|
|
122
|
-
title: "what the sandbox blocks
|
|
122
|
+
title: "what the sandbox blocks shows nothing on the page: nothing loads, a fetch rejects; the host hears of it where reportError is declared, and nothing where it isn't",
|
|
123
123
|
async run(context) {
|
|
124
124
|
const canary = await context.http();
|
|
125
125
|
const seen = watch(context.page);
|
|
@@ -143,6 +143,21 @@ export const LIMIT_PROBES = [
|
|
|
143
143
|
});
|
|
144
144
|
expect(canary.requests()).toEqual([]);
|
|
145
145
|
expect(seen.dialogs).toEqual([]);
|
|
146
|
+
// Where reportError is declared, each block is reported to the host (the manual says where
|
|
147
|
+
// it goes, by \`errors\`, #4); where it isn't, nothing is.
|
|
148
|
+
await expect
|
|
149
|
+
.poll(async () => (await display.errors()).some((each) => each.startsWith("Blocked by the sandbox:")))
|
|
150
|
+
.toBe(true);
|
|
151
|
+
const quiet = await context.launch({
|
|
152
|
+
config: inline({ errors: "none" }),
|
|
153
|
+
content: attack(`
|
|
154
|
+
r.fetch = await fetch(${js(`${c}/quiet`)}).then(() => "sent", (e) => "rejected " + e.name);`, `<img src="${c}/quiet.png">`),
|
|
155
|
+
});
|
|
156
|
+
expect(await context.result(quiet, 15_000)).toMatchObject({ fetch: "rejected TypeError" });
|
|
157
|
+
await settle();
|
|
158
|
+
expect(await quiet.errors()).toEqual([]);
|
|
159
|
+
expect(canary.requests()).toEqual([]);
|
|
160
|
+
expect(seen.dialogs).toEqual([]);
|
|
146
161
|
},
|
|
147
162
|
},
|
|
148
163
|
{
|
|
@@ -375,9 +375,19 @@ export function servedTests(host, options = {}) {
|
|
|
375
375
|
});
|
|
376
376
|
define("served.url.ticket", "an app shows nothing to a browser without its ticket's cookie", async ({ page }) => {
|
|
377
377
|
const location = await host.locate(host.app("url-ticket"));
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
378
|
+
// A fresh browser: no cookie of the content's, none of the app's. Porthole refuses it, or
|
|
379
|
+
// (redirect entry, P068) hands it to the app's authorize route, which asks it to sign in.
|
|
380
|
+
const fresh = await page.context().browser().newContext();
|
|
381
|
+
try {
|
|
382
|
+
const other = await fresh.newPage();
|
|
383
|
+
const bare = await other.goto(`${location.origin}/`);
|
|
384
|
+
const away = new URL(other.url()).origin !== location.origin;
|
|
385
|
+
expect(bare?.status() === 401 || away, `ended at ${other.url()}`).toBe(true);
|
|
386
|
+
expect(await other.evaluate(() => document.documentElement.dataset.app ?? null)).toBeNull();
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
await fresh.close();
|
|
390
|
+
}
|
|
381
391
|
});
|
|
382
392
|
define("served.url.tab", "an app opened on a tab of its own runs, its requests riding on its cookie", async ({ page }) => {
|
|
383
393
|
const location = await host.locate(host.app("url-tab"));
|
package/dist/guide/limits.js
CHANGED
|
@@ -81,7 +81,15 @@ export function manualLimits(config, network) {
|
|
|
81
81
|
: documents === "never"
|
|
82
82
|
? "It always shows in light: the app doesn't theme it, so style it yourself; prefers-color-scheme may report the reader's system setting, so don't rely on it."
|
|
83
83
|
: 'It shows in light, as you style it; to follow the person\'s light or dark theme, declare it in its head (<meta name="color-scheme" content="light dark">) and draw with the variables below.');
|
|
84
|
-
|
|
84
|
+
// What a block does on the page, and where it is reported: as the Errors line says (#4).
|
|
85
|
+
const blocked = "a blocked script, style or image just doesn't load and a blocked fetch rejects";
|
|
86
|
+
add("silent", !declared.has("reportError")
|
|
87
|
+
? "What the sandbox blocks fails silently: a blocked script, style or image just doesn't load, a blocked fetch rejects, and nothing shows an error."
|
|
88
|
+
: config.errors === "agent"
|
|
89
|
+
? `What the sandbox blocks shows nothing on the page: ${blocked}. Each block comes back to you as an error, so check them.`
|
|
90
|
+
: config.errors === "host"
|
|
91
|
+
? `What the sandbox blocks shows nothing on the page: ${blocked}. The app is told of each block, but you aren't: test what you write.`
|
|
92
|
+
: `What the sandbox blocks shows nothing on the page: ${blocked}. Each block is reported to the app.`);
|
|
85
93
|
add("network", network);
|
|
86
94
|
// Assets: what isn't opened goes inline (Network: none already says "everything inline").
|
|
87
95
|
const anywhere = (kind) => (config.egress.anyHttps ?? []).some((each) => each === kind);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PortholeConfig } from "../config/schema.ts";
|
|
2
|
+
import type { InstanceLocator } from "../isolation/types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The client half of the host kit (P066): a locator that asks the app's issue endpoint
|
|
5
|
+
* (`ContentHost.issue`) for a frame's location, and the choice between served and opaque configs.
|
|
6
|
+
* The client proposes an instance; the server's mapping decides what it means, and the server
|
|
7
|
+
* alone chooses the origin.
|
|
8
|
+
*/
|
|
9
|
+
/** Why the issue endpoint gave no location: its status and code (`not-served`, `forbidden`, …). */
|
|
10
|
+
export declare class LocateError extends Error {
|
|
11
|
+
readonly status: number;
|
|
12
|
+
readonly code: string;
|
|
13
|
+
constructor(message: string, status: number, code: string);
|
|
14
|
+
}
|
|
15
|
+
export interface HttpLocatorOptions {
|
|
16
|
+
/** Headers for each request (an `authorization`), or a function giving them now. */
|
|
17
|
+
readonly headers?: HeadersInit | (() => HeadersInit);
|
|
18
|
+
/** Default `same-origin` (the app's own cookies). */
|
|
19
|
+
readonly credentials?: RequestCredentials;
|
|
20
|
+
/** Content frames aren't served now: refresh what the page knows (`ContentHost.meta`). */
|
|
21
|
+
readonly onNotServed?: () => void;
|
|
22
|
+
readonly fetch?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
/** A locator that POSTs `{ instance }` to the app's issue endpoint at `url`. */
|
|
25
|
+
export declare function httpLocator(url: string, options?: HttpLocatorOptions): InstanceLocator;
|
|
26
|
+
/** What the page knows of content (`ContentHost.meta`, as the app's own meta carries it). */
|
|
27
|
+
export interface ServedMeta {
|
|
28
|
+
readonly served: boolean;
|
|
29
|
+
readonly contentSource: string | null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The configs a page's displays run under: served (from the content source) while content is
|
|
33
|
+
* served, opaque otherwise; null while the page doesn't know yet, so a frame never starts opaque
|
|
34
|
+
* and then restarts served.
|
|
35
|
+
*/
|
|
36
|
+
export declare function servedOrOpaque<T extends Readonly<Record<string, PortholeConfig>>>(meta: ServedMeta | null | undefined, served: (contentSource: string) => T, opaque: T): {
|
|
37
|
+
readonly served: boolean;
|
|
38
|
+
readonly configs: T;
|
|
39
|
+
} | null;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client half of the host kit (P066): a locator that asks the app's issue endpoint
|
|
3
|
+
* (`ContentHost.issue`) for a frame's location, and the choice between served and opaque configs.
|
|
4
|
+
* The client proposes an instance; the server's mapping decides what it means, and the server
|
|
5
|
+
* alone chooses the origin.
|
|
6
|
+
*/
|
|
7
|
+
/** Why the issue endpoint gave no location: its status and code (`not-served`, `forbidden`, …). */
|
|
8
|
+
export class LocateError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
code;
|
|
11
|
+
constructor(message, status, code) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "LocateError";
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
19
|
+
/** A locator that POSTs `{ instance }` to the app's issue endpoint at `url`. */
|
|
20
|
+
export function httpLocator(url, options = {}) {
|
|
21
|
+
return {
|
|
22
|
+
async locate(instance, signal) {
|
|
23
|
+
const extra = typeof options.headers === "function" ? options.headers() : options.headers;
|
|
24
|
+
const headers = new Headers(extra);
|
|
25
|
+
headers.set("content-type", "application/json");
|
|
26
|
+
headers.set("accept", "application/json");
|
|
27
|
+
const response = await (options.fetch ?? fetch)(url, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers,
|
|
30
|
+
credentials: options.credentials ?? "same-origin",
|
|
31
|
+
redirect: "error",
|
|
32
|
+
body: JSON.stringify({ instance }),
|
|
33
|
+
...(signal === undefined ? {} : { signal }),
|
|
34
|
+
});
|
|
35
|
+
const data = await response.json().catch(() => null);
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
const error = isRecord(data) && isRecord(data.error) ? data.error : {};
|
|
38
|
+
const code = typeof error.code === "string" ? error.code : "failed";
|
|
39
|
+
if (code === "not-served") {
|
|
40
|
+
options.onNotServed?.();
|
|
41
|
+
}
|
|
42
|
+
throw new LocateError(typeof error.message === "string"
|
|
43
|
+
? error.message
|
|
44
|
+
: `The app's issue endpoint answered ${response.status}`, response.status, code);
|
|
45
|
+
}
|
|
46
|
+
if (!isRecord(data) ||
|
|
47
|
+
typeof data.url !== "string" ||
|
|
48
|
+
typeof data.origin !== "string" ||
|
|
49
|
+
(data.reports !== undefined && typeof data.reports !== "boolean")) {
|
|
50
|
+
throw new LocateError("The app's issue endpoint gave no frame location", response.status, "bad-location");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
url: data.url,
|
|
54
|
+
origin: data.origin,
|
|
55
|
+
...(typeof data.reports === "boolean" ? { reports: data.reports } : {}),
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The configs a page's displays run under: served (from the content source) while content is
|
|
62
|
+
* served, opaque otherwise; null while the page doesn't know yet, so a frame never starts opaque
|
|
63
|
+
* and then restarts served.
|
|
64
|
+
*/
|
|
65
|
+
export function servedOrOpaque(meta, served, opaque) {
|
|
66
|
+
if (meta === null || meta === undefined) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return meta.served && meta.contentSource !== null
|
|
70
|
+
? { served: true, configs: served(meta.contentSource) }
|
|
71
|
+
: { served: false, configs: opaque };
|
|
72
|
+
}
|
package/dist/host/outcome.js
CHANGED
package/dist/host/types.d.ts
CHANGED
|
@@ -79,6 +79,8 @@ export type FrameBlock =
|
|
|
79
79
|
"framing"
|
|
80
80
|
/** The browser keeps no cookie for it inside a frame (WebKit, P025): open it on a tab. */
|
|
81
81
|
| "cookies"
|
|
82
|
+
/** The person isn't signed in at the app, and its sign-in never shows in a frame: a tab (P068). */
|
|
83
|
+
| "sign-in"
|
|
82
84
|
/** Its ticket was used or its session ended. */
|
|
83
85
|
| "session"
|
|
84
86
|
/** Its app didn't answer. */
|
package/dist/index.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export { COPY_TEXT_METHOD, MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS
|
|
|
25
25
|
export type { CallContext, CallOutcome, CapabilityHandler, CapabilityHandlers, Confirm, ConfirmAnswer, ConfirmRequest, Proceed, HostFunctionBridge, Refusal, } from "./bridge/types.ts";
|
|
26
26
|
export { createHost } from "./host/create-host.ts";
|
|
27
27
|
export { writeClipboard } from "./host/clipboard.ts";
|
|
28
|
+
export { httpLocator, LocateError, servedOrOpaque, type HttpLocatorOptions, type ServedMeta, } from "./host/locator.ts";
|
|
28
29
|
export type { AnnotationControls, DisplayControls, DisplayEvents, FrameBlock, FrameOutcome, DisplayHandle, HostOptions, MountOptions, PortholeHost, } from "./host/types.ts";
|
|
29
30
|
export { ANNOTATE_METHODS, ANNOTATION_LIMITS, type AnchorResult, type AnchorState, type AnnotateMode, type AnnotationAnchor, type AnnotationMark, type AnnotationRect, type AnnotationReport, type AnnotationSelector, type CssSelector, type TextPositionSelector, type TextQuoteSelector, } from "./annotate/types.ts";
|
|
30
31
|
export { parseAnchor, parseRect, parseReport } from "./annotate/validate.ts";
|