@botiverse/porthole 0.1.3 → 0.1.5

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 (51) hide show
  1. package/dist/compile/policy.d.ts +10 -2
  2. package/dist/compile/policy.js +18 -4
  3. package/dist/config/schema.d.ts +20 -0
  4. package/dist/config/schema.js +2 -0
  5. package/dist/config/validate.js +174 -2
  6. package/dist/conformance/behavior.js +14 -1
  7. package/dist/conformance/kit.d.ts +5 -0
  8. package/dist/conformance/kit.js +11 -0
  9. package/dist/conformance/probes.js +19 -2
  10. package/dist/conformance/served.d.ts +8 -1
  11. package/dist/conformance/served.js +12 -8
  12. package/dist/guide/guide.d.ts +2 -0
  13. package/dist/guide/guide.js +22 -5
  14. package/dist/host/create-host.js +24 -7
  15. package/dist/host/types.d.ts +44 -2
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +2 -1
  18. package/dist/presets/presets.d.ts +3 -1
  19. package/dist/presets/presets.js +5 -0
  20. package/dist/react/frame.d.ts +34 -4
  21. package/dist/react/frame.js +91 -22
  22. package/dist/server/naming.d.ts +6 -0
  23. package/dist/server/naming.js +38 -0
  24. package/dist/server/pages.js +2 -0
  25. package/dist/server/server.js +53 -17
  26. package/dist/server/types.d.ts +6 -3
  27. package/dist/shell/annotate.js +31 -0
  28. package/dist/shell/document.d.ts +6 -0
  29. package/dist/shell/document.js +8 -2
  30. package/dist/shell/runtime.js +47 -2
  31. package/package.json +1 -1
  32. package/src/compile/policy.ts +22 -6
  33. package/src/config/schema.ts +23 -0
  34. package/src/config/validate.ts +193 -2
  35. package/src/conformance/behavior.ts +20 -1
  36. package/src/conformance/kit.ts +16 -0
  37. package/src/conformance/probes.ts +21 -2
  38. package/src/conformance/served.ts +24 -9
  39. package/src/guide/guide.ts +25 -4
  40. package/src/host/create-host.ts +26 -7
  41. package/src/host/types.ts +39 -2
  42. package/src/index.ts +4 -0
  43. package/src/presets/presets.ts +8 -0
  44. package/src/react/frame.tsx +136 -27
  45. package/src/server/naming.ts +41 -0
  46. package/src/server/pages.ts +2 -0
  47. package/src/server/server.ts +65 -20
  48. package/src/server/types.ts +6 -3
  49. package/src/shell/annotate.ts +31 -0
  50. package/src/shell/document.ts +15 -2
  51. package/src/shell/runtime.ts +47 -2
@@ -1,5 +1,10 @@
1
1
  import { CAPABILITY_NAMES, isCapabilityName, type Gate } from "./capabilities.ts";
2
- import { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, type PortholeConfig } from "./schema.ts";
2
+ import {
3
+ PASSIVE_KINDS,
4
+ URL_PERMISSIONS,
5
+ URL_SANDBOX_EXTRAS,
6
+ type PortholeConfig,
7
+ } from "./schema.ts";
3
8
 
