@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.
Files changed (59) hide show
  1. package/dist/conformance/clipboard.js +5 -2
  2. package/dist/conformance/entry.d.ts +37 -0
  3. package/dist/conformance/entry.js +120 -0
  4. package/dist/conformance/harness/page.d.ts +32 -0
  5. package/dist/conformance/harness/page.js +215 -0
  6. package/dist/conformance/harness.js +1662 -0
  7. package/dist/conformance/host-kit.d.ts +85 -0
  8. package/dist/conformance/host-kit.js +341 -0
  9. package/dist/conformance/index.d.ts +2 -0
  10. package/dist/conformance/index.js +4 -0
  11. package/dist/conformance/limits.js +16 -1
  12. package/dist/conformance/served.js +13 -3
  13. package/dist/guide/limits.js +9 -1
  14. package/dist/host/locator.d.ts +39 -0
  15. package/dist/host/locator.js +72 -0
  16. package/dist/host/outcome.js +1 -0
  17. package/dist/host/types.d.ts +2 -0
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/node/index.d.ts +41 -0
  21. package/dist/node/index.js +160 -0
  22. package/dist/platform/web.js +12 -1
  23. package/dist/server/domain.d.ts +43 -0
  24. package/dist/server/domain.js +100 -0
  25. package/dist/server/host.d.ts +158 -0
  26. package/dist/server/host.js +416 -0
  27. package/dist/server/index.d.ts +9 -2
  28. package/dist/server/index.js +7 -0
  29. package/dist/server/naming.d.ts +12 -1
  30. package/dist/server/naming.js +9 -8
  31. package/dist/server/pages.d.ts +9 -0
  32. package/dist/server/pages.js +32 -0
  33. package/dist/server/server.js +287 -50
  34. package/dist/server/types.d.ts +37 -0
  35. package/dist/transport/post-message.d.ts +9 -1
  36. package/dist/transport/post-message.js +11 -2
  37. package/package.json +6 -2
  38. package/src/conformance/clipboard.ts +5 -2
  39. package/src/conformance/entry.ts +206 -0
  40. package/src/conformance/harness/page.ts +267 -0
  41. package/src/conformance/host-kit.ts +466 -0
  42. package/src/conformance/index.ts +12 -0
  43. package/src/conformance/limits.ts +21 -1
  44. package/src/conformance/served.ts +14 -3
  45. package/src/guide/limits.ts +9 -1
  46. package/src/host/locator.ts +111 -0
  47. package/src/host/outcome.ts +1 -0
  48. package/src/host/types.ts +2 -0
  49. package/src/index.ts +7 -0
  50. package/src/node/index.ts +221 -0
  51. package/src/platform/web.ts +19 -1
  52. package/src/server/domain.ts +158 -0
  53. package/src/server/host.ts +706 -0
  54. package/src/server/index.ts +27 -1
  55. package/src/server/naming.ts +22 -2
  56. package/src/server/pages.ts +44 -0
  57. package/src/server/server.ts +355 -63
  58. package/src/server/types.ts +33 -0
  59. package/src/transport/post-message.ts +12 -1
