@botiverse/porthole 0.1.5 → 0.1.7

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.
@@ -4,6 +4,7 @@ import { resolveTheme } from "../theme/variables.js";
4
4
  import { attack, inline, js } from "./kit.js";
5
5
  import { BEHAVIOR_PROBES } from "./behavior.js";
6
6
  import { CLIPBOARD_PROBES } from "./clipboard.js";
7
+ import { LIMIT_PROBES } from "./limits.js";
7
8
  import { readFileSync } from "node:fs";
8
9
  import { createRequire } from "node:module";
9
10
  import { httpOracle, settle, udpOracle, } from "./oracles.js";
@@ -832,6 +833,7 @@ export const PROBES = [
832
833
  },
833
834
  ...BEHAVIOR_PROBES,
834
835
  ...CLIPBOARD_PROBES,
836
+ ...LIMIT_PROBES,
835
837
  ];
836
838
  /** Every probe as a test entry (see `ConformanceTest`). */
837
839
  export function leakTests(host, options = {}) {
@@ -189,6 +189,19 @@ export function servedTests(host, options = {}) {
189
189
  expect(await host.canaryHits(`${mode}-popup`)).toBe(0);
190
190
  });
191
191
  }
192
+ define("served.domain.address", "a served frame's address is its own, carrying at most Porthole's ticket: never the app's", async ({ page }) => {
193
+ const display = await host.show(page, host.shell("address"), { content: SHELL_CONTENT });
194
+ const frame = await display.frame();
195
+ const seen = await frame.evaluate(() => ({
196
+ origin: location.origin,
197
+ search: location.search,
198
+ hash: location.hash,
199
+ }));
200
+ expect(seen.origin).toBe(display.origin);
201
+ expect(seen.search).toMatch(/^(\?ticket=[^&]+)?$/u);
202
+ expect(seen.hash).toBe("");
203
+ expect(seen.origin).not.toBe(new URL(page.url()).origin);
204
+ });
192
205
  define("served.domain.storage-scope", "storage persists within a scope and is never shared across scopes", async ({ page }) => {
193
206
  const first = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
194
207
  await (await first.frame()).evaluate(() => localStorage.setItem("kept", "a"));
@@ -250,27 +263,76 @@ export function servedTests(host, options = {}) {
250
263
  expect(shown.origin).not.toBe(location.origin);
251
264
  }
252
265
  });