4
9
  /**
5
10
  * Checks a config before anything is derived from it. Every string that ends up in a CSP, an
@@ -22,6 +27,155 @@ export class ConfigError extends Error {
22
27
  export const BUILT_IN_TOPICS = ["widget", "chart", "interactive"] as const;
23
28
 
24
29
  const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])$|\.localhost$/u;
30
+
31
+ /**
32
+ * Suffixes under which anyone can register or publish a name (a public suffix): a wildcard over
33
+ * one (`https://*.co.uk`, `https://*.github.io`) opens every stranger's site. Single labels (`com`,
34
+ * `dev`) are refused by rule; these are the common multi-label ones, registries' and hosting
35
+ * platforms'. Not the whole Public Suffix List: a wildcard should name the host's own domain.
36
+ */
37
+ const PUBLIC_SUFFIXES = new Set([
38
+ // Registries' second levels.
39
+ ...["co", "org", "ac", "gov", "me", "net", "ltd", "plc", "sch", "nhs", "police"].map(
40
+ (each) => `${each}.uk`,
41
+ ),
42
+ ...["com", "net", "org", "edu", "gov", "asn", "id"].map((each) => `${each}.au`),
43
+ ...["co", "org", "net", "ac", "govt", "geek", "school"].map((each) => `${each}.nz`),
44
+ ...["co", "ne", "or", "ac", "go", "ad", "ed", "gr", "lg"].map((each) => `${each}.jp`),
45
+ ...["co", "or", "ne", "ac", "go", "re"].map((each) => `${each}.kr`),
46
+ ...["com", "net", "org", "gov", "edu"].flatMap((each) =>
47
+ [
48
+ "br",
49
+ "cn",
50
+ "hk",
51
+ "tw",
52
+ "sg",
53
+ "my",
54
+ "mx",
55
+ "ar",
56
+ "tr",
57
+ "ua",
58
+ "pl",
59
+ "ph",
60
+ "pk",
61
+ "ng",
62
+ "eg",
63
+ "sa",
64
+ "vn",
65
+ "co",
66
+ ].map((country) => `${each}.${country}`),
67
+ ),
68
+ ...["co", "net", "org", "gen", "firm", "ind", "ac", "edu", "gov", "res"].map(
69
+ (each) => `${each}.in`,
70
+ ),
71
+ ...["co", "org", "ac", "gov", "net", "web"].map((each) => `${each}.za`),
72
+ ...["co", "org", "ac", "gov", "net", "muni"].map((each) => `${each}.il`),
73
+ "co.id",
74
+ "or.id",
75
+ "ac.id",
76
+ "co.th",
77
+ "or.th",
78
+ "ac.th",
79
+ "co.ke",
80
+ "co.tz",
81
+ "co.ug",
82
+ "com.de",
83
+ "co.at",
84
+ "or.at",
85
+ "ac.at",
86
+ "co.it",
87
+ "com.es",
88
+ "nom.es",
89
+ "com.fr",
90
+ "asso.fr",
91
+ "eu.org",
92
+ "us.com",
93
+ "uk.com",
94
+ "eu.com",
95
+ "uk.net",
96
+ "gb.net",
97
+ "jpn.com",
98
+ "kr.com",
99
+ // Hosting platforms: anyone gets a name under these.
100
+ "github.io",
101
+ "githubusercontent.com",
102
+ "gitlab.io",
103
+ "bitbucket.io",
104
+ "pages.dev",
105
+ "workers.dev",
106
+ "r2.dev",
107
+ "vercel.app",
108
+ "now.sh",
109
+ "netlify.app",
110
+ "netlify.com",
111
+ "herokuapp.com",
112
+ "herokussl.com",
113
+ "appspot.com",
114
+ "web.app",
115
+ "firebaseapp.com",
116
+ "cloudfunctions.net",
117
+ "run.app",
118
+ "googleusercontent.com",
119
+ "blogspot.com",
120
+ "azurewebsites.net",
121
+ "azurestaticapps.net",
122
+ "azureedge.net",
123
+ "blob.core.windows.net",
124
+ "cloudapp.net",
125
+ "trafficmanager.net",
126
+ "cloudfront.net",
127
+ "amazonaws.com",
128
+ "s3.amazonaws.com",
129
+ "elasticbeanstalk.com",
130
+ "awsapprunner.com",
131
+ "amplifyapp.com",
132
+ "fly.dev",
133
+ "onrender.com",
134
+ "railway.app",
135
+ "up.railway.app",
136
+ "glitch.me",
137
+ "repl.co",
138
+ "replit.app",
139
+ "replit.dev",
140
+ "surge.sh",
141
+ "deno.dev",
142
+ "ngrok.io",
143
+ "ngrok-free.app",
144
+ "ngrok.app",
145
+ "loca.lt",
146
+ "trycloudflare.com",
147
+ "codesandbox.io",
148
+ "csb.app",
149
+ "stackblitz.io",
150
+ "webcontainer.io",
151
+ "neocities.org",
152
+ "wordpress.com",
153
+ "tumblr.com",
154
+ "wixsite.com",
155
+ "squarespace.com",
156
+ "myshopify.com",
157
+ "carrd.co",
158
+ "notion.site",
159
+ "framer.app",
160
+ "webflow.io",
161
+ "readthedocs.io",
162
+ "gitbook.io",
163
+ "sourceforge.net",
164
+ "digitaloceanspaces.com",
165
+ "ondigitalocean.app",
166
+ "b-cdn.net",
167
+ "jsdelivr.net",
168
+ ]);
169
+
170
+ /** Why a wildcard's base can't be wildcarded, or null: a public suffix, or a single label. */
171
+ function wildcardProblem(base: string): string | null {
172
+ if (base === "localhost") {
173
+ return null;
174
+ }
175
+ return !base.includes(".") || PUBLIC_SUFFIXES.has(base)
176
+ ? `is a wildcard over a public suffix (*.${base}): anyone can serve a site under it; name a domain of your own (https://*.cdn.example.com)`
177
+ : null;
178
+ }
25
179
  const HOST =
