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