@@ -0,0 +1,206 @@
1
+ import { expect, type Frame, type Page, type TestInfo } from "@playwright/test";
2
+ import type { FrameOutcome } from "../host/types.ts";
3
+ import type { ConformanceTest } from "./probes.ts";
4
+
5
+ /**
6
+ * Redirect entry's cases (P068): a clean address opens directly, signed in or not, and never
7
+ * shows content without access; its one-time code works once, on its own origin; in a frame, the
8
+ * app's sign-in never shows. A host implements `EntryConformanceHost` (or gives
9
+ * `defineHostConformance` its `entry`).
10
+ */
11
+
12
+ export interface EntryConformanceHost {
13
+ readonly name: string;
14
+ /** Signs `page`'s browser in at the app, as a person does, leaving it on the app's page. */
15
+ signIn(page: Page): Promise<void>;
16
+ /**
17
+ * The clean address of a stored page the signed-in viewer may open (`ContentHost.urlOf`), for
18
+ * `scope`: it draws `<main id="artifact">version 1</main>`.
19
+ */
20
+ url(scope: string): string;
21
+ /** A clean address the signed-in viewer may not open. */
22
+ denied(scope: string): string;
23
+ /** Whether `page` shows the app's own sign-in. */
24
+ atSignIn(page: Page): Promise<boolean>;
25
+ /** On the app's sign-in page: signs in, as a person does (and the app goes back). */
26
+ completeSignIn(page: Page): Promise<void>;
27
+ /**
28
+ * Frames a clean address on the app's page as the app would (a location of that address),
29
+ * returning the frame and the display's outcome once it has one.
30
+ */
31
+ frame(
32
+ page: Page,
33
+ url: string,
34
+ ): Promise<{ frame(): Promise<Frame>; outcome(): Promise<FrameOutcome> }>;
35
+ }
36
+
37
+ export interface EntrySuiteOptions {
38
+ readonly skip?: Readonly<Record<string, string>>;
39
+ }
40
+
41
+ type Fixtures = { page: Page; browserName: string };
42
+
43
+ /** Whether a page shows the stored page's own content. */
44
+ const shows = async (where: Page | Frame): Promise<boolean> =>
45
+ (await where.locator("#artifact").count()) > 0;
46
+
47
+ /** Walks a redirect entry by hand, as the browser would on a tab: the code URL it ends with. */
48
+ async function codeFor(page: Page, url: string): Promise<string> {
49
+ const request = page.context().request;
50
+ const top = { "sec-fetch-dest": "document" };
51
+ const begin = await request.get(url, { maxRedirects: 0, headers: top });
52
+ expect(begin.status(), "a clean address without a cookie goes to the app").toBe(302);
53
+ const authorized = await request.get(begin.headers().location ?? "", {
54
+ maxRedirects: 0,
55
+ headers: top,
56
+ });
57
+ expect(authorized.status(), "the app's authorize route gives a code").toBe(302);
58
+ const code = authorized.headers().location ?? "";
59
+ expect(code).toContain("/__porthole/entry?code=");
60
+ return code;
61
+ }
62
+
63
+ export function entryTests(
64
+ host: EntryConformanceHost,
65
+ options: EntrySuiteOptions = {},
66
+ ): ConformanceTest[] {
67
+ const tests: ConformanceTest[] = [];
68
+ const define = (
69
+ id: string,
70
+ title: string,
71
+ body: (fixtures: Fixtures, info: TestInfo) => Promise<void>,
72
+ ) => {
73
+ tests.push({
74
+ id,
75
+ title: `${id}: ${title}`,
76
+ async run({ page, browserName }, info) {
77
+ const reason = options.skip?.[id];
78
+ info.skip(reason !== undefined, reason);
79
+ await body({ page, browserName }, info);
80
+ },
81
+ });
82
+ };
83
+
84
+ define(
85
+ "served.entry.direct",
86
+ "a clean address opens directly for a signed-in viewer, and stays clean; a reload rides on its cookie",
87
+ async ({ page }) => {
88
+ await host.signIn(page);
89
+ const url = host.url("entry-direct");
90
+ await page.goto(url);
91
+ await expect(page.locator("#artifact")).toHaveText("version 1");
92
+ expect(page.url(), "the address bar shows the clean address, no code").toBe(url);
93
+ const entries: string[] = [];
94
+ page.on("request", (request) => {
95
+ if (request.url().includes("/__porthole/entry")) {
96
+ entries.push(request.url());
97
+ }
98
+ });
99
+ await page.reload();
100
+ await expect(page.locator("#artifact")).toHaveText("version 1");
101
+ expect(entries, "a reload needs no entry").toEqual([]);
102
+ },
103
+ );
104
+
105
+ define(
106
+ "served.entry.signed-out",
107
+ "signed out, a clean address goes to the app's own sign-in, then back to the content",
108
+ async ({ page }) => {
109
+ const url = host.url("entry-signed-out");
110
+ await page.goto(url);
111
+ expect(await host.atSignIn(page), "the app's sign-in").toBe(true);
112
+ expect(await shows(page)).toBe(false);
113
+ await host.completeSignIn(page);
114
+ await expect(page.locator("#artifact")).toHaveText("version 1");
115
+ expect(page.url()).toBe(url);
116
+ },
117
+ );
118
+
119
+ define(
120
+ "served.entry.denied",
121
+ "a viewer without access gets the app's refusal, never the content",
122
+ async ({ page }) => {
123
+ await host.signIn(page);
124
+ const url = host.denied("entry-denied");
125
+ const answer = await page.goto(url);
126
+ expect(answer?.status()).toBe(403);
127
+ expect(await shows(page)).toBe(false);
128
+ expect(new URL(page.url()).origin, "the refusal is the app's").not.toBe(new URL(url).origin);
129
+ },
130
+ );
131
+
132
+ define(
133
+ "served.entry.code-once",
134
+ "an entry code works once: replayed, it opens nothing",
135
+ async ({ page }) => {
136
+ await host.signIn(page);
137
+ const code = await codeFor(page, host.url("entry-once"));
138
+ const request = page.context().request;
139
+ const top = { "sec-fetch-dest": "document" };
140
+ const first = await request.get(code, { maxRedirects: 0, headers: top });
141
+ expect(first.status()).toBe(303);
142
+ const again = await request.get(code, { maxRedirects: 0, headers: top });
143
+ expect(again.status()).toBe(403);
144
+ expect(await again.text()).not.toContain('id="artifact"');
145
+ },
146
+ );
147
+
148
+ define(
149
+ "served.entry.code-elsewhere",
150
+ "an entry code taken to another instance's origin opens nothing there",
151
+ async ({ page }) => {
152
+ 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"));
155
+ const request = page.context().request;
156
+ const answer = await request.get(`${other.origin}${code.pathname}${code.search}`, {
157
+ maxRedirects: 0,
158
+ headers: { "sec-fetch-dest": "document" },
159
+ });
160
+ expect(answer.status()).toBe(403);
161
+ expect(answer.headers()["set-cookie"] ?? "").not.toMatch(/porthole-[a-z2-7]{16}=/u);
162
+ },
163
+ );
164
+
165
+ define(
166
+ "served.entry.framed",
167
+ "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
+ async ({ page, browserName }, info) => {
169
+ await host.signIn(page);
170
+ const shown = await host.frame(page, host.url("entry-framed"));
171
+ const outcome = await shown.outcome();
172
+ const frame = await shown.frame();
173
+ expect(await host.atSignIn(page)).toBe(false);
174
+ expect(new URL(frame.url()).pathname).not.toMatch(/sign-?in/iu);
175
+ if (outcome.state === "loaded") {
176
+ await expect(frame.locator("#artifact")).toHaveText("version 1");
177
+ } else {
178
+ expect(outcome).toMatchObject({ state: "blocked" });
179
+ expect(["cookies", "sign-in"]).toContain((outcome as { readonly reason: string }).reason);
180
+ expect(await shows(frame)).toBe(false);
181
+ }
182
+ const said = outcome.state === "blocked" ? `blocked: ${outcome.reason}` : outcome.state;
183
+ info.annotations.push({
184
+ type: "measured",
185
+ description: `${browserName}: framed entry ${said}`,
186
+ });
187
+ },
188
+ );
189
+
190
+ define(
191
+ "served.entry.framed-signed-out",
192
+ "signed out, a clean address in a frame asks for a tab to sign in: the sign-in never shows in a frame",
193
+ async ({ page }) => {
194
+ await host.signIn(page);
195
+ // The app's page stays; the browser forgets every sign-in.
196
+ await page.context().clearCookies();
197
+ const shown = await host.frame(page, host.url("entry-framed-out"));
198
+ expect(await shown.outcome()).toMatchObject({ state: "blocked", reason: "sign-in" });
199
+ const frame = await shown.frame();
200
+ await expect(frame.locator("h1")).toHaveText("Open this in a new tab to sign in");
201
+ expect(await shows(frame)).toBe(false);
202
+ },
203
+ );
204
+
205
+ return tests;
206
+ }
@@ -0,0 +1,267 @@
1
+ import type { CapabilityHandlers, ConfirmRequest } from "../../bridge/types.ts";
2
+ import type { PortholeConfig } from "../../config/schema.ts";
3
+ import { createHost } from "../../host/create-host.ts";
4
+ import { httpLocator } from "../../host/locator.ts";
5
+ import type { DisplayEvents, DisplayHandle } from "../../host/types.ts";
6
+ import type { FrameLocation, InstanceLocator, InstanceRef } from "../../isolation/types.ts";
7
+ import type { LaunchSpec, RecordedCall, RecordedEvent } from "../index.ts";
8
+
9
+ /**
10
+ * The conformance harness (P066), injected into an app's real page under its nonce: framework-
11
+ * free, on the core's own mount (`createHost`), it shows each display the suite asks for (an
12
+ * opaque one with the suite's content; a served one located by the app's issue endpoint),
13
+ * records what reaches its handlers and holds each confirmation until the test answers it. Built
14
+ * into one script (`dist/conformance/harness.js`); `defineHostConformance` injects it.
15
+ */
16
+
17
+ interface Shown {
18
+ readonly handle: DisplayHandle;
19
+ readonly calls: RecordedCall[];
20
+ readonly events: RecordedEvent[];
21
+ readonly confirmations: { request: ConfirmRequest; resolve: (accepted: boolean) => void }[];
22
+ readonly received: unknown[];
23
+ origin: string | null;
24
+ /** Why the instance couldn't be located (the issue endpoint's refusal). */
25
+ failure: string | null;
26
+ frame: HTMLIFrameElement | null;
27
+ }
28
+
29
+ const EVENTS: readonly (keyof DisplayEvents)[] = [
30
+ "initialized",
31
+ "resize",
32
+ "error",
33
+ "contentError",
34
+ "hostError",
35
+ "navigated",
36
+ "refused",
37
+ "muted",
38
+ "outcome",
39
+ "annotation",
40
+ ];
41
+
42
+ const nonce =
43
+ document.currentScript instanceof HTMLScriptElement && document.currentScript.nonce !== ""
44
+ ? document.currentScript.nonce
45
+ : undefined;
46
+ const displays = new Map<number, Shown>();
47
+ let next = 0;
48
+ const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
49
+
50
+ /** The harness's own layer, over the app's page, so the app's UI never takes a click. */
51
+ function layer(): HTMLElement {
52
+ let element = document.getElementById("porthole-conformance");
53
+ if (element === null) {
54
+ element = document.createElement("main");
55
+ element.id = "porthole-conformance";
56
+ element.style.cssText =
57
+ "position:fixed;inset:0;overflow:auto;z-index:2147483000;background:#fff;color:#000;padding:8px";
58
+ document.body.append(element);
59
+ }
60
+ return element;
61
+ }
62
+
63
+ function box(id: number, widthPx: number, heightPx?: number): HTMLElement {
64
+ const element = document.createElement("section");
65
+ element.id = `porthole-display-${id}`;
66
+ element.style.width = `${widthPx}px`;
67
+ if (heightPx !== undefined) {
68
+ element.style.height = `${heightPx}px`;
69
+ }
70
+ layer().append(element);
71
+ return element;
72
+ }
73
+
74
+ function recorder(calls: RecordedCall[]): CapabilityHandlers {
75
+ const record =
76
+ <T>(capability: RecordedCall["capability"], value: T) =>
77
+ (args: unknown): T => {
78
+ calls.push({ capability, args });
79
+ return value;
80
+ };
81
+ return {
82
+ sendPrompt: record("sendPrompt", true),
83
+ openLink: record("openLink", true),
84
+ downloadFile: record("downloadFile", true),
85
+ callTool: record("callTool", { content: [] }),
86
+ readData: record("readData", { contents: [] }),
87
+ setModelContext: record("setModelContext", undefined),
88
+ copyText: record("copyText", true),
89
+ };
90
+ }
91
+
92
+ function watch(id: number, shown: Shown): void {
93
+ for (const type of EVENTS) {
94
+ shown.handle.on(type, (detail) => shown.events.push({ type, detail } as RecordedEvent));
95
+ }
96
+ displays.set(id, shown);
97
+ }
98
+
99
+ /** The frame's messages, from its window and its located origin only, as a host takes them. */
100
+ addEventListener("message", (event) => {
101
+ for (const shown of displays.values()) {
102
+ if (shown.frame?.contentWindow === event.source && event.origin === shown.origin) {
103
+ shown.received.push(event.data);
104
+ }
105
+ }
106
+ });
107
+
108
+ const harness = {
109
+ /** An opaque display with the suite's content (the leak suite's `launch`). */
110
+ async launch(spec: LaunchSpec): Promise<number> {
111
+ const id = next;
112
+ next += 1;
113
+ const container = box(id, spec.widthPx ?? 600, spec.heightPx);
114
+ const calls: RecordedCall[] = [];
115
+ const confirmations: Shown["confirmations"] = [];
116
+ const host = createHost(spec.config, {
117
+ handlers: recorder(calls),
118
+ ...(spec.confirms === false
119
+ ? {}
120
+ : {
121
+ confirm: (request: ConfirmRequest) =>
122
+ new Promise<boolean>((resolve) => confirmations.push({ request, resolve })),
123
+ }),
124
+ theme: (scheme) => spec.hostTheme?.[scheme] ?? {},
125
+ hostInfo: { name: "porthole-conformance", version: "1" },
126
+ ...(nonce === undefined ? {} : { nonce }),
127
+ });
128
+ const handle = host.mount(container, {
129
+ displayId: `display-${id}`,
130
+ title: spec.title ?? "Probe",
131
+ scheme: spec.scheme ?? "light",
132
+ ...(spec.document === undefined ? {} : { document: spec.document }),
133
+ });
134
+ const shown: Shown = {
135
+ handle,
136
+ calls,
137
+ events: [],
138
+ confirmations,
139
+ received: [],
140
+ origin: null,
141
+ failure: null,
142
+ frame: null,
143
+ };
144
+ const initialized = new Promise<void>((resolve) => handle.on("initialized", () => resolve()));
145
+ watch(id, shown);
146
+ if (spec.document === undefined) {
147
+ await Promise.race([initialized, pause(5_000)]);
148
+ for (const partial of spec.partials ?? []) {
149
+ handle.setContent(partial, { partial: true });
150
+ await pause(150);
151
+ }
152
+ if (spec.content !== undefined) {
153
+ handle.setContent(spec.content);
154
+ }
155
+ }
156
+ return id;
157
+ },
158
+
159
+ /**
160
+ * A served display, located by the app's issue endpoint (`issue`, from this page with its own
161
+ * cookies) or at a location the test got (`location`), with the suite's content for a shell.
162
+ */
163
+ show(
164
+ config: PortholeConfig,
165
+ instance: InstanceRef,
166
+ where:
167
+ | { readonly issue: string; readonly headers?: Readonly<Record<string, string>> }
168
+ | { readonly location: FrameLocation },
169
+ content?: string,
170
+ ): number {
171
+ const id = next;
172
+ next += 1;
173
+ const container = box(id, 600, 400);
174
+ const calls: RecordedCall[] = [];
175
+ const shown: Shown = {
176
+ handle: null as unknown as DisplayHandle,
177
+ calls,
178
+ events: [],
179
+ confirmations: [],
180
+ received: [],
181
+ origin: null,
182
+ failure: null,
183
+ frame: null,
184
+ };
185
+ const asked: InstanceLocator =
186
+ "issue" in where
187
+ ? httpLocator(where.issue, where.headers === undefined ? {} : { headers: where.headers })
188
+ : { locate: () => Promise.resolve(where.location) };
189
+ const locator: InstanceLocator = {
190
+ async locate(each, signal) {
191
+ try {
192
+ const location = await asked.locate(each, signal);
193
+ shown.origin = location.origin;
194
+ return location;
195
+ } catch (error) {
196
+ shown.failure =
197
+ error instanceof Error ? `${error.name}: ${error.message}` : String(error);
198
+ throw error;
199
+ }
200
+ },
201
+ };
202
+ const handle = createHost(config, {
203
+ handlers: recorder(calls),
204
+ locator,
205
+ hostInfo: { name: "porthole-conformance", version: "1" },
206
+ ...(nonce === undefined ? {} : { nonce }),
207
+ }).mount(container, {
208
+ displayId: `display-${id}`,
209
+ title: "Probe",
210
+ scheme: "light",
211
+ instance,
212
+ });
213
+ (shown as { handle: DisplayHandle }).handle = handle;
214
+ watch(id, shown);
215
+ // The frame element, once it exists, so its messages can be told from others'.
216
+ const found = () => {
217
+ shown.frame = container.querySelector("iframe");
218
+ if (shown.frame === null) {
219
+ requestAnimationFrame(found);
220
+ }
221
+ };
222
+ found();
223
+ if (content !== undefined) {
224
+ handle.setContent(content);
225
+ }
226
+ return id;
227
+ },
228
+
229
+ origin: (id: number) => displays.get(id)?.origin ?? null,
230
+ failure: (id: number) => displays.get(id)?.failure ?? null,
231
+ received: (id: number) => displays.get(id)?.received ?? [],
232
+ post(id: number, message: unknown) {
233
+ const shown = displays.get(id);
234
+ if (shown?.frame?.contentWindow && shown.origin !== null) {
235
+ shown.frame.contentWindow.postMessage(message, shown.origin);
236
+ }
237
+ },
238
+ setContent(id: number, html: string, partial: boolean) {
239
+ displays.get(id)?.handle.setContent(html, { partial });
240
+ },
241
+ calls: (id: number) => displays.get(id)?.calls ?? [],
242
+ events: (id: number) => displays.get(id)?.events ?? [],
243
+ errors: (id: number) =>
244
+ (displays.get(id)?.events ?? []).flatMap((event) =>
245
+ event.type === "error" ? [event.detail.message] : [],
246
+ ),
247
+ pendingConfirmations: (id: number) => displays.get(id)?.confirmations.length ?? 0,
248
+ answer(id: number, accept: boolean): ConfirmRequest | null {
249
+ const waiting = displays.get(id)?.confirmations.shift();
250
+ if (waiting === undefined) {
251
+ return null;
252
+ }
253
+ waiting.resolve(accept);
254
+ return waiting.request;
255
+ },
256
+ setTheme: (id: number, scheme: "light" | "dark") => displays.get(id)?.handle.setTheme(scheme),
257
+ destroy(id: number) {
258
+ displays.get(id)?.handle.destroy();
259
+ document.getElementById(`porthole-display-${id}`)?.remove();
260
+ displays.delete(id);
261
+ },
262
+ };
263
+
264
+ export type ConformanceHarness = typeof harness;
265
+
266
+ (window as unknown as { portholeConformance: ConformanceHarness }).portholeConformance = harness;
267
+ document.documentElement.dataset.portholeConformance = "ready";