26
180
  /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/u;
27
181
 
@@ -50,6 +204,12 @@ export function originSourceProblem(value: unknown): string | null {
50
204
  if (host !== "[::1]" && !HOST.test(host)) {
51
205
  return `${JSON.stringify(value)} ${shape}`;
52
206
  }
207
+ if (host.startsWith("*.")) {
208
+ const problem = wildcardProblem(host.slice(2));
209
+ if (problem !== null) {
210
+ return `${JSON.stringify(value)} ${problem}`;
211
+ }
212
+ }
53
213
  if ((scheme === "http" || scheme === "ws") && !LOOPBACK.test(host.replace(/^\*\./u, "x."))) {
54
214
  return `${JSON.stringify(value)} uses ${scheme} off loopback: ${scheme} is allowed only for localhost, *.localhost, 127.0.0.1 and [::1] (loopback development); use ${scheme === "http" ? "https" : "wss"}`;
55
215
  }
@@ -131,6 +291,7 @@ export function validateConfig(input: unknown): PortholeConfig {
131
291
  "embedder",
132
292
  "sandbox",
133
293
  "permissions",
294
+ "storage",
134
295
  ],
135
296
  "config",
136
297
  );
@@ -173,10 +334,26 @@ export function validateConfig(input: unknown): PortholeConfig {
173
334
 
174
335
  const egress = object(config.egress, "config.egress");
175
336
  const kinds = ["connect", "script", "style", "img", "font", "media"];
176
- only(egress, kinds, "config.egress");
337
+ only(egress, [...kinds, "anyHttps"], "config.egress");
177
338
  for (const kind of kinds) {
178
339
  sources(egress[kind], `config.egress.${kind}`);
179
340
  }
341
+ if (egress.anyHttps !== undefined) {
342
+ if (!Array.isArray(egress.anyHttps)) {
343
+ fail("config.egress.anyHttps", `must be a list of ${PASSIVE_KINDS.join(", ")}`);
344
+ }
345
+ (egress.anyHttps as unknown[]).forEach((kind, index) => {
346
+ if (!(PASSIVE_KINDS as readonly unknown[]).includes(kind)) {
347
+ fail(
348
+ `config.egress.anyHttps[${index}]`,
349
+ `${JSON.stringify(kind)}: only images, media and fonts may load from any https site (P059), never scripts, styles or data`,
350
+ );
351
+ }
352
+ });
353
+ if (config.mode === "url") {
354
+ fail("config.egress.anyHttps", "url mode has no egress control");
355
+ }
356
+ }
180
357
 
181
358
  const theme = object(config.theme, "config.theme");
182
359
  only(theme, ["variables", "event", "documents"], "config.theme");
@@ -315,6 +492,20 @@ export function validateConfig(input: unknown): PortholeConfig {
315
492
  });
316
493
  }
317
494
 