253
- define("served.domain.egress", "the CSP in the document's headers closes egress", async ({ page }) => {
266
+ define("served.domain.egress", "the CSP in the document's headers closes egress; only what the config opens to any https site (images, media) reaches it", async ({ page }) => {
254
267
  const display = await host.show(page, host.shell("egress"), { content: SHELL_CONTENT });
255
268
  const frame = await display.frame();
256
- const fetched = await frame.evaluate(async (url) => {
269
+ const kinds = ["fetch", "img", "media", "script", "style", "frame"];
270
+ const urls = Object.fromEntries(await Promise.all(kinds.map(async (kind) => [kind, await host.canary(`egress-${kind}`)])));
271
+ // Each kind tried once; the policy that applied is read from the violations it raises.
272
+ const seen = await frame.evaluate(async (each) => {
273
+ const policies = [];
274
+ document.addEventListener("securitypolicyviolation", (event) => policies.push(event.originalPolicy));
275
+ let fetched = "sent";
257
276
  try {
258
- await fetch(url, { mode: "no-cors" });
259
- return "sent";
277
+ await fetch(each.fetch, { mode: "no-cors" });
260
278
  }
261
279
  catch {
262
- return "blocked";
280
+ fetched = "blocked";
263
281
  }
264
- }, await host.canary("egress-fetch"));
265
- await frame.evaluate((url) => {
266
282
  const image = new Image();
267
- image.src = url;
283
+ image.src = each.img;
268
284
  document.body.append(image);
269
- }, await host.canary("egress-img"));
285
+ const audio = document.createElement("audio");
286
+ audio.preload = "auto";
287
+ audio.src = each.media;
288
+ document.body.append(audio);
289
+ const script = document.createElement("script");
290
+ script.src = each.script;
291
+ document.body.append(script);
292
+ const link = document.createElement("link");
293
+ link.rel = "stylesheet";
294
+ link.href = each.style;
295
+ document.head.append(link);
296
+ const nested = document.createElement("iframe");
297
+ nested.src = each.frame;
298
+ document.body.append(nested);
299
+ await new Promise((resolve) => setTimeout(resolve, 800));
300
+ return { fetched, policy: policies.find((policy) => /connect-src/u.test(policy)) ?? "" };
301
+ }, urls);
270
302
  await page.waitForTimeout(SETTLE_MS);
271
- expect(fetched).toBe("blocked");
272
- expect(await host.canaryHits("egress-fetch")).toBe(0);
273
- expect(await host.canaryHits("egress-img")).toBe(0);
303
+ expect(seen.fetched).toBe("blocked");
304
+ expect(seen.policy, "a CSP applied to the document").not.toBe("");
305
+ const sources = new Map(seen.policy
306
+ .split(";")
307
+ .map((directive) => directive.trim().split(/\s+/u))
308
+ .map(([name = "", ...rest]) => [name, rest]));
309
+ // What runs or talks never opens to any site (P059), whatever the config.
310
+ for (const name of ["script-src", "style-src", "connect-src", "frame-src", "default-src"]) {
311
+ expect(sources.get(name) ?? [], `${name} opens no scheme`).not.toContain("https:");
312
+ }
313
+ for (const kind of ["fetch", "script", "style", "frame"]) {
314
+ expect(await host.canaryHits(`egress-${kind}`), kind).toBe(0);
315
+ }
316
+ // Images and media: closed, unless the config opened them to any https site (P059);
317
+ // then, the canary being an https site, an image reaches it, as the config means.
318
+ const https = (kind) => (sources.get(`${kind}-src`) ?? []).includes("https:") &&
319
+ new URL(urls[kind]).protocol === "https:";
320
+ const opened = ["img", "media"].filter(https);
321
+ if (opened.length > 0) {
322
+ info?.annotations.push({
323
+ type: "expected",
324
+ description: `${opened.join(" and ")} load from any https site under this config (anyHttps, P059): ${opened.includes("img") ? "the image is asserted to reach the outside site" : "measured, not asserted"}; script, style, connect and frames still can't`,
325
+ });
326
+ }
327
+ if (https("img")) {
328
+ expect(await host.canaryHits("egress-img"), "the image, as the config opens it").toBeGreaterThan(0);
329
+ }
330
+ else {
331
+ expect(await host.canaryHits("egress-img"), "img").toBe(0);
332
+ }
333
+ if (!https("media")) {
334
+ expect(await host.canaryHits("egress-media"), "media").toBe(0);
335
+ }
274
336
  });
275
337
  const resourceReady = (html) => ({
276
338
  jsonrpc: "2.0",
@@ -1,9 +1,10 @@
1
1
  import { MAX_COPY_CHARS, capabilitySpec, } from "../config/capabilities.js";
2
2
  import { BUILT_IN_TOPICS } from "../config/validate.js";
3
- import { anyHttps, declaredCapabilities, effectiveGates, egressOrigins, sandboxTokens, } from "../compile/policy.js";
3
+ import { anyHttps, declaredCapabilities, effectiveGates, egressOrigins, } from "../compile/policy.js";
4
4
  import { shellPlan } from "../shell/plan.js";
5
5
  import { themeDefault } from "../theme/variables.js";
6
6
  import { GUIDE_EXAMPLES } from "./examples.js";
7
+ import { manualLimits, sizeWords } from "./limits.js";
7
8
  const VARIABLE = /--[a-z0-9]+(?:-[a-z0-9]+)*/gu;
8
9
  function context(config) {
9
10
  const variables = Object.keys(config.theme.variables);
@@ -73,25 +74,6 @@ function network(config) {
73
74
  /** Whether a whole document takes the theme only when it declares support (ThemeConfig.documents). */
74
75
  const optsIn = (config) => config.mode === "artifact" && (config.theme.documents ?? "declared") === "declared";
75
76
  const loadsScripts = (config) => egressOrigins(config, "script").length > 0;
76
- /** What the sandbox withholds, from the sandbox tokens and the shell's own walls. */
77
- function forbidden(config, c) {
78
- const tokens = sandboxTokens(config);
79
- const items = ["position: fixed", "full screen"];
80
- if (!tokens.includes("allow-modals")) {
81
- items.push("alert/confirm/prompt");
82
- }
83
- if (!tokens.includes("allow-forms")) {
84
- items.push("forms that submit");
85
- }
86
- if (!tokens.includes("allow-popups")) {
87
- items.push("popups and new windows");
88
- }
89
- if (!tokens.includes("allow-downloads")) {
90
- items.push("downloads");
91
- }
92
- items.push(c.global("copyText") ? "the clipboard (copy buttons call copyText)" : "the clipboard", "frames (iframe, object, embed)", "WebRTC");
93
- return `Not allowed: ${items.join(", ")}.`;
94
- }
95
77
  function widget(c) {
96
78
  const { config, noun } = c;
97
79
  const { layout, limits } = config;
@@ -102,18 +84,23 @@ function widget(c) {
102
84
  : config.mode === "mcp-app"
103
85
  ? "What you pass: an MCP Apps view, a whole HTML document that talks to its host through the official App SDK (@modelcontextprotocol/ext-apps) or the protocol's own messages."
104
86
  : "What you pass: SVG that starts with <svg, or an HTML fragment: no <!doctype>, <html>, <head> or <body>. Order it styles, then markup, then scripts: people see the styled markup as you write it, and scripts run once all of it is written, in order, each external one loaded before the next runs.";
105
- const height = c.declared("resize")
106
- ? `Its height follows its content, at most ${limits.maxHeightPx.toLocaleString("en-US")} px (then it scrolls inside).`
107
- : "Its height is fixed by the app: keep it short, or let it scroll inside.";
87
+ const stated = manualLimits(config, network(config));
88
+ const said = (id) => stated.find((limit) => limit.id === id)?.text ?? "";
89
+ const fills = layout.kind === "fill";
90
+ const themeless = config.theme.documents === "never";
108
91
  const viewBox = layout.narrowWidthPx + 20;
109
92
  const smallest = Math.round(((13 * layout.minWidthPx) / viewBox) * 10) / 10;
110
93
  const largest = Math.round(((13 * 400) / viewBox) * 10) / 10;
111
94
  const textSize = themeDefault(config.theme.variables["--font-text-md-size"] ?? "14px", "light").replace(/^(\d+(?:\.\d+)?)px$/u, "$1 px");
112
- return lines(`# ${title}${tool}`, "", what, "", `Where it runs: in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the box's clientWidth, again from a ResizeObserver). ${height} ${optsIn(config) ? `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.` : "It runs in the person's light or dark theme."} It runs again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`, "", "Sizes that read on a phone:", c.when(`- Words go in HTML (titles, notes, legends, readouts): it keeps its size and wraps. The frame's text is already ${textSize} --font-sans in --color-text-primary; notes --font-text-sm-size, never under 12 px.`), `- An SVG scales with the frame: its text shows at font-size × frame width ÷ viewBox width, so a 680-wide viewBox turns 12 into 5 px on a phone. Draw it for the phone: viewBox="0 0 ${viewBox} H", no text under 13, width="100%" style="max-width: 400px"; its text then shows at ${smallest} to ${largest} px on every screen. Or draw it 1 unit = 1 px with a script (topic chart).`, `- The frame never scrolls sideways: nothing fixed wider than ${layout.minWidthPx} px; images max-width: 100%; a grid or flex child with long text min-width: 0; a wide table or code block in its own box with overflow-x: auto.`, "- Cards in a row wrap (flex-wrap: wrap; flex: 1 1 100px), or a grid of repeat(auto-fit, minmax(160px, 1fr)): at most 2 columns under 500 px, never a fixed repeat(3, …).", "- Touch: anything clickable is at least 44 px tall. Phones don't hover: what a hover shows, a tap (click) shows too.", "", `${forbidden(config, c)} ${network(config)} Keep it under ${Math.floor(limits.maxBytes / 1000)} KB.`, loadsScripts(config)
95
+ return lines(`# ${title}${tool}`, "", what, "", `Where it runs: ${fills ? `in a sandboxed frame that fills the viewer, about ${layout.narrowWidthPx} px wide on a phone (${layout.minWidthPx} on a small one) and as wide as the window on a desktop` : `in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop`}; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the ${fills ? "window's innerWidth, again on resize" : "box's clientWidth, again from a ResizeObserver"}). ${said("layout")} ${said("theme")} It runs again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`, "", "Sizes that read on a phone:", c.when(`- Words go in HTML (titles, notes, legends, readouts): it keeps its size and wraps. The frame's text is already ${textSize} --font-sans in --color-text-primary; notes --font-text-sm-size, never under 12 px.`), `- An SVG scales with the frame: its text shows at font-size × frame width ÷ viewBox width, so a 680-wide viewBox turns 12 into 5 px on a phone. Draw it for the phone: viewBox="0 0 ${viewBox} H", no text under 13, width="100%" style="max-width: 400px"; its text then shows at ${smallest} to ${largest} px on every screen. Or draw it 1 unit = 1 px with a script (topic chart).`, `- The frame never scrolls sideways: nothing fixed wider than ${layout.minWidthPx} px; images max-width: 100%; a grid or flex child with long text min-width: 0; a wide table or code block in its own box with overflow-x: auto.`, "- Cards in a row wrap (flex-wrap: wrap; flex: 1 1 100px), or a grid of repeat(auto-fit, minmax(160px, 1fr)): at most 2 columns under 500 px, never a fixed repeat(3, …).", "- Touch: anything clickable is at least 44 px tall. Phones don't hover: what a hover shows, a tap (click) shows too.", fills
96
+ ? null
97
+ : "- No position: fixed: the frame grows with its content, so a fixed element covers it.", "", "The sandbox:", ...stated.filter((limit) => limit.topic === "sandbox").map((limit) => `- ${limit.text}`), `Keep it under ${sizeWords(limits.maxBytes)}.`, loadsScripts(config)
113
98
  ? "\nLibraries: pin an exact version at least two weeks old (chart.js@4.4.1 in its URL), never @latest or a range, so the code can't be swapped under you and draws the same every time."
114
- : null, "", optsIn(config)
115
- ? `Colors, for a ${noun} that declares light and dark: every color from these variables, never hard-coded, so both work; its own background then stays transparent (it sits on the app's surface).`
116
- : `Colors: every color from these variables, never hard-coded, so light and dark both work. The ${noun}'s own background is transparent (it sits on the app's surface).`, c.names("--color-background-") === "" ? null : `- Surfaces: ${c.names("--color-background-")}`, c.names("--color-text-") === "" ? null : `- Text: ${c.names("--color-text-")}`, c.names("--color-border-") === ""
99
+ : null, "", themeless
100
+ ? `Colors: these variables hold the app's light palette; use them, or colors of your own. Give the ${noun} its own background.`
101
+ : optsIn(config)
102
+ ? `Colors, for a ${noun} that declares light and dark: every color from these variables, never hard-coded, so both work; its own background then stays transparent (it sits on the app's surface).`
103
+ : `Colors: every color from these variables, never hard-coded, so light and dark both work. The ${noun}'s own background is transparent (it sits on the app's surface).`, c.names("--color-background-") === "" ? null : `- Surfaces: ${c.names("--color-background-")}`, c.names("--color-text-") === "" ? null : `- Text: ${c.names("--color-text-")}`, c.names("--color-border-") === ""
117
104
  ? null
118
105
  : `- Borders: ${c.names("--color-border-")}${c.names("--color-ring-") === "" ? "" : `; focus rings: ${c.names("--color-ring-")}`}`, c.names("--color-accent") === ""
119
106
  ? null
@@ -127,7 +114,9 @@ function widget(c) {
127
114
  ? null
128
115
  : `- Shape: ${[c.names("--border-"), c.names("--shadow-")].filter((each) => each !== "").join(", ")}`, otherVariables(c) === "" ? null : `- Also: ${otherVariables(c)}`, "", exampleFits(c, GUIDE_EXAMPLES.stats)
129
116
  ? `Cards that read in both themes, and wrap on a phone:\n${GUIDE_EXAMPLES.stats}\n`
130
- : null, c.when(`Don't use --color-background-primary as a card's fill: it is the app's own surface, so a card on it vanishes.`), `A theme change updates the variables in place and fires window "${config.theme.event}": CSS that uses var(...) follows by itself; a canvas or a library you gave colors to must redraw.`, config.mode === "inline"
117
+ : null, c.when(`Don't use --color-background-primary as a card's fill: it is the app's own surface, so a card on it vanishes.`), themeless
118
+ ? null
119
+ : `A theme change updates the variables in place and fires window "${config.theme.event}": CSS that uses var(...) follows by itself; a canvas or a library you gave colors to must redraw.`, config.mode === "inline"
131
120
  ? "Buttons and inputs already match the app (its borders, radius, colors and focus ring, 44 px tall): use them as they are, or restyle them with your own CSS."
132
121
  : null, "", "Diagrams (boxes and arrows): show the mechanism asked about (what calls what, where data goes), only the parts the ask names; label each arrow with what moves.", `- Top to bottom, at most 2 boxes a row, in the phone viewBox; the title in HTML above. Too much for one glance on a phone: make two.`, "- SVG doesn't wrap: break lines yourself. A box is its longest line + 24 wide (13 px text takes about 7 units a character, a 14 px bold name 8.5: a 136-wide box holds 16); a name and a line, 52 tall; 16 between boxes.", c.when("- Boxes fill none, stroke --color-border-secondary; at most one stands out: stroke --color-chart-1, 2 wide, fill --color-chart-1 at fill-opacity 0.12. Arrows --color-text-tertiary, in the gaps, never across a box, their labels beside them. Every <text> sets fill: names --color-text-primary, the rest --color-text-secondary."), exampleFits(c, GUIDE_EXAMPLES.diagram) ? `\n${GUIDE_EXAMPLES.diagram}` : null);
133
122
  }
@@ -157,7 +146,7 @@ function chart(c) {
157
146
  const categorical = Object.keys(config.theme.variables).filter((name) => /^--color-chart-\d+$/u.test(name));
158
147
  const sequential = Object.keys(config.theme.variables).filter((name) => /^--color-chart-seq-\d+$/u.test(name));
159
148
  const libraries = loadsScripts(config)
160
- ? `Libraries (Chart.js, ECharts, d3) draw in px too, in a box with a set height (Chart.js responsive: true, maintainAspectRatio: false). They don't read CSS variables: pass in getComputedStyle(document.documentElement).getPropertyValue("${categorical[0] ?? "--color-text-primary"}").trim(), redraw on window "${config.theme.event}", and override their palette and white background.`
149
+ ? `Libraries (Chart.js, ECharts, d3) draw in px too, in a box with a set height (Chart.js responsive: true, maintainAspectRatio: false). They don't read CSS variables: pass in getComputedStyle(document.documentElement).getPropertyValue("${categorical[0] ?? "--color-text-primary"}").trim()${config.theme.documents === "never" ? "" : `, redraw on window "${config.theme.event}"`}, and override their palette and white background.`
161
150
  : "No chart library loads here: draw the SVG yourself, from a script, as below.";
162
151
  return lines(`# Charts (a chart is a ${c.noun})`, "", "Pick the form first: a trend over time → line; compare categories → horizontal bars; x against y → scatter; one number → a big number, not a chart; a few numbers → a table, not a chart.", "", "The look:", '- The title states the finding, with its number ("Failed builds fell from 9 to 2 a week"), not the topic; a line under it says what is measured, in what unit.', c.when("- Grey first: the data in a quiet grey (--color-text-disabled), one accent (--color-chart-1) on what the title is about. More hues only for series that differ in kind and must each be found."), "- Values on the marks or a light axis, not both; label only the points the finding rests on. Bars start at zero.", "- Write the numbers once, as rows, and draw marks, labels and readout from them.", "", "Size: draw it at the frame's width, 1 unit = 1 px: read the box's clientWidth, draw an <svg> that wide with 12 px text, and again from a ResizeObserver. Its text stays 12 px on every screen; keep x labels 44 px apart. No script: the phone viewBox (topic widget). Bars need no SVG: an HTML grid row per category.", "", libraries, "", categorical.length === 0 ? null : "Colors:", categorical.length === 0
163
152
  ? null
@@ -200,7 +189,7 @@ const ENTRIES = {
200
189
  ? `- copyText(text): copies text to the person's clipboard (at most ${MAX_COPY_CHARS.toLocaleString("en-US")} characters), and the app shows them what was copied. Use it for every copy button: nothing else in the ${c.noun} can write the clipboard. ${gateWords(gates, "the text")} It returns a promise: true if it was copied.`
201
190
  : `- Copying: the request porthole/copy-text with { text } (at most ${MAX_COPY_CHARS.toLocaleString("en-US")} characters) copies text to the person's clipboard, and the app shows them what was copied; nothing else in the ${c.noun} can write the clipboard. ${gateWords(gates, "the text")}`,
202
191
  annotate: (c) => `- Comments: the person may point at parts of the ${c.noun} to comment on them. Give headings and the parts that matter stable ids (id="revenue"), so comments stay on them when the ${c.noun} is written again.`,
203
- reportError: (c, gates) => `- Errors: what your code throws, and anything the sandbox blocks, is reported to the app (at most ${c.config.limits.maxErrorReports} a run). ${gateWords(gates, "it")}`.trim(),
192
+ reportError: (c) => `- ${manualLimits(c.config, "").find((limit) => limit.id === "errors")?.text ?? ""}`,
204
193
  resize: () => "",
205
194
  };
206
195
  function interactive(c) {
@@ -211,15 +200,17 @@ function interactive(c) {
211
200
  return own === undefined ? ENTRIES[name](c, effectiveGates(config, name)) : `- ${own}`;
212
201
  })
213
202
  .filter((entry) => entry !== "");
214
- const state = config.isolation === "opaque"
215
- ? `- 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.`
216
- : config.storage?.shared === undefined
217
- ? `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`
218
- : `- 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.`;
203
+ const stated = manualLimits(config, "");
204
+ const stored = stated.find((limit) => limit.id === "storage");
205
+ const state = stored === undefined ? null : `- ${stored.text}`;
206
+ // Without reportError the capability list has no errors entry: say that nothing reports them.
207
+ const errors = c.declared("reportError")
208
+ ? null
209
+ : `- ${stated.find((limit) => limit.id === "errors")?.text ?? ""}`;
219
210
  const none = entries.length === 0
220
211
  ? `- It can only draw: it has no way to send anything to you or the app.`
221
212
  : null;
222
- return lines(`# Interactive ${noun}s`, "", ...entries, none, state);
213
+ return lines(`# Interactive ${noun}s`, "", ...entries, errors, none, state);
223
214
  }
224
215
  const TOPICS = {
225
216
  widget,
@@ -282,5 +273,11 @@ export function generateToolDescription(config) {
282
273
  const read = config.guide.guideToolName === undefined
283
274
  ? ""
284
275
  : ` Read ${config.guide.guideToolName} once before your first ${c.noun}${config.mode === "inline" ? " (topic chart before your first chart)" : ""}.`;
285
- return `${what} It runs in a sandbox about ${layout.narrowWidthPx} px wide on a phone and ${layout.wideWidthPx} on a desktop, from the same code${c.declared("resize") ? " (its height follows the content)" : ""}, in the person's light or dark theme through CSS variables, ${net}. ${globals.join(" ")}${read} Each time it is shown it runs again from this code.`.replace(/ {2,}/gu, " ");
276
+ const where = layout.kind === "fill"
277
+ ? `It fills the viewer in a sandbox about ${layout.narrowWidthPx} px wide on a phone, from the same code on every screen`
278
+ : `It runs in a sandbox about ${layout.narrowWidthPx} px wide on a phone and ${layout.wideWidthPx} on a desktop, from the same code${c.declared("resize") ? " (its height follows the content)" : ""}`;
279
+ const theme = config.theme.documents === "never"
280
+ ? "always in light (the app doesn't theme it)"
281
+ : "in the person's light or dark theme through CSS variables";
282
+ return `${what} ${where}, ${theme}, ${net}. ${globals.join(" ")}${read} Each time it is shown it runs again from this code.`.replace(/ {2,}/gu, " ");
286
283
  }
@@ -0,0 +1,24 @@
1
+ import type { PortholeConfig } from "../config/schema.ts";
2
+ /**
3
+ * The sandbox's limits as the manual states them (P064): each a short line, derived from the
4
+ * config, for what silently changes how content behaves. Every limit has an id, and the
5
+ * conformance suite has a named check for each (`LIMIT_CHECKS`), so the manual can't claim what
6
+ * no browser test proves. What differs by engine is stated as the behavior safe on all of them.
7
+ */
8
+ export declare const LIMIT_IDS: readonly ["layout", "theme", "silent", "network", "inline-assets", "dialogs", "print", "downloads", "forms", "link-schemes", "windows", "devices", "workers", "sound", "address", "shell", "storage", "errors"];
9
+ export type LimitId = (typeof LIMIT_IDS)[number];
10
+ /** Where the manual states a limit: where it runs, the sandbox list, or the interactive topic. */
11
+ export type LimitTopic = "where" | "sandbox" | "interactive";
12
+ export interface ManualLimit {
13
+ readonly id: LimitId;
14
+ readonly text: string;
15
+ readonly topic: LimitTopic;
16
+ }
17
+ /**
18
+ * The limits the manual states for `config`, in the order it states them. `network` is the
19
+ * caller's (the egress line, from the CSP's own sources); `storage` goes in the interactive topic.
20
+ * Url mode has no shell and no manual: none.
21
+ */
22
+ export declare function manualLimits(config: PortholeConfig, network: string): ManualLimit[];
23
+ /** A byte limit in words: KB under a megabyte, MB above. */
24
+ export declare function sizeWords(bytes: number): string;
@@ -0,0 +1,152 @@
1
+ import { declaredCapabilities, egressOrigins, sandboxTokens } from "../compile/policy.js";
2
+ import { shellPlan } from "../shell/plan.js";
3
+ /**
4
+ * The sandbox's limits as the manual states them (P064): each a short line, derived from the
5
+ * config, for what silently changes how content behaves. Every limit has an id, and the
6
+ * conformance suite has a named check for each (`LIMIT_CHECKS`), so the manual can't claim what
7
+ * no browser test proves. What differs by engine is stated as the behavior safe on all of them.
8
+ */
9
+ export const LIMIT_IDS = [
10
+ "layout",
11
+ "theme",
12
+ "silent",
13
+ "network",
14
+ "inline-assets",
15
+ "dialogs",
16
+ "print",
17
+ "downloads",
18
+ "forms",
19
+ "link-schemes",
20
+ "windows",
21
+ "devices",
22
+ "workers",
23
+ "sound",
24
+ "address",
25
+ "shell",
26
+ "storage",
27
+ "errors",
28
+ ];
29
+ const TOPICS = {
30
+ layout: "where",
31
+ theme: "where",
32
+ storage: "interactive",
33
+ errors: "interactive",
34
+ silent: "sandbox",
35
+ network: "sandbox",
36
+ "inline-assets": "sandbox",
37
+ dialogs: "sandbox",
38
+ print: "sandbox",
39
+ downloads: "sandbox",
40
+ forms: "sandbox",
41
+ "link-schemes": "sandbox",
42
+ windows: "sandbox",
43
+ devices: "sandbox",
44
+ workers: "sandbox",
45
+ sound: "sandbox",
46
+ address: "sandbox",
47
+ shell: "sandbox",
48
+ };
49
+ const noun = (config) => config.mode === "artifact" ? "page" : config.mode === "mcp-app" ? "view" : "widget";
50
+ /** Whether content may load `kind` from somewhere outside (listed origins, or any https site). */
51
+ const opens = (config, kind) => egressOrigins(config, kind).length > 0 ||
52
+ (config.egress.anyHttps ?? []).some((each) => each === kind);
53
+ /**
54
+ * The limits the manual states for `config`, in the order it states them. `network` is the
55
+ * caller's (the egress line, from the CSP's own sources); `storage` goes in the interactive topic.
56
+ * Url mode has no shell and no manual: none.
57
+ */
58
+ export function manualLimits(config, network) {
59
+ if (config.mode === "url") {
60
+ return [];
61
+ }
62
+ const tokens = sandboxTokens(config);
63
+ const declared = new Set(declaredCapabilities(config));
64
+ const globals = new Set(shellPlan(config)?.globals ?? []);
65
+ const it = noun(config);
66
+ const out = [];
67
+ const add = (id, text) => {
68
+ if (text !== null) {
69
+ out.push({ id, text, topic: TOPICS[id] });
70
+ }
71
+ };
72
+ // Where it runs (P065): a card that grows with its content, or the whole viewer.
73
+ add("layout", config.layout.kind === "fill"
74
+ ? "It is as tall as the window: the page scrolls itself, and position: fixed and sticky work."
75
+ : declared.has("resize")
76
+ ? `Its height follows its content, at most ${config.limits.maxHeightPx.toLocaleString("en-US")} px (then it scrolls inside).`
77
+ : "Its height is fixed by the app: keep it short, or let it scroll inside.");
78
+ const documents = config.theme.documents ?? "declared";
79
+ add("theme", config.mode !== "artifact" || documents === "always"
80
+ ? "It runs in the person's light or dark theme."
81
+ : documents === "never"
82
+ ? "It always shows in light: the app doesn't theme it, so style it yourself; prefers-color-scheme may report the reader's system setting, so don't rely on it."
83
+ : 'It shows in light, as you style it; to follow the person\'s light or dark theme, declare it in its head (<meta name="color-scheme" content="light dark">) and draw with the variables below.');
84
+ add("silent", "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.");
85
+ add("network", network);
86
+ // Assets: what isn't opened goes inline (Network: none already says "everything inline").
87
+ const anywhere = (kind) => (config.egress.anyHttps ?? []).some((each) => each === kind);
88
+ const passive = ["img", "font", "media"].filter((kind) => !anywhere(kind));
89
+ const listed = passive.some((kind) => opens(config, kind)) || opens(config, "style");
90
+ const anyOpen = listed ||
91
+ egressOrigins(config, "script").length > 0 ||
92
+ egressOrigins(config, "connect").length > 0 ||
93
+ passive.length < 3;
94
+ if (anyOpen) {
95
+ const images = passive.length === 0
96
+ ? null
97
+ : passive.every((kind) => !opens(config, kind))
98
+ ? "images, icons, fonts and sound go inline (data: URLs or SVG)"
99
+ : "images, fonts and sound from anywhere not listed go inline (data: URLs or SVG)";
100
+ const css = opens(config, "style") ? "other CSS goes in a <style>" : "CSS goes in a <style>";
101
+ const said = [images, css].filter((each) => each !== null).join("; ");
102
+ add("inline-assets", `${said.charAt(0).toUpperCase()}${said.slice(1)}.`);
103
+ }
104
+ if (!tokens.includes("allow-modals")) {
105
+ add("dialogs", "alert, confirm and prompt show nothing: confirm returns false and prompt null. Draw your own dialogs.");
106
+ add("print", "window.print() does nothing.");
107
+ }
108
+ if (!tokens.includes("allow-downloads")) {
109
+ add("downloads", `Downloads don't work: <a download>, data: and blob: links, saves from script.${declared.has("downloadFile") ? " To give the person a file, ask the app (ui/download-file; with the App SDK, app.downloadFile)." : ""}`);
110
+ }
111
+ if (!tokens.includes("allow-forms")) {
112
+ add("forms", 'Forms never submit, and some browsers fire no submit event: use them as UI, give buttons type="button" and handle their click.');
113
+ }
114
+ add("link-schemes", `mailto:, tel: and sms: links open nothing: show the address or number as text${globals.has("copyText") ? " (with a copy button)" : ""}.`);
115
+ if (!tokens.includes("allow-popups")) {
116
+ add("windows", globals.has("openLink") || declared.has("openLink")
117
+ ? "window.open and target=_blank open no window (window.open returns null or throws): links open in a new tab through the app (openLink; a click on <a href> does it)."
118
+ : "Links to other pages, window.open and target=_blank open nothing (window.open returns null or throws).");
119
+ }
120
+ add("devices", "Camera, microphone, location and sharing are denied without asking (they reject, or aren't there); file inputs and FileReader work.");
121
+ add("workers", "Web Workers from blob: URLs work; service workers can't be registered (even reading navigator.serviceWorker can throw), and WebRTC is gone.");
122
+ add("sound", "Sound (and video with sound) plays only from a click: call play() in its handler.");
123
+ add("address", config.isolation === "opaque"
124
+ ? `The ${it}'s address is about:srcdoc: it has no query string or hash to read.`
125
+ : `The ${it}'s address is its own (with Porthole's one-time ticket), never the app's: read no parameters from it.`);
126
+ add("shell", `document.write and document.open throw; nested frames (iframe, object, embed) are removed; full screen and the clipboard are closed${globals.has("copyText") ? " (copy buttons call copyText)" : ""}.`);
127
+ add("storage", config.isolation === "opaque"
128
+ ? `State isn't saved: localStorage works but lasts only while the ${it} is on screen, and a reload or another device starts fresh. Keep what matters in the ${it}'s code.`
129
+ : config.storage?.scope !== undefined
130
+ ? `localStorage is kept for ${config.storage.scope}, in this browser; another browser or device starts fresh. Reads and writes can throw (private windows, strict settings): wrap them in try/catch and keep only what can be lost.`
131
+ : config.storage?.shared === undefined
132
+ ? `localStorage is kept for this ${it} in this browser; another device starts fresh. Reads and writes can throw (private windows, strict settings): wrap them in try/catch and keep only what can be lost.`
133
+ : `localStorage is shared by every ${it} in this ${config.storage.shared}, in this browser (another device starts fresh): prefix your keys with a name of this ${it}'s own ("budget:filters", not "filters"), or ${it}s overwrite each other. Reads and writes can throw (private windows, strict settings): wrap them in try/catch and keep only what can be lost.`);
134
+ // Where the content's errors go (P065): never promise feedback that doesn't come.
135
+ const reports = config.limits.maxErrorReports;
136
+ const rate = (config.capabilities.reportError?.gates ?? [{ rateLimit: { perMinute: 10 } }]).find((gate) => typeof gate === "object");
137
+ const atMost = rate === undefined ? "" : ` At most ${rate.rateLimit.perMinute} a minute.`;
138
+ add("errors", !declared.has("reportError")
139
+ ? "Errors: nothing reports what your code throws, to you or the app: test what you write."
140
+ : config.errors === "agent"
141
+ ? `Errors: what your code throws, and anything the sandbox blocks, comes back to you (at most ${reports} a run), so you can fix it.${atMost}`
142
+ : config.errors === "host"
143
+ ? `Errors: what your code throws, and anything the sandbox blocks, goes to the app (at most ${reports} a run), not to you: test what you write.${atMost}`
144
+ : `Errors: what your code throws, and anything the sandbox blocks, is reported to the app (at most ${reports} a run).${atMost}`);
145
+ return out;
146
+ }
147
+ /** A byte limit in words: KB under a megabyte, MB above. */
148
+ export function sizeWords(bytes) {
149
+ return bytes < 1_000_000
150
+ ? `${Math.floor(bytes / 1000)} KB`
151
+ : `${Math.round((bytes / 1_048_576) * 10) / 10} MB`;
152
+ }
@@ -67,7 +67,9 @@ export function createHost(config, options = {}) {
67
67
  emit("hostError", { message, kind });
68
68
  emit("error", { message, source: "host" });
69
69
  };
70
- let scheme = mount.scheme;
70
+ // A page the host doesn't theme (`documents: "never"`, P065) is always given light.
71
+ const themeless = config.theme.documents === "never";
72
+ let scheme = themeless ? "light" : mount.scheme;
71
73
  let height = mount.initialHeightPx ?? 0;
72
74
  let surface = null;
73
75
  let connection = null;
@@ -271,8 +273,9 @@ export function createHost(config, options = {}) {
271
273
  referrerPolicy: compiled.referrerPolicy,
272
274
  colorScheme: scheme,
273
275
  // An app reports no size: its frame fills the host's container, at least the minimum;
274
- // so does any display the host mounts to fill (its content scrolls inside).
275
- ...(speaks && mount.fill !== true
276
+ // so does any display the host mounts to fill, or whose layout fills (P065): its
277
+ // content scrolls itself.
278
+ ...(speaks && mount.fill !== true && config.layout.kind !== "fill"
276
279
  ? { initialHeightPx: height }
277
280
  : { fill: true, initialHeightPx: config.limits.minHeightPx }),
278
281
  });
@@ -386,6 +389,9 @@ export function createHost(config, options = {}) {
386
389
  displayId: mount.displayId,
387
390
  annotations,
388
391
  setTheme(next) {
392
+ if (themeless) {
393
+ return;
394
+ }
389
395
  scheme = next;
390
396
  surface?.setColorScheme(next);
391
397
  // Read the host's values two frames later: a host that restyles its page for the new
@@ -21,7 +21,7 @@ export interface PresetOptions {
21
21
  readonly theme?: {
22
22
  readonly variables?: Readonly<Record<string, ThemeValue | null>>;
23
23
  readonly event?: string;
24
- readonly documents?: "declared" | "always";
24
+ readonly documents?: "declared" | "always" | "never";
25
25
  };
26
26
  readonly limits?: Partial<Limits>;
27
27
  readonly layout?: Partial<LayoutConfig>;
@@ -32,6 +32,8 @@ export interface PresetOptions {
32
32
  readonly embedder?: EmbedderConfig;
33
33
  /** Domain isolation: what displays sharing an origin share (`StorageConfig`), for the manual. */
34
34
  readonly storage?: StorageConfig;
35
+ /** Where the content's errors go (`PortholeConfig.errors`); `none` drops `reportError`. */
36
+ readonly errors?: "agent" | "host" | "none";
35
37
  }
36
38
  /** The url preset's options: where the app is served, and the embedding page's extra frames. */
37
39
  export interface UrlPresetOptions {
@@ -83,6 +83,9 @@ function build(mode, defaults, options) {
83
83
  const capabilities = {
84
84
  ...BASE_CAPABILITIES,
85
85
  };
86
+ if (options.errors === "none") {
87
+ delete capabilities.reportError;
88
+ }
86
89
  for (const [name, value] of Object.entries(options.capabilities ?? {})) {
87
90
  if (value === false) {
88
91
  delete capabilities[name];
@@ -107,6 +110,7 @@ function build(mode, defaults, options) {
107
110
  contentOrigins: options.contentOrigins ?? [],
108
111
  embedder: options.embedder ?? {},
109
112
  ...(options.storage === undefined ? {} : { storage: options.storage }),
113
+ ...(options.errors === undefined ? {} : { errors: options.errors }),
110
114
  });
111
115
  }
112
116
  export const presets = {
@@ -147,7 +147,11 @@ export function PortholeFrame(props) {
147
147
  position: "relative",
148
148
  width: "100%",
149
149
  // A display that fills takes its parent's height (the host sizes the parent, or this box).
150
- ...(props.fill === true || props.config.mode === "url" ? { height: "100%" } : {}),
150
+ ...(props.fill === true ||
151
+ props.config.mode === "url" ||
152
+ props.config.layout.kind === "fill"
153
+ ? { height: "100%" }
154
+ : {}),
151
155
  ...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
152
156
  ...props.style,
153
157
  }, children: state === "running" ? (_jsx(Mount, { frame: props, page: page, initialHeightPx: height, placeholder: placeholder, run: run }, String(props.generation ?? 0))) : (placeholder({ state, title: props.title, run })) }));
@@ -163,7 +167,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
163
167
  // By value: a config or instance made inline (each render) is the same display.
164
168
  const config = useByValue(frame.config);
165
169
  const instance = useByValue(frame.instance);
166
- const fills = config.mode === "url" || frame.fill === true;
170
+ const fills = config.mode === "url" || frame.fill === true || config.layout.kind === "fill";
167
171
  // The latest props, read when the display calls back (never during render).
168
172
  const latest = useEffectEvent(() => frame);
169
173
  const notify = useEffectEvent((event, detail) => {
@@ -27,7 +27,7 @@ export interface ShellPlan {
27
27
  /** The window event fired after a theme change. */
28
28
  readonly themeEvent: string;
29
29
  /** Whether a whole document takes the person's scheme: when it declares it, or always. */
30
- readonly themeDocuments: "declared" | "always";
30
+ readonly themeDocuments: "declared" | "always" | "never";
31
31
  /** `memory`: an opaque origin has no storage, so a map stands in for the frame's life. */
32
32
  readonly storage: "memory" | "native";
33
33
  readonly maxErrorReports: number;
@@ -567,7 +567,7 @@ export const SHELL_RUNTIME = String.raw `(() => {
567
567
  document.body.replaceWith(document.importNode(parsed.body, true));
568
568
  root = null;
569
569
  const declaredScheme = parsed.querySelector('meta[name="color-scheme" i]');
570
- themed = plan.themeDocuments === "always" || (declaredScheme !== null && /\bdark\b/i.test(declaredScheme.getAttribute("content") || ""));
570
+ themed = plan.themeDocuments !== "never" && (plan.themeDocuments === "always" || (declaredScheme !== null && /\bdark\b/i.test(declaredScheme.getAttribute("content") || "")));
571
571
  schemeFollows();
572
572
  await runScripts(scripts);
573
573
  announceLoaded();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/porthole",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "A host-side sandbox for agent-made HTML: isolation, egress hardening, a themed shell, gated host functions, and the agent manual, from one config.",
5
5
  "license": "Apache-2.0",
6
6
  "files": [