@botiverse/porthole 0.0.1 → 0.1.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/LICENSE +217 -0
- package/dist/bridge/bridge.d.ts +39 -0
- package/dist/bridge/bridge.js +184 -0
- package/dist/bridge/types.d.ts +67 -0
- package/dist/bridge/types.js +1 -0
- package/dist/compile/compile.d.ts +35 -0
- package/dist/compile/compile.js +30 -0
- package/dist/compile/policy.d.ts +55 -0
- package/dist/compile/policy.js +106 -0
- package/dist/config/capabilities.d.ts +154 -0
- package/dist/config/capabilities.js +63 -0
- package/dist/config/schema.d.ts +131 -0
- package/dist/config/schema.js +1 -0
- package/dist/config/validate.d.ts +20 -0
- package/dist/config/validate.js +230 -0
- package/dist/conformance/behavior.d.ts +2 -0
- package/dist/conformance/behavior.js +376 -0
- package/dist/conformance/index.d.ts +95 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/kit.d.ts +42 -0
- package/dist/conformance/kit.js +24 -0
- package/dist/conformance/oracles.d.ts +35 -0
- package/dist/conformance/oracles.js +77 -0
- package/dist/conformance/probes.d.ts +9 -0
- package/dist/conformance/probes.js +865 -0
- package/dist/conformance/served.d.ts +68 -0
- package/dist/conformance/served.js +311 -0
- package/dist/guide/examples.d.ts +70 -0
- package/dist/guide/examples.js +69 -0
- package/dist/guide/guide.d.ts +16 -0
- package/dist/guide/guide.js +261 -0
- package/dist/host/create-host.d.ts +8 -0
- package/dist/host/create-host.js +248 -0
- package/dist/host/locate.d.ts +8 -0
- package/dist/host/locate.js +11 -0
- package/dist/host/types.d.ts +95 -0
- package/dist/host/types.js +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +19 -0
- package/dist/isolation/served.d.ts +32 -0
- package/dist/isolation/served.js +97 -0
- package/dist/isolation/types.d.ts +87 -0
- package/dist/isolation/types.js +1 -0
- package/dist/mcp/adapter.d.ts +43 -0
- package/dist/mcp/adapter.js +327 -0
- package/dist/mcp/index.d.ts +27 -0
- package/dist/mcp/index.js +25 -0
- package/dist/mcp/proxy.d.ts +25 -0
- package/dist/mcp/proxy.js +106 -0
- package/dist/mcp/view.d.ts +31 -0
- package/dist/mcp/view.js +72 -0
- package/dist/platform/types.d.ts +44 -0
- package/dist/platform/types.js +1 -0
- package/dist/platform/web.d.ts +8 -0
- package/dist/platform/web.js +72 -0
- package/dist/presets/presets.d.ts +65 -0
- package/dist/presets/presets.js +125 -0
- package/dist/protocol/adapter.d.ts +73 -0
- package/dist/protocol/adapter.js +1 -0
- package/dist/protocol/jsonrpc.d.ts +43 -0
- package/dist/protocol/jsonrpc.js +50 -0
- package/dist/protocol/mcp-apps.d.ts +79 -0
- package/dist/protocol/mcp-apps.js +99 -0
- package/dist/protocol/shell-adapter.d.ts +5 -0
- package/dist/protocol/shell-adapter.js +228 -0
- package/dist/react/dialog.d.ts +20 -0
- package/dist/react/dialog.js +98 -0
- package/dist/react/frame.d.ts +75 -0
- package/dist/react/frame.js +221 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.js +13 -0
- package/dist/react/live.d.ts +24 -0
- package/dist/react/live.js +57 -0
- package/dist/server/index.d.ts +27 -0
- package/dist/server/index.js +26 -0
- package/dist/server/keys.d.ts +19 -0
- package/dist/server/keys.js +78 -0
- package/dist/server/pages.d.ts +6 -0
- package/dist/server/pages.js +20 -0
- package/dist/server/proxy.d.ts +21 -0
- package/dist/server/proxy.js +107 -0
- package/dist/server/server.d.ts +7 -0
- package/dist/server/server.js +518 -0
- package/dist/server/tickets.d.ts +8 -0
- package/dist/server/tickets.js +28 -0
- package/dist/server/types.d.ts +97 -0
- package/dist/server/types.js +1 -0
- package/dist/shell/document.d.ts +28 -0
- package/dist/shell/document.js +84 -0
- package/dist/shell/plan.d.ts +35 -0
- package/dist/shell/plan.js +34 -0
- package/dist/shell/runtime.d.ts +28 -0
- package/dist/shell/runtime.js +832 -0
- package/dist/theme/variables.d.ts +73 -0
- package/dist/theme/variables.js +171 -0
- package/dist/transport/post-message.d.ts +9 -0
- package/dist/transport/post-message.js +38 -0
- package/dist/transport/types.d.ts +15 -0
- package/dist/transport/types.js +1 -0
- package/package.json +71 -1
- package/src/bridge/bridge.ts +256 -0
- package/src/bridge/types.ts +77 -0
- package/src/compile/compile.ts +68 -0
- package/src/compile/policy.ts +133 -0
- package/src/config/capabilities.ts +138 -0
- package/src/config/schema.ts +140 -0
- package/src/config/validate.ts +284 -0
- package/src/conformance/behavior.ts +430 -0
- package/src/conformance/index.ts +102 -0
- package/src/conformance/kit.ts +63 -0
- package/src/conformance/oracles.ts +126 -0
- package/src/conformance/probes.ts +1093 -0
- package/src/conformance/served.ts +472 -0
- package/src/guide/examples.ts +74 -0
- package/src/guide/guide.ts +424 -0
- package/src/host/create-host.ts +290 -0
- package/src/host/locate.ts +20 -0
- package/src/host/types.ts +95 -0
- package/src/index.ts +118 -0
- package/src/isolation/served.ts +127 -0
- package/src/isolation/types.ts +77 -0
- package/src/mcp/adapter.ts +441 -0
- package/src/mcp/index.ts +50 -0
- package/src/mcp/proxy.ts +117 -0
- package/src/mcp/view.ts +97 -0
- package/src/platform/types.ts +48 -0
- package/src/platform/web.ts +78 -0
- package/src/presets/presets.ts +193 -0
- package/src/protocol/adapter.ts +67 -0
- package/src/protocol/jsonrpc.ts +83 -0
- package/src/protocol/mcp-apps.ts +138 -0
- package/src/protocol/shell-adapter.ts +261 -0
- package/src/react/dialog.tsx +158 -0
- package/src/react/frame.tsx +422 -0
- package/src/react/index.ts +20 -0
- package/src/react/live.ts +76 -0
- package/src/server/index.ts +37 -0
- package/src/server/keys.ts +100 -0
- package/src/server/pages.ts +31 -0
- package/src/server/proxy.ts +131 -0
- package/src/server/server.ts +617 -0
- package/src/server/tickets.ts +30 -0
- package/src/server/types.ts +104 -0
- package/src/shell/document.ts +120 -0
- package/src/shell/plan.ts +66 -0
- package/src/shell/runtime.ts +832 -0
- package/src/theme/variables.ts +190 -0
- package/src/transport/post-message.ts +45 -0
- package/src/transport/types.ts +16 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The served modes' conformance cases (P016, P020): domain mode and url mode, probed in a real
|
|
3
|
+
* browser against a host's own serving. A host implements `ServedConformanceHost` (show an
|
|
4
|
+
* instance the way it does: located through its backend, framed under the config's policy), then
|
|
5
|
+
* registers the cases in its Playwright project:
|
|
6
|
+
*
|
|
7
|
+
* import { test } from "@playwright/test";
|
|
8
|
+
* import { defineServedSuite } from "@botiverse/porthole/conformance";
|
|
9
|
+
* defineServedSuite(test, myServedHost);
|
|
10
|
+
*
|
|
11
|
+
* Each case attempts one escape or access channel and asserts, with an out-of-band oracle where
|
|
12
|
+
* one exists (a canary URL the host counts requests to), that nothing got through.
|
|
13
|
+
*/
|
|
14
|
+
import { expect, type Frame, type Page, type TestType } from "@playwright/test";
|
|
15
|
+
import type { EgressConfig } from "../config/schema.ts";
|
|
16
|
+
import type { FrameLocation, InstanceRef } from "../isolation/types.ts";
|
|
17
|
+
import { MCP_APPS_METHODS } from "../protocol/mcp-apps.ts";
|
|
18
|
+
|
|
19
|
+
/** One served display, as the host shows it. */
|
|
20
|
+
export interface ServedDisplay {
|
|
21
|
+
/** The content origin the frame was located on. */
|
|
22
|
+
readonly origin: string;
|
|
23
|
+
/** The display's frame, once its document loaded. */
|
|
24
|
+
frame(): Promise<Frame>;
|
|
25
|
+
/** Removes the frame. */
|
|
26
|
+
destroy(): Promise<void>;
|
|
27
|
+
/** Posts `message` to the frame as the host does (to the frame's window, at its origin). */
|
|
28
|
+
post(message: unknown): Promise<void>;
|
|
29
|
+
/** The messages the host took from the frame so far (from its window and origin only). */
|
|
30
|
+
received(): Promise<readonly unknown[]>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What a host implements to run the served-mode cases against its integration. */
|
|
34
|
+
export interface ServedConformanceHost {
|
|
35
|
+
readonly name: string;
|
|
36
|
+
/**
|
|
37
|
+
* Shows `instance` in `page` as the host does: a fresh ticket from its backend, framed under
|
|
38
|
+
* the config's sandbox, in an embedding page that sends the config's `embedderCsp`. The same page
|
|
39
|
+
* may show several, one after another.
|
|
40
|
+
*/
|
|
41
|
+
show(page: Page, instance: InstanceRef): Promise<ServedDisplay>;
|
|
42
|
+
/** A fresh ticketed location for `instance`, as the host's backend issues it. */
|
|
43
|
+
locate(instance: InstanceRef): Promise<FrameLocation>;
|
|
44
|
+
/** Navigates `page` to a page on an origin that is not one of the host's, framing `url`. */
|
|
45
|
+
frameFromForeignOrigin(page: Page, url: string): Promise<void>;
|
|
46
|
+
/** A URL whose requests the host counts, on an origin that is neither the host's nor content's. */
|
|
47
|
+
canary(name: string): Promise<string>;
|
|
48
|
+
/** How many requests reached `canary(name)`. */
|
|
49
|
+
canaryHits(name: string): Promise<number>;
|
|
50
|
+
/** A served shell (domain isolation) for `scope`. */
|
|
51
|
+
shell(scope: string): InstanceRef;
|
|
52
|
+
/**
|
|
53
|
+
* A url-mode app for `scope`: its page loads a script from its own origin that sets
|
|
54
|
+
* `document.documentElement.dataset.app = "ready"`.
|
|
55
|
+
*/
|
|
56
|
+
app(scope: string): InstanceRef;
|
|
57
|
+
/**
|
|
58
|
+
* A stored artifact page (domain isolation) for `scope`: version `"1"` and `"2"` of one page,
|
|
59
|
+
* each with `<main id="artifact">version N</main>` and a script that records
|
|
60
|
+
* `document.documentElement.dataset.rtc = typeof RTCPeerConnection`.
|
|
61
|
+
*/
|
|
62
|
+
artifact(scope: string, version: "1" | "2"): InstanceRef;
|
|
63
|
+
/** The MCP Apps sandbox proxy (domain isolation) for `scope`, opening the view's `egress`. */
|
|
64
|
+
proxy(scope: string, egress?: EgressConfig): InstanceRef;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ServedSuiteOptions {
|
|
68
|
+
/** Case ids to skip, each with the reason (shown in the report). */
|
|
69
|
+
readonly skip?: Readonly<Record<string, string>>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type Fixtures = { page: Page; browserName: string };
|
|
73
|
+
|
|
74
|
+
const SETTLE_MS = 800;
|
|
75
|
+
|
|
76
|
+
/** What the frame can see of its host: everything here must be closed to it. */
|
|
77
|
+
async function reachIntoHost(frame: Frame): Promise<Record<string, string>> {
|
|
78
|
+
return frame.evaluate(() => {
|
|
79
|
+
const attempt = (read: () => string | null | undefined): string => {
|
|
80
|
+
try {
|
|
81
|
+
const value = read();
|
|
82
|
+
return value === undefined || value === null ? "empty" : `read: ${value}`;
|
|
83
|
+
} catch {
|
|
84
|
+
return "blocked";
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
parentDocument: attempt(() => window.parent.document.title),
|
|
89
|
+
topLocation: attempt(() => window.top?.location.href),
|
|
90
|
+
parentStorage: attempt(() => window.parent.localStorage.getItem("porthole-probe")),
|
|
91
|
+
ownStorage: attempt(() => window.localStorage.getItem("porthole-probe")),
|
|
92
|
+
cookie: attempt(() => (document.cookie.includes("porthole-probe") ? document.cookie : null)),
|
|
93
|
+
referrer: attempt(() => document.referrer || null),
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const CLOSED = {
|
|
99
|
+
parentDocument: "blocked",
|
|
100
|
+
topLocation: "blocked",
|
|
101
|
+
parentStorage: "blocked",
|
|
102
|
+
ownStorage: "empty",
|
|
103
|
+
cookie: "empty",
|
|
104
|
+
referrer: "empty",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A view as an MCP Apps server would send it, speaking the protocol by hand: it asks its parent to
|
|
109
|
+
* initialize, and reports what its realm offers and whether a fetch got out.
|
|
110
|
+
*/
|
|
111
|
+
const PROBE_VIEW = (canary: string) => `<!doctype html><html><head><title>View</title></head><body>
|
|
112
|
+
<p id="view">A view.</p>
|
|
113
|
+
<script>
|
|
114
|
+
document.documentElement.dataset.rtc = typeof RTCPeerConnection;
|
|
115
|
+
window.parent.postMessage({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: { protocolVersion: "2026-01-26", appInfo: { name: "probe", version: "1" }, appCapabilities: {} } }, "*");
|
|
116
|
+
fetch(${JSON.stringify(canary)}, { mode: "no-cors" }).then(
|
|
117
|
+
() => { document.documentElement.dataset.fetch = "sent"; },
|
|
118
|
+
() => { document.documentElement.dataset.fetch = "blocked"; },
|
|
119
|
+
);
|
|
120
|
+
</script></body></html>`;
|
|
121
|
+
|
|
122
|
+
const methodsOf = (messages: readonly unknown[]): string[] =>
|
|
123
|
+
messages.flatMap((message) =>
|
|
124
|
+
message !== null &&
|
|
125
|
+
typeof message === "object" &&
|
|
126
|
+
typeof (message as { method?: unknown }).method === "string"
|
|
127
|
+
? [(message as { method: string }).method]
|
|
128
|
+
: [],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
/** Marks the host page with secrets the content must not see. */
|
|
132
|
+
async function plantHostSecrets(page: Page): Promise<void> {
|
|
133
|
+
await page.evaluate(() => {
|
|
134
|
+
localStorage.setItem("porthole-probe", "host-secret");
|
|
135
|
+
document.cookie = "porthole-probe=host-secret; Path=/";
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Registers every served-mode case as a test. */
|
|
140
|
+
export function defineServedSuite(
|
|
141
|
+
test: TestType<object, object>,
|
|
142
|
+
host: ServedConformanceHost,
|
|
143
|
+
options: ServedSuiteOptions = {},
|
|
144
|
+
): void {
|
|
145
|
+
const typed = test as unknown as TestType<Fixtures, object>;
|
|
146
|
+
const define = (id: string, title: string, body: (fixtures: Fixtures) => Promise<void>) => {
|
|
147
|
+
const reason = options.skip?.[id];
|
|
148
|
+
// Playwright reads the fixtures a test uses from its parameter's destructuring.
|
|
149
|
+
typed(`${id}: ${title}`, async ({ page, browserName }) => {
|
|
150
|
+
typed.skip(reason !== undefined, reason);
|
|
151
|
+
await body({ page, browserName });
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
typed.describe(`served modes (${host.name})`, () => {
|
|
156
|
+
const modes = [
|
|
157
|
+
["domain", (scope: string) => host.shell(scope)],
|
|
158
|
+
["url", (scope: string) => host.app(scope)],
|
|
159
|
+
] as const;
|
|
160
|
+
|
|
161
|
+
for (const [mode, instance] of modes) {
|
|
162
|
+
define(
|
|
163
|
+
`served.${mode}.origin`,
|
|
164
|
+
"content runs on its own origin, never the host's",
|
|
165
|
+
async ({ page }) => {
|
|
166
|
+
const display = await host.show(page, instance(`${mode}-origin`));
|
|
167
|
+
const frame = await display.frame();
|
|
168
|
+
const origin = await frame.evaluate(() => window.origin);
|
|
169
|
+
expect(origin).toBe(display.origin);
|
|
170
|
+
expect(origin).not.toBe(new URL(page.url()).origin);
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
define(
|
|
175
|
+
`served.${mode}.no-reach`,
|
|
176
|
+
"content can't reach the host's DOM, storage, cookies or URL",
|
|
177
|
+
async ({ page }) => {
|
|
178
|
+
const display = await host.show(page, instance(`${mode}-reach`));
|
|
179
|
+
await plantHostSecrets(page);
|
|
180
|
+
expect(await reachIntoHost(await display.frame())).toEqual(CLOSED);
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
define(
|
|
185
|
+
`served.${mode}.no-top-navigation`,
|
|
186
|
+
"content can't navigate the host's page, even after a click",
|
|
187
|
+
async ({ page }) => {
|
|
188
|
+
const display = await host.show(page, instance(`${mode}-top`));
|
|
189
|
+
const frame = await display.frame();
|
|
190
|
+
const before = page.url();
|
|
191
|
+
const target = await host.canary(`${mode}-top`);
|
|
192
|
+
await frame.evaluate((url) => {
|
|
193
|
+
const button = document.createElement("button");
|
|
194
|
+
button.id = "porthole-probe-top";
|
|
195
|
+
button.textContent = "go";
|
|
196
|
+
button.addEventListener("click", () => {
|
|
197
|
+
try {
|
|
198
|
+
window.top!.location.href = url;
|
|
199
|
+
} catch {
|
|
200
|
+
// Blocked: what we want.
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
document.body.append(button);
|
|
204
|
+
try {
|
|
205
|
+
window.top!.location.href = url;
|
|
206
|
+
} catch {
|
|
207
|
+
// Blocked.
|
|
208
|
+
}
|
|
209
|
+
}, target);
|
|
210
|
+
await frame.click("#porthole-probe-top");
|
|
211
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
212
|
+
expect(page.url()).toBe(before);
|
|
213
|
+
expect(await host.canaryHits(`${mode}-top`)).toBe(0);
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
define(
|
|
218
|
+
`served.${mode}.no-popups`,
|
|
219
|
+
"content can't open a window, even after a click",
|
|
220
|
+
async ({ page }) => {
|
|
221
|
+
const display = await host.show(page, instance(`${mode}-popup`));
|
|
222
|
+
const frame = await display.frame();
|
|
223
|
+
const target = await host.canary(`${mode}-popup`);
|
|
224
|
+
const opened: Page[] = [];
|
|
225
|
+
page.context().on("page", (each) => opened.push(each));
|
|
226
|
+
const programmatic = await frame.evaluate((url) => {
|
|
227
|
+
const button = document.createElement("button");
|
|
228
|
+
button.id = "porthole-probe-popup";
|
|
229
|
+
button.textContent = "open";
|
|
230
|
+
// Without allow-popups, Chromium and WebKit return null; Firefox throws. Both refuse.
|
|
231
|
+
const open = () => {
|
|
232
|
+
try {
|
|
233
|
+
return window.open(url) !== null;
|
|
234
|
+
} catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
button.addEventListener("click", () => {
|
|
239
|
+
document.body.dataset.popup = String(open());
|
|
240
|
+
});
|
|
241
|
+
document.body.append(button);
|
|
242
|
+
return open();
|
|
243
|
+
}, target);
|
|
244
|
+
await frame.click("#porthole-probe-popup");
|
|
245
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
246
|
+
expect(programmatic).toBe(false);
|
|
247
|
+
expect(await frame.evaluate(() => document.body.dataset.popup)).toBe("false");
|
|
248
|
+
expect(opened).toHaveLength(0);
|
|
249
|
+
expect(await host.canaryHits(`${mode}-popup`)).toBe(0);
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
define(
|
|
255
|
+
"served.domain.storage-scope",
|
|
256
|
+
"storage persists within a scope and is never shared across scopes",
|
|
257
|
+
async ({ page }) => {
|
|
258
|
+
const first = await host.show(page, host.shell("scope-a"));
|
|
259
|
+
await (await first.frame()).evaluate(() => localStorage.setItem("kept", "a"));
|
|
260
|
+
await first.destroy();
|
|
261
|
+
const again = await host.show(page, host.shell("scope-a"));
|
|
262
|
+
expect(await (await again.frame()).evaluate(() => localStorage.getItem("kept"))).toBe("a");
|
|
263
|
+
expect(again.origin).toBe(first.origin);
|
|
264
|
+
const other = await host.show(page, host.shell("scope-b"));
|
|
265
|
+
expect(other.origin).not.toBe(first.origin);
|
|
266
|
+
expect(await (await other.frame()).evaluate(() => localStorage.getItem("kept"))).toBeNull();
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
define(
|
|
271
|
+
"served.artifact.versions",
|
|
272
|
+
"each version of a page shows as stored, on its scope's origin, hardened before it runs",
|
|
273
|
+
async ({ page }) => {
|
|
274
|
+
const one = await host.show(page, host.artifact("artifact-scope", "1"));
|
|
275
|
+
const two = await host.show(page, host.artifact("artifact-scope", "2"));
|
|
276
|
+
expect(two.origin).toBe(one.origin);
|
|
277
|
+
for (const [display, version] of [
|
|
278
|
+
[one, "1"],
|
|
279
|
+
[two, "2"],
|
|
280
|
+
] as const) {
|
|
281
|
+
const frame = await display.frame();
|
|
282
|
+
await expect(frame.locator("#artifact")).toHaveText(`version ${version}`);
|
|
283
|
+
expect(await frame.evaluate(() => document.documentElement.dataset.rtc)).toBe(
|
|
284
|
+
"undefined",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
define(
|
|
291
|
+
"served.domain.ticket-once",
|
|
292
|
+
"a ticket opens its content once; without it there is nothing",
|
|
293
|
+
async ({ page, browserName }) => {
|
|
294
|
+
const location = await host.locate(host.shell("ticket-once"));
|
|
295
|
+
const first = await page.goto(location.url);
|
|
296
|
+
expect(first?.status()).toBe(200);
|
|
297
|
+
// A fresh browser state: no cookie, only the used ticket.
|
|
298
|
+
const fresh = await page.context().browser()!.newContext();
|
|
299
|
+
try {
|
|
300
|
+
const other = await fresh.newPage();
|
|
301
|
+
const again = await other.goto(location.url);
|
|
302
|
+
expect(again?.status(), browserName).toBe(401);
|
|
303
|
+
expect(await other.locator("#porthole-root").count()).toBe(0);
|
|
304
|
+
} finally {
|
|
305
|
+
await fresh.close();
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
define(
|
|
311
|
+
"served.domain.frame-ancestors",
|
|
312
|
+
"only the host's origins may frame content",
|
|
313
|
+
async ({ page }) => {
|
|
314
|
+
const location = await host.locate(host.shell("foreign"));
|
|
315
|
+
await host.frameFromForeignOrigin(page, location.url);
|
|
316
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
317
|
+
for (const frame of page.frames().slice(1)) {
|
|
318
|
+
// A refused frame shows the engine's error page, which Firefox never lets a script
|
|
319
|
+
// finish in: a frame that doesn't answer in time showed nothing of the content.
|
|
320
|
+
const unreachable = { origin: "unreachable", root: false };
|
|
321
|
+
const shown = await Promise.race([
|
|
322
|
+
frame
|
|
323
|
+
.evaluate(() => ({
|
|
324
|
+
origin: window.origin,
|
|
325
|
+
root: document.getElementById("porthole-root") !== null,
|
|
326
|
+
}))
|
|
327
|
+
.catch(() => unreachable),
|
|
328
|
+
new Promise<typeof unreachable>((resolve) =>
|
|
329
|
+
setTimeout(() => resolve(unreachable), 3_000),
|
|
330
|
+
),
|
|
331
|
+
]);
|
|
332
|
+
expect(shown.root).toBe(false);
|
|
333
|
+
expect(shown.origin).not.toBe(location.origin);
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
define(
|
|
339
|
+
"served.domain.egress",
|
|
340
|
+
"the CSP in the document's headers closes egress",
|
|
341
|
+
async ({ page }) => {
|
|
342
|
+
const display = await host.show(page, host.shell("egress"));
|
|
343
|
+
const frame = await display.frame();
|
|
344
|
+
const fetched = await frame.evaluate(
|
|
345
|
+
async (url) => {
|
|
346
|
+
try {
|
|
347
|
+
await fetch(url, { mode: "no-cors" });
|
|
348
|
+
return "sent";
|
|
349
|
+
} catch {
|
|
350
|
+
return "blocked";
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
await host.canary("egress-fetch"),
|
|
354
|
+
);
|
|
355
|
+
await frame.evaluate(
|
|
356
|
+
(url) => {
|
|
357
|
+
const image = new Image();
|
|
358
|
+
image.src = url;
|
|
359
|
+
document.body.append(image);
|
|
360
|
+
},
|
|
361
|
+
await host.canary("egress-img"),
|
|
362
|
+
);
|
|
363
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
364
|
+
expect(fetched).toBe("blocked");
|
|
365
|
+
expect(await host.canaryHits("egress-fetch")).toBe(0);
|
|
366
|
+
expect(await host.canaryHits("egress-img")).toBe(0);
|
|
367
|
+
},
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const resourceReady = (html: string) => ({
|
|
371
|
+
jsonrpc: "2.0",
|
|
372
|
+
method: MCP_APPS_METHODS.sandboxResourceReady,
|
|
373
|
+
params: { html },
|
|
374
|
+
});
|
|
375
|
+
const proxyReady = async (display: ServedDisplay) =>
|
|
376
|
+
expect
|
|
377
|
+
.poll(async () => methodsOf(await display.received()))
|
|
378
|
+
.toContain(MCP_APPS_METHODS.sandboxProxyReady);
|
|
379
|
+
|
|
380
|
+
define(
|
|
381
|
+
"served.proxy.view",
|
|
382
|
+
"the sandbox proxy becomes the view, hardened, talking to its host directly",
|
|
383
|
+
async ({ page }) => {
|
|
384
|
+
const display = await host.show(page, host.proxy("proxy-view"));
|
|
385
|
+
await proxyReady(display);
|
|
386
|
+
const canary = await host.canary("proxy-undeclared");
|
|
387
|
+
await display.post(resourceReady(PROBE_VIEW(canary)));
|
|
388
|
+
const frame = await display.frame();
|
|
389
|
+
await expect(frame.locator("#view")).toHaveText("A view.");
|
|
390
|
+
await expect
|
|
391
|
+
.poll(async () => methodsOf(await display.received()))
|
|
392
|
+
.toContain(MCP_APPS_METHODS.initialize);
|
|
393
|
+
await expect(frame.locator("html")).toHaveAttribute("data-fetch", "blocked");
|
|
394
|
+
expect(
|
|
395
|
+
await frame.evaluate(() => [window.origin, document.documentElement.dataset.rtc]),
|
|
396
|
+
).toEqual([display.origin, "undefined"]);
|
|
397
|
+
expect(await host.canaryHits("proxy-undeclared")).toBe(0);
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
define(
|
|
402
|
+
"served.proxy.parent-only",
|
|
403
|
+
"the sandbox proxy takes a view only from its host",
|
|
404
|
+
async ({ page }) => {
|
|
405
|
+
const display = await host.show(page, host.proxy("proxy-parent"));
|
|
406
|
+
await proxyReady(display);
|
|
407
|
+
const frame = await display.frame();
|
|
408
|
+
await frame.evaluate(
|
|
409
|
+
(message) => window.postMessage(message, "*"),
|
|
410
|
+
resourceReady('<p id="view">Not from the host.</p>'),
|
|
411
|
+
);
|
|
412
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
413
|
+
expect(await frame.locator("#view").count()).toBe(0);
|
|
414
|
+
},
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
define(
|
|
418
|
+
"served.proxy.egress",
|
|
419
|
+
"the sandbox proxy opens what the view declares, and nothing else",
|
|
420
|
+
async ({ page }) => {
|
|
421
|
+
const canary = await host.canary("proxy-declared");
|
|
422
|
+
const display = await host.show(
|
|
423
|
+
page,
|
|
424
|
+
host.proxy("proxy-egress", { connect: [new URL(canary).origin] }),
|
|
425
|
+
);
|
|
426
|
+
await proxyReady(display);
|
|
427
|
+
await display.post(resourceReady(PROBE_VIEW(canary)));
|
|
428
|
+
const frame = await display.frame();
|
|
429
|
+
await expect(frame.locator("html")).toHaveAttribute("data-fetch", "sent");
|
|
430
|
+
expect(await host.canaryHits("proxy-declared")).toBe(1);
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
define(
|
|
435
|
+
"served.url.ticket",
|
|
436
|
+
"an app shows nothing to a browser without its ticket's cookie",
|
|
437
|
+
async ({ page }) => {
|
|
438
|
+
const location = await host.locate(host.app("url-ticket"));
|
|
439
|
+
const bare = await page.goto(`${location.origin}/`);
|
|
440
|
+
expect(bare?.status()).toBe(401);
|
|
441
|
+
expect(await page.evaluate(() => document.documentElement.dataset.app ?? null)).toBeNull();
|
|
442
|
+
},
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
define(
|
|
446
|
+
"served.url.tab",
|
|
447
|
+
"an app opened on a tab of its own runs, its requests riding on its cookie",
|
|
448
|
+
async ({ page }) => {
|
|
449
|
+
const location = await host.locate(host.app("url-tab"));
|
|
450
|
+
await page.goto(location.url);
|
|
451
|
+
await expect(page.locator("html")).toHaveAttribute("data-app", "ready");
|
|
452
|
+
},
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
define(
|
|
456
|
+
"served.url.framed",
|
|
457
|
+
"a framed app's own requests ride on its partitioned cookie",
|
|
458
|
+
async ({ page, browserName }) => {
|
|
459
|
+
// Measured (P025): WebKit keeps no cookie in a cross-site frame, partitioned or not, so a
|
|
460
|
+
// framed app's subresources are refused there; such hosts open the app on a tab.
|
|
461
|
+
typed.fail(browserName === "webkit", "WebKit keeps no cookie in a cross-site frame");
|
|
462
|
+
const display = await host.show(page, host.app("url-framed"));
|
|
463
|
+
const frame = await display.frame();
|
|
464
|
+
await expect
|
|
465
|
+
.poll(() => frame.evaluate(() => document.documentElement.dataset.app ?? null), {
|
|
466
|
+
timeout: 3_000,
|
|
467
|
+
})
|
|
468
|
+
.toBe("ready");
|
|
469
|
+
},
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The manual's examples, each a whole widget. They use only the default theme's variables and no
|
|
3
|
+
* network, so they render under every preset; the leak suite renders each through the real
|
|
4
|
+
* sandbox at phone and desktop widths, in light and dark, and measures its text (the examples are
|
|
5
|
+
* conformance cases, PRINCIPLE 10).
|
|
6
|
+
*/
|
|
7
|
+
export const GUIDE_EXAMPLES = {
|
|
8
|
+
/** Stat cards that wrap: three across on a desktop, fewer on a phone. */
|
|
9
|
+
stats: `<style>
|
|
10
|
+
.stats { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
11
|
+
.stat { flex: 1 1 100px; background: var(--color-background-secondary); border: var(--border-width-regular) solid var(--color-border-tertiary); border-radius: var(--border-radius-lg); padding: 12px 16px; }
|
|
12
|
+
.stat div { font-size: var(--font-text-sm-size); color: var(--color-text-secondary); }
|
|
13
|
+
.stat b { font-size: var(--font-heading-md-size); font-weight: var(--font-weight-semibold); }
|
|
14
|
+
</style>
|
|
15
|
+
<div class="stats">
|
|
16
|
+
<div class="stat"><div>Monthly cost</div><b>$1,240</b></div>
|
|
17
|
+
<div class="stat"><div>Failed builds</div><b>2.4%</b></div>
|
|
18
|
+
</div>`,
|
|
19
|
+
|
|
20
|
+
/** A diagram drawn for the phone: top to bottom, two boxes a row at most, scaled at most 1.25×. */
|
|
21
|
+
diagram: `<div style="font-weight: var(--font-weight-semibold); margin-bottom: 8px">Uploads reach storage only through the API</div>
|
|
22
|
+
<svg viewBox="0 0 320 260" width="100%" style="max-width: 400px" font-size="13" role="img" aria-label="Uploads reach storage only through the API">
|
|
23
|
+
<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="var(--color-text-tertiary)"/></marker></defs>
|
|
24
|
+
<g fill="none" stroke="var(--color-border-secondary)" stroke-width="1.25"><rect x="16" y="8" width="136" height="52" rx="8"/><rect x="168" y="8" width="136" height="52" rx="8"/><rect x="16" y="200" width="136" height="52" rx="8"/><rect x="168" y="200" width="136" height="52" rx="8"/></g>
|
|
25
|
+
<rect x="16" y="104" width="288" height="52" rx="8" fill="var(--color-chart-1)" fill-opacity="0.12" stroke="var(--color-chart-1)" stroke-width="2"/>
|
|
26
|
+
<g fill="none" stroke="var(--color-text-tertiary)" stroke-width="1.25" marker-end="url(#arrow)"><path d="M84 60V102"/><path d="M236 60V102"/><path d="M84 156V198"/><path d="M236 156V198"/></g>
|
|
27
|
+
<g text-anchor="middle" font-size="14" font-weight="600" fill="var(--color-text-primary)"><text x="84" y="30">Phone app</text><text x="236" y="30">Web app</text><text x="160" y="126">API server</text><text x="84" y="222">Object storage</text><text x="236" y="222">Database</text></g>
|
|
28
|
+
<g text-anchor="middle" fill="var(--color-text-secondary)"><text x="84" y="48">iOS, Android</text><text x="236" y="48">any browser</text><text x="160" y="144">checks every upload</text><text x="84" y="240">the files</text><text x="236" y="240">who sent what</text></g>
|
|
29
|
+
<g fill="var(--color-text-secondary)"><text x="92" y="86">HTTPS</text><text x="244" y="86">HTTPS</text><text x="92" y="182">writes</text><text x="244" y="182">records</text></g>
|
|
30
|
+
</svg>`,
|
|
31
|
+
|
|
32
|
+
/** A line chart drawn at the frame's width (1 unit = 1 px), its numbers on a tap. */
|
|
33
|
+
line: `<style>
|
|
34
|
+
.note { font-size: var(--font-text-sm-size); color: var(--color-text-secondary); }
|
|
35
|
+
</style>
|
|
36
|
+
<div style="font-weight: var(--font-weight-semibold)">Failed builds fell from 9 to 2 a week</div>
|
|
37
|
+
<div class="note">Builds per week, last 8 weeks</div>
|
|
38
|
+
<div class="note"><b style="color: var(--color-chart-1)">━</b> Failed <b style="color: var(--color-text-disabled)">━</b> All builds</div>
|
|
39
|
+
<div id="plot" style="height: 200px"></div>
|
|
40
|
+
<div class="note" id="readout"></div>
|
|
41
|
+
<script>
|
|
42
|
+
const rows = [["W1", 41, 9], ["W2", 44, 8], ["W3", 39, 7], ["W4", 47, 6], ["W5", 52, 4], ["W6", 49, 3], ["W7", 55, 3], ["W8", 58, 2]];
|
|
43
|
+
const series = [[1, "builds", "var(--color-text-disabled)"], [2, "failed", "var(--color-chart-1)"]];
|
|
44
|
+
const plot = document.getElementById("plot");
|
|
45
|
+
const show = (i) => (document.getElementById("readout").textContent = \`\${rows[i][0]}: \${series.map(([at, name]) => \`\${rows[i][at]} \${name}\`).join(", ")}\`);
|
|
46
|
+
function draw() {
|
|
47
|
+
const w = plot.clientWidth, h = plot.clientHeight, left = 36, base = h - 24;
|
|
48
|
+
const x = (i) => left + 12 + (i * (w - left - 36)) / (rows.length - 1);
|
|
49
|
+
const y = (v) => base - (v / 60) * (base - 8);
|
|
50
|
+
const step = Math.ceil((rows.length * 44) / w); // x labels at least 44 px apart
|
|
51
|
+
let svg = \`<svg width="\${w}" height="\${h}" font-size="12" fill="var(--color-text-secondary)">\`;
|
|
52
|
+
for (const v of [0, 30, 60]) svg += \`<line x1="\${left}" x2="\${w}" y1="\${y(v)}" y2="\${y(v)}" stroke="var(--color-border-tertiary)"/><text x="\${left - 6}" y="\${y(v) + 4}" text-anchor="end">\${v}</text>\`;
|
|
53
|
+
rows.forEach((row, i) => { if (i % step === 0) svg += \`<text x="\${x(i)}" y="\${h - 6}" text-anchor="middle">\${row[0]}</text>\`; });
|
|
54
|
+
for (const [at, , color] of series) svg += \`<polyline points="\${rows.map((row, i) => \`\${x(i)},\${y(row[at])}\`).join(" ")}" fill="none" stroke="\${color}" stroke-width="2"/>\`;
|
|
55
|
+
// The two values the title rests on; then a column per week to tap.
|
|
56
|
+
for (const i of [0, rows.length - 1]) svg += \`<text x="\${x(i)}" y="\${y(rows[i][2]) - 10}" text-anchor="middle" fill="var(--color-text-primary)">\${rows[i][2]}</text>\`;
|
|
57
|
+
const band = (w - left) / rows.length;
|
|
58
|
+
rows.forEach((row, i) => (svg += \`<rect data-i="\${i}" x="\${x(i) - band / 2}" width="\${band}" height="\${base}" fill="transparent"/>\`));
|
|
59
|
+
plot.innerHTML = svg + "</svg>";
|
|
60
|
+
}
|
|
61
|
+
plot.addEventListener("click", (event) => event.target.dataset.i && show(Number(event.target.dataset.i)));
|
|
62
|
+
show(rows.length - 1);
|
|
63
|
+
new ResizeObserver(draw).observe(plot);
|
|
64
|
+
</script>`,
|
|
65
|
+
|
|
66
|
+
/** Horizontal bars in HTML: no SVG, so they read at any width. */
|
|
67
|
+
bars: `<div style="display: grid; grid-template-columns: 5em 1fr 3em; gap: 8px; align-items: center; font-size: var(--font-text-sm-size)">
|
|
68
|
+
<span>Web</span><div style="height: 12px; width: 100%; background: var(--color-chart-1)"></div><span>412</span>
|
|
69
|
+
<span>iOS</span><div style="height: 12px; width: 77%; background: var(--color-text-disabled)"></div><span>318</span>
|
|
70
|
+
<span>Docs</span><div style="height: 12px; width: 16%; background: var(--color-text-disabled)"></div><span>64</span>
|
|
71
|
+
</div>`,
|
|
72
|
+
} as const;
|
|
73
|
+
|
|
74
|
+
export type GuideExample = keyof typeof GUIDE_EXAMPLES;
|