495
+ if (config.storage !== undefined) {
496
+ const storage = object(config.storage, "config.storage");
497
+ only(storage, ["shared"], "config.storage");
498
+ if (config.isolation !== "domain") {
499
+ fail("config.storage", "only domain isolation keeps storage across displays");
500
+ }
501
+ if (
502
+ storage.shared !== undefined &&
503
+ (typeof storage.shared !== "string" || !/^[a-z][a-z -]{0,39}$/u.test(storage.shared))
504
+ ) {
505
+ fail("config.storage.shared", 'must be a short lowercase noun ("conversation")');
506
+ }
507
+ }
508
+
318
509
  if (config.permissions !== undefined) {
319
510
  if (config.mode !== "url") {
320
511
  fail("config.permissions", "only url mode may be delegated features");
@@ -197,7 +197,8 @@ export const BEHAVIOR_PROBES: readonly Probe[] = [
197
197
  {
198
198
  id: "11.theme-flip",
199
199
  item: 11,
200
- title: "a theme flip re-themes the running content in place, and tells it once",
200
+ title:
201
+ "a theme flip re-themes the running content in place, and tells it once; no event without one",
201
202
  async run(context) {
202
203
  const config = inline({ theme: { event: "app:theme" } });
203
204
  const light = resolveTheme(config.theme, "light");
@@ -210,6 +211,13 @@ export const BEHAVIOR_PROBES: readonly Probe[] = [
210
211
  const frame = await display.frame();
211
212
  await expect(frame.locator("p")).toHaveText("themed");
212
213
  expect(await variable(frame, "--color-text-primary")).toBe(light["--color-text-primary"]);
214
+ const flips = () => read(frame, () => (window as unknown as { flips: string[] }).flips);
215
+ // Mounting, and a host telling it the scheme it already has, are no flip: no event (#3).
216
+ await settle(400);
217
+ expect(await flips(), "no theme event at mount").toEqual([]);
218
+ await display.setTheme("light");
219
+ await settle(400);
220
+ expect(await flips(), "no theme event for the scheme it has").toEqual([]);
213
221
  await display.setTheme("dark");
214
222
  await expect
215
223
  .poll(() => variable(frame, "--color-text-primary"))
@@ -477,6 +485,17 @@ export const BEHAVIOR_PROBES: readonly Probe[] = [
477
485
  content: PROMPT_CONTENT,
478
486
  });
479
487
  await click(display, "#ask");
488
+ // A host that always has a confirm function (a default dialog) asks anyway: what this case
489
+ // proves can't happen there (P058). Its answer is no, so nothing is sent either way.
490
+ const asked = await display.answerNextConfirmation(false, { timeoutMs: 1_500 }).then(
491
+ () => true,
492
+ () => false,
493
+ );
494
+ if (asked) {
495
+ context.inapplicable(
496
+ "this host always has a confirm function (a default dialog), so a display without one can't be shown; it asked the person instead",
497
+ );
498
+ }
480
499
  expect(await bodyData(display, "result")).toBe("false");
481
500
  expect(await refusals(display)).toContain("sendPrompt:no-confirm");
482
501
  expect(await display.calls()).toEqual([]);
@@ -22,6 +22,11 @@ export interface ProbeContext {
22
22
  requestsTo(origin: string): string[];
23
23
  /** Records a measured engine fact in the report. */
24
24
  measured(text: string): void;
25
+ /**
26
+ * Ends the probe as inapplicable on this host (reported as skipped, with the reason): what it
27
+ * proves can't be observed here, through no fault of the host's isolation (P058).
28
+ */
29
+ inapplicable(reason: string): never;
25
30
  }
26
31
 
27
32
  export interface Probe {
@@ -49,6 +54,17 @@ export function attack(code: string, markup = ""): string {
49
54
  (async () => {
50
55
  const r = {};
51
56
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
57
+ // Whether a policy blocks \`origin\` though the frame's own allows it: then it is the page's,
58
+ // which an opaque frame inherits (P058). \`attempt\` loads something from it once more.
59
+ const inheritedBlocks = async (origin, attempt) => {
60
+ let blocked = false;
61
+ const seen = (event) => { if (String(event.blockedURI).indexOf(origin) === 0) blocked = true; };
62
+ document.addEventListener("securitypolicyviolation", seen);
63
+ try { await attempt(); } catch (e) {}
64
+ await wait(300);
65
+ document.removeEventListener("securitypolicyviolation", seen);
66
+ return blocked;
67
+ };
52
68
  try {
53
69
  ${code}
54
70
  } catch (error) {
@@ -335,6 +335,13 @@ const sdkProbe = (preset: "inline" | "artifact" | "mcp-app"): Probe => ({
335
335
  export type { Probe, ProbeContext } from "./kit.ts";
336
336
  export { attack } from "./kit.ts";
337
337
 
338
+ /**
339
+ * Why a positive control can't pass on this host: an opaque (`srcdoc`) frame inherits the
340
+ * embedding page's whole policy, and the page's own closes what the config opens (P058).
341
+ */
342
+ const INHERITED = (directive: string, origin: string) =>
343
+ `the page's own CSP, which an opaque frame inherits, blocks ${origin} (${directive}): content in opaque frames here is as closed as the page, whatever its config opens. Domain mode's frames don't inherit it.`;
344
+
338
345
  export const PROBES: readonly Probe[] = [
339
346
  // ── 0: the shell's own wiring, in a real frame ─────────────────────────────────────────────
340
347
  {
@@ -440,10 +447,14 @@ export const PROBES: readonly Probe[] = [
440
447
  worker.onmessage = (event) => (event.data === "sent" ? resolve() : reject());
441
448
  worker.onerror = reject;
442
449
  }));
443
- r.allowed = await fetch(${js(allowed.origin)} + "/allowed").then((response) => response.text(), () => "blocked");`),
450
+ r.allowed = await fetch(${js(allowed.origin)} + "/allowed").then((response) => response.text(), () => "blocked");
451
+ r.inherited = await inheritedBlocks(${js(allowed.origin)}, () => fetch(${js(allowed.origin)} + "/policy").catch(() => {}));`),
444
452
  });
445
453
  const verdict = await context.result(display, 20_000);
446
454
  await settle();
455
+ if (verdict.allowed !== "oracle" && verdict.inherited === true) {
456
+ context.inapplicable(INHERITED("connect-src", allowed.origin));
457
+ }
447
458
  expect(verdict.allowed, "the allowed origin (the positive control)").toBe("oracle");
448
459
  expect(allowed.requests().map((request) => request.path)).toContain("/allowed");
449
460
  expect(canary.requests(), "requests reached the canary").toEqual([]);
@@ -489,12 +500,16 @@ export const PROBES: readonly Probe[] = [
489
500
  try { new Worker(${js(`${c}/worker.js`)}); } catch (e) {}
490
501
  const ok = document.getElementById("ok");
491
502
  r.allowed = await new Promise((resolve) => { if (ok.complete && ok.naturalWidth > 0) resolve("loaded"); ok.onload = () => resolve("loaded"); ok.onerror = () => resolve("blocked"); setTimeout(() => resolve("timeout"), 3000); });
492
- r.script = typeof globalThis.__portholeOracleScript;`,
503
+ r.script = typeof globalThis.__portholeOracleScript;
504
+ r.inherited = await inheritedBlocks(${js(allowed.origin)}, () => { new Image().src = ${js(`${allowed.origin}/policy.png`)}; });`,
493
505
  markup,
494
506
  ),
495
507
  });
496
508
  const verdict = await context.result(display, 20_000);
497
509
  await settle();
510
+ if (verdict.allowed !== "loaded" && verdict.inherited === true) {
511
+ context.inapplicable(INHERITED("img-src", allowed.origin));
512
+ }
498
513
  expect(verdict.allowed, "the allowed image (the positive control)").toBe("loaded");
499
514
  expect(canary.requests(), "resources loaded from the canary").toEqual([]);
500
515
  expect(context.requestsTo(canary.origin)).toEqual([]);
@@ -1106,6 +1121,10 @@ export function leakTests(
1106
1121
  requestsTo: (origin) =>
1107
1122
  requests.filter((url) => url.startsWith(`${origin}/`) || url === origin),
1108
1123
  measured: (text) => info.annotations.push({ type: "measured", description: text }),
1124
+ inapplicable: (why) => {
1125
+ info.skip(true, `inapplicable on this host: ${why}`);
1126
+ throw new Error(`inapplicable on this host: ${why}`);
1127
+ },
1109
1128
  });
1110
1129
  } finally {
1111
1130
  await Promise.all(closers.map((close) => close()));
@@ -38,8 +38,17 @@ export interface ServedConformanceHost {
38
38
  * Shows `instance` in `page` as the host does: a fresh ticket from its backend, framed under
39
39
  * the config's sandbox, in an embedding page that sends the config's `embedderCsp`. The same page
40
40
  * may show several, one after another.
41
+ *
42
+ * For a served shell the suite gives `options.content`, a line of HTML: a host that speaks to
43
+ * the shell (`createHost`, the React binding) sends it as the display's content, since a shell
44
+ * draws nothing (and reports no height) until it has content, and the cases click inside it. A
45
+ * host whose frame has a height of its own may ignore it.
41
46
  */
42
- show(page: Page, instance: InstanceRef): Promise<ServedDisplay>;
47
+ show(
48
+ page: Page,
49
+ instance: InstanceRef,
50
+ options?: { readonly content?: string },
51
+ ): Promise<ServedDisplay>;
43
52
  /** A fresh ticketed location for `instance`, as the host's backend issues it. */
44
53
  locate(instance: InstanceRef): Promise<FrameLocation>;
45
54
  /** Navigates `page` to a page on an origin that is not one of the host's, framing `url`. */
@@ -74,6 +83,9 @@ type Fixtures = { page: Page; browserName: string };
74
83
 
75
84
  const SETTLE_MS = 800;
76
85
 
86
+ /** What a served shell is given to draw (`ServedConformanceHost.show`'s `content`). */
87
+ const SHELL_CONTENT = '<p id="porthole-probe-content">Porthole conformance probe</p>';
88
+
77
89
  /** What the frame can see of its host: everything here must be closed to it. */
78
90
  async function reachIntoHost(frame: Frame): Promise<Record<string, string>> {
79
91
  return frame.evaluate(() => {
@@ -185,13 +197,16 @@ export function servedTests(
185
197
  ["domain", (scope: string) => host.shell(scope)],
186
198
  ["url", (scope: string) => host.app(scope)],
187
199
  ] as const;
200
+ // A served shell draws (and has a height to click in) once its host gives it content.
201
+ const showMode = (page: Page, mode: "domain" | "url", instance: InstanceRef) =>
202
+ host.show(page, instance, mode === "domain" ? { content: SHELL_CONTENT } : {});
188
203
 
189
204
  for (const [mode, instance] of modes) {
190
205
  define(
191
206
  `served.${mode}.origin`,
192
207
  "content runs on its own origin, never the host's",
193
208
  async ({ page }) => {
194
- const display = await host.show(page, instance(`${mode}-origin`));
209
+ const display = await showMode(page, mode, instance(`${mode}-origin`));
195
210
  const frame = await display.frame();
196
211
  const origin = await frame.evaluate(() => window.origin);
197
212
  expect(origin).toBe(display.origin);
@@ -203,7 +218,7 @@ export function servedTests(
203
218
  `served.${mode}.no-reach`,
204
219
  "content can't reach the host's DOM, storage, cookies or URL",
205
220
  async ({ page }) => {
206
- const display = await host.show(page, instance(`${mode}-reach`));
221
+ const display = await showMode(page, mode, instance(`${mode}-reach`));
207
222
  await plantHostSecrets(page);
208
223
  expect(await reachIntoHost(await display.frame())).toEqual(CLOSED);
209
224
  },
@@ -213,7 +228,7 @@ export function servedTests(
213
228
  `served.${mode}.no-top-navigation`,
214
229
  "content can't navigate the host's page, even after a click",
215
230
  async ({ page }) => {
216
- const display = await host.show(page, instance(`${mode}-top`));
231
+ const display = await showMode(page, mode, instance(`${mode}-top`));
217
232
  const frame = await display.frame();
218
233
  const before = page.url();
219
234
  const target = await host.canary(`${mode}-top`);
@@ -246,7 +261,7 @@ export function servedTests(
246
261
  `served.${mode}.no-popups`,
247
262
  "content can't open a window, even after a click",
248
263
  async ({ page }) => {
249
- const display = await host.show(page, instance(`${mode}-popup`));
264
+ const display = await showMode(page, mode, instance(`${mode}-popup`));
250
265
  const frame = await display.frame();
251
266
  const target = await host.canary(`${mode}-popup`);
252
267
  const opened: Page[] = [];
@@ -283,13 +298,13 @@ export function servedTests(
283
298
  "served.domain.storage-scope",
284
299
  "storage persists within a scope and is never shared across scopes",
285
300
  async ({ page }) => {
286
- const first = await host.show(page, host.shell("scope-a"));
301
+ const first = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
287
302
  await (await first.frame()).evaluate(() => localStorage.setItem("kept", "a"));
288
303
  await first.destroy();
289
- const again = await host.show(page, host.shell("scope-a"));
304
+ const again = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
290
305
  expect(await (await again.frame()).evaluate(() => localStorage.getItem("kept"))).toBe("a");
291
306
  expect(again.origin).toBe(first.origin);
292
- const other = await host.show(page, host.shell("scope-b"));
307
+ const other = await host.show(page, host.shell("scope-b"), { content: SHELL_CONTENT });
293
308
  expect(other.origin).not.toBe(first.origin);
294
309
  expect(await (await other.frame()).evaluate(() => localStorage.getItem("kept"))).toBeNull();
295
310
  },
@@ -367,7 +382,7 @@ export function servedTests(
367
382
  "served.domain.egress",
368
383
  "the CSP in the document's headers closes egress",
369
384
  async ({ page }) => {
370
- const display = await host.show(page, host.shell("egress"));
385
+ const display = await host.show(page, host.shell("egress"), { content: SHELL_CONTENT });
371
386
  const frame = await display.frame();
372
387
  const fetched = await frame.evaluate(
373
388
  async (url) => {
@@ -7,6 +7,7 @@ import {
7
7
  import type { PortholeConfig } from "../config/schema.ts";
8
8
  import { BUILT_IN_TOPICS } from "../config/validate.ts";
9
9
  import {
10
+ anyHttps,
10
11
  declaredCapabilities,
11
12
  effectiveGates,
12
13
  egressOrigins,
@@ -83,11 +84,20 @@ const KIND_WORDS: Readonly<Record<EgressKind, string>> = {
83
84
  connect: "data (fetch, WebSocket)",
84
85
  };
85
86
 
87
+ /** What the manual calls `https:` (any https site), where a config opens passive kinds to it. */
88
+ export const ANY_HTTPS_SITE = "any https site";
89
+
90
+ /** The sources content may load `kind` from, as the manual names them: origins, any https site. */
91
+ const sourcesFor = (config: PortholeConfig, kind: EgressKind): string[] => [
92
+ ...(anyHttps(config, kind) ? [ANY_HTTPS_SITE] : []),
93
+ ...egressOrigins(config, kind),
94
+ ];
95
+
86
96
  /** What content may load, in prose, from the same origins the CSP lists. */
87
97
  function network(config: PortholeConfig): string {
88
98
  const kinds: EgressKind[] = ["script", "style", "font", "img", "media", "connect"];
89
99
  const open = kinds
90
- .map((kind) => [kind, egressOrigins(config, kind)] as const)
100
+ .map((kind) => [kind, sourcesFor(config, kind)] as const)
91
101
  .filter(([, origins]) => origins.length > 0);
92
102
  if (open.length === 0) {
93
103
  return "Network: none. Nothing loads from outside (no scripts, styles, fonts, images or data from any server) and any fetch fails, so write everything inline: SVG and CSS draw charts well.";
@@ -368,7 +378,9 @@ function interactive(c: Context): string {
368
378
  const state =
369
379
  config.isolation === "opaque"
370
380
  ? `- State isn't saved: localStorage works but lasts only while the ${noun} is on screen, and a reload or another device starts fresh. Keep what matters in the ${noun}'s code.`
371
- : `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`;
381
+ : config.storage?.shared === undefined
382
+ ? `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`
383
+ : `- localStorage is shared by every ${noun} in this ${config.storage.shared}, in this browser (another device starts fresh): prefix your keys with a name of this ${noun}'s own ("budget:filters", not "filters"), or ${noun}s overwrite each other.`;
372
384
  const none =
373
385
  entries.length === 0
374
386
  ? `- It can only draw: it has no way to send anything to you or the app.`
@@ -417,10 +429,19 @@ export function generateToolDescription(config: PortholeConfig): string {
417
429
  ),
418
430
  ),
419
431
  ];
432
+ const passive = (["img", "media", "font"] as const)
433
+ .filter((kind) => anyHttps(config, kind))
434
+ .map((kind) => KIND_WORDS[kind]);
435
+ const fromAnywhere =
436
+ passive.length === 0
437
+ ? ""
438
+ : `${passive.length === 1 ? passive[0] : `${passive.slice(0, -1).join(", ")} and ${passive.at(-1)}`} from ${ANY_HTTPS_SITE}`;
420
439
  const net =
421
- origins.length === 0
440
+ origins.length === 0 && fromAnywhere === ""
422
441
  ? "with no network (nothing loads from outside)"
423
- : `loading only from ${origins.join(", ")}`;
442
+ : origins.length === 0
443
+ ? `loading only ${fromAnywhere}`
444
+ : `loading only from ${origins.join(", ")}${fromAnywhere === "" ? "" : `, and ${fromAnywhere}`}`;
424
445
  const globals = declaredCapabilities(config).flatMap((name) => {
425
446
  const spec = capabilitySpec(name);
426
447
  if (spec.global === undefined || !c.global(spec.global)) {
@@ -26,6 +26,7 @@ import { shellAdapter } from "../protocol/shell-adapter.ts";
26
26
  import { resolveTheme } from "../theme/variables.ts";
27
27
  import type {
28
28
  AnnotationControls,
29
+ HostErrorKind,
29
30
  DisplayEvents,
30
31
  DisplayHandle,
31
32
  HostOptions,
@@ -96,6 +97,16 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
96
97
  (listener as (detail: DisplayEvents[E]) => void)(detail);
97
98
  }
98
99
  };
100
+ // What the content did wrong (the agent's to hear and fix), and what failed on the host's
101
+ // side (its own to handle), each on its own event and on `error` with its source.
102
+ const contentError = (message: string) => {
103
+ emit("contentError", { message });
104
+ emit("error", { message, source: "content" });
105
+ };
106
+ const hostError = (message: string, kind: HostErrorKind) => {
107
+ emit("hostError", { message, kind });
108
+ emit("error", { message, source: "host" });
109
+ };
99
110
 
100
111
  let scheme: ColorScheme = mount.scheme;
101
112
  let height = mount.initialHeightPx ?? 0;
@@ -277,7 +288,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
277
288
  return;
278
289
  }
279
290
  case "error":
280
- emit("error", { message: event.message });
291
+ contentError(event.message);
281
292
  return;
282
293
  case "refused":
283
294
  emit("refused", { capability: event.capability, refusal: event.refusal });
@@ -311,8 +322,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
311
322
  allow: compiled.allow,
312
323
  referrerPolicy: compiled.referrerPolicy,
313
324
  colorScheme: scheme,
314
- // An app reports no size: its frame fills the host's container, at least the minimum.
315
- ...(speaks
325
+ // An app reports no size: its frame fills the host's container, at least the minimum;
326
+ // so does any display the host mounts to fill (its content scrolls inside).
327
+ ...(speaks && mount.fill !== true
316
328
  ? { initialHeightPx: height }
317
329
  : { fill: true, initialHeightPx: config.limits.minHeightPx }),
318
330
  });
@@ -377,9 +389,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
377
389
  run();
378
390
  } else {
379
391
  stop();
380
- emit("error", {
381
- message: "The content kept navigating its frame away; it was stopped.",
382
- });
392
+ contentError("The content kept navigating its frame away; it was stopped.");
383
393
  }
384
394
  });
385
395
  };
@@ -396,6 +406,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
396
406
  html: compiled.shellDocument({
397
407
  origins: [container.ownerDocument.defaultView?.location.origin ?? ""],
398
408
  title: mount.title,
409
+ ...nonceOf(options.nonce),
399
410
  ...(mount.document === undefined ? {} : { document: mount.document }),
400
411
  }),
401
412
  });
@@ -418,7 +429,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
418
429
  },
419
430
  (error: unknown) => {
420
431
  if (mine === generation && !controller.signal.aborted) {
421
- emit("error", { message: error instanceof Error ? error.message : String(error) });
432
+ // The host's own failure (its backend down, access gone): never the content's.
433
+ hostError(error instanceof Error ? error.message : String(error), "locate");
434
+ emit("outcome", { state: "blocked", reason: "locate" });
422
435
  }
423
436
  },
424
437
  );
@@ -488,3 +501,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
488
501
  },
489
502
  };
490
503
  }
504
+
505
+ /** The host's nonce option, read now (`HostOptions.nonce`). */
506
+ function nonceOf(nonce: HostOptions["nonce"]): { nonce?: string } {
507
+ const value = typeof nonce === "function" ? nonce() : nonce;
508
+ return value === undefined || value === "" ? {} : { nonce: value };
509
+ }