@botiverse/porthole 0.1.2 → 0.1.3

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 (64) hide show
  1. package/dist/annotate/types.d.ts +120 -0
  2. package/dist/annotate/types.js +42 -0
  3. package/dist/annotate/validate.d.ts +11 -0
  4. package/dist/annotate/validate.js +156 -0
  5. package/dist/bridge/bridge.d.ts +4 -0
  6. package/dist/bridge/bridge.js +12 -1
  7. package/dist/compile/policy.d.ts +4 -3
  8. package/dist/compile/policy.js +7 -4
  9. package/dist/config/capabilities.d.ts +37 -4
  10. package/dist/config/capabilities.js +20 -0
  11. package/dist/conformance/clipboard.d.ts +2 -0
  12. package/dist/conformance/clipboard.js +167 -0
  13. package/dist/conformance/kit.d.ts +1 -1
  14. package/dist/conformance/probes.js +2 -0
  15. package/dist/guide/guide.js +8 -4
  16. package/dist/host/clipboard.d.ts +11 -0
  17. package/dist/host/clipboard.js +43 -0
  18. package/dist/host/create-host.js +141 -0
  19. package/dist/host/types.d.ts +24 -0
  20. package/dist/index.d.ts +6 -3
  21. package/dist/index.js +6 -2
  22. package/dist/mcp/adapter.js +10 -1
  23. package/dist/protocol/mcp-apps.d.ts +5 -0
  24. package/dist/protocol/mcp-apps.js +5 -0
  25. package/dist/protocol/shell-adapter.js +6 -2
  26. package/dist/react/dialog.js +2 -0
  27. package/dist/react/frame.d.ts +18 -0
  28. package/dist/react/frame.js +82 -2
  29. package/dist/react/index.d.ts +2 -1
  30. package/dist/react/index.js +2 -1
  31. package/dist/react/toast.d.ts +33 -0
  32. package/dist/react/toast.js +42 -0
  33. package/dist/shell/annotate.d.ts +20 -0
  34. package/dist/shell/annotate.js +669 -0
  35. package/dist/shell/document.js +4 -1
  36. package/dist/shell/plan.d.ts +2 -0
  37. package/dist/shell/plan.js +2 -1
  38. package/dist/shell/runtime.d.ts +4 -1
  39. package/dist/shell/runtime.js +38 -2
  40. package/package.json +1 -1
  41. package/src/annotate/types.ts +126 -0
  42. package/src/annotate/validate.ts +186 -0
  43. package/src/bridge/bridge.ts +13 -0
  44. package/src/compile/policy.ts +7 -4
  45. package/src/config/capabilities.ts +33 -3
  46. package/src/conformance/clipboard.ts +184 -0
  47. package/src/conformance/kit.ts +1 -1
  48. package/src/conformance/probes.ts +2 -0
  49. package/src/guide/guide.ts +19 -4
  50. package/src/host/clipboard.ts +41 -0
  51. package/src/host/create-host.ts +155 -0
  52. package/src/host/types.ts +25 -0
  53. package/src/index.ts +22 -0
  54. package/src/mcp/adapter.ts +10 -1
  55. package/src/protocol/mcp-apps.ts +6 -0
  56. package/src/protocol/shell-adapter.ts +11 -2
  57. package/src/react/dialog.tsx +2 -0
  58. package/src/react/frame.tsx +117 -1
  59. package/src/react/index.ts +9 -1
  60. package/src/react/toast.tsx +96 -0
  61. package/src/shell/annotate.ts +669 -0
  62. package/src/shell/document.ts +6 -1
  63. package/src/shell/plan.ts +4 -1
  64. package/src/shell/runtime.ts +38 -2
@@ -0,0 +1,167 @@
1
+ import { expect } from "@playwright/test";
2
+ import { inline } from "./kit.js";
3
+ import { settle } from "./oracles.js";
4
+ /**
5
+ * The clipboard (item 13, P051): content can't write it, whatever it tries in a person's click,
6
+ * and what a person copies themselves is what they selected. `copyText` is the way copy buttons
7
+ * work instead (item 12, P052). The oracle is the person's own clipboard: known text put on it
8
+ * from the host's page, then pasted back into a text area there.
9
+ */
10
+ const SEED = "porthole-seed";
11
+ /** A text area of the oracle's own on the host's page. */
12
+ async function area(page, id) {
13
+ await page.evaluate((each) => {
14
+ if (document.getElementById(each) === null) {
15
+ const element = document.createElement("textarea");
16
+ element.id = each;
17
+ element.style.cssText = "display:block;width:200px;height:40px";
18
+ document.body.prepend(element);
19
+ }
20
+ }, id);
21
+ }
22
+ /** Puts `text` on the clipboard the way a person does: selects it on the host's page, copies. */
23
+ async function seed(page, text) {
24
+ await area(page, "porthole-oracle-seed");
25
+ await page.evaluate((each) => {
26
+ document.getElementById("porthole-oracle-seed").value = each;
27
+ }, text);
28
+ await page.locator("#porthole-oracle-seed").click();
29
+ await page.keyboard.press("ControlOrMeta+A");
30
+ await page.keyboard.press("ControlOrMeta+C");
31
+ }
32
+ /** What is on the clipboard: pasted into a text area on the host's page. */
33
+ async function pasted(page) {
34
+ await area(page, "porthole-oracle-paste");
35
+ await page.evaluate(() => {
36
+ document.getElementById("porthole-oracle-paste").value = "";
37
+ });
38
+ await page.locator("#porthole-oracle-paste").click();
39
+ await page.keyboard.press("ControlOrMeta+V");
40
+ await settle(200);
41
+ return page.evaluate(() => document.getElementById("porthole-oracle-paste").value);
42
+ }
43
+ /** Clicks `selector` in the display (a real click: the user activation a person gives). */
44
+ async function click(display, selector) {
45
+ await (await display.frame()).locator(selector).click();
46
+ }
47
+ async function bodyData(display, name) {
48
+ const frame = await display.frame();
49
+ await expect
50
+ .poll(() => frame.locator("body").getAttribute(`data-${name}`), { timeout: 5_000 })
51
+ .not.toBeNull();
52
+ return (await frame.locator("body").getAttribute(`data-${name}`)) ?? "";
53
+ }
54
+ const settled = (name) => `(value) => (document.body.dataset.${name} = String(value)), (error) => (document.body.dataset.${name} = "refused: " + (error && error.name))`;
55
+ export const CLIPBOARD_PROBES = [
56
+ {
57
+ id: "13.clipboard-api",
58
+ item: 13,
59
+ title: "content can't write (or read) the clipboard through the async Clipboard API, even in a click",
60
+ async run(context) {
61
+ const display = await context.launch({
62
+ config: inline(),
63
+ content: `<button id="go">copy</button>
64
+ <script>
65
+ document.getElementById("go").addEventListener("click", () => {
66
+ navigator.clipboard.writeText("porthole-pwned-text").then(${settled("text")});
67
+ if (typeof ClipboardItem === "function") {
68
+ navigator.clipboard.write([new ClipboardItem({ "text/plain": new Blob(["porthole-pwned-item"], { type: "text/plain" }) })]).then(${settled("item")});
69
+ } else {
70
+ document.body.dataset.item = "refused: no ClipboardItem";
71
+ }
72
+ navigator.clipboard.readText().then(${settled("read")});
73
+ });
74
+ </script>`,
75
+ });
76
+ await seed(context.page, SEED);
77
+ await click(display, "#go");
78
+ expect(await bodyData(display, "text")).toMatch(/^refused/u);
79
+ expect(await bodyData(display, "item")).toMatch(/^refused/u);
80
+ expect(await bodyData(display, "read")).toMatch(/^refused/u);
81
+ expect(await pasted(context.page), "the clipboard still holds the person's own").toBe(SEED);
82
+ },
83
+ },
84
+ {
85
+ id: "13.clipboard-command",
86
+ item: 13,
87
+ title: "content can't copy or cut its own text onto the clipboard with execCommand, even in a click",
88
+ async run(context) {
89
+ const display = await context.launch({
90
+ config: inline(),
91
+ content: `<button id="go">copy</button>
92
+ <script>
93
+ document.getElementById("go").addEventListener("click", () => {
94
+ const area = document.createElement("textarea");
95
+ area.value = "porthole-pwned-command";
96
+ area.style.cssText = "position:absolute;left:-9999px";
97
+ document.body.append(area);
98
+ area.select();
99
+ let copied = "threw";
100
+ let cut = "threw";
101
+ try { copied = document.execCommand("copy"); } catch (_) {}
102
+ area.select();
103
+ try { cut = document.execCommand("cut"); } catch (_) {}
104
+ document.body.dataset.copied = String(copied) + "," + String(cut);
105
+ });
106
+ </script>`,
107
+ });
108
+ await seed(context.page, SEED);
109
+ await click(display, "#go");
110
+ expect(await bodyData(display, "copied")).not.toContain("true");
111
+ expect(await pasted(context.page), "the clipboard still holds the person's own").toBe(SEED);
112
+ },
113
+ },
114
+ {
115
+ id: "13.clipboard-event",
116
+ item: 13,
117
+ title: "what a person copies is what they selected: no copy listener of the content's can swap it",
118
+ async run(context) {
119
+ const display = await context.launch({
120
+ config: inline(),
121
+ content: `<p id="safe">porthole-selected-text</p>
122
+ <script>
123
+ const swap = (event) => {
124
+ event.clipboardData.setData("text/plain", "porthole-pwned-event");
125
+ event.preventDefault();
126
+ document.body.dataset.ran = "yes";
127
+ };
128
+ document.addEventListener("copy", swap);
129
+ document.addEventListener("copy", swap, true);
130
+ window.addEventListener("copy", swap);
131
+ document.getElementById("safe").oncopy = swap;
132
+ </script>`,
133
+ });
134
+ await seed(context.page, SEED);
135
+ const frame = await display.frame();
136
+ await frame.locator("#safe").click({ clickCount: 3 });
137
+ await context.page.keyboard.press("ControlOrMeta+C");
138
+ await settle(200);
139
+ // The positive control: the person's own copy works, and copies what they selected.
140
+ expect((await pasted(context.page)).trim()).toBe("porthole-selected-text");
141
+ expect(await frame.locator("body").getAttribute("data-ran")).toBeNull();
142
+ },
143
+ },
144
+ {
145
+ id: "12.copy-text",
146
+ item: 12,
147
+ title: "copyText reaches the host's handler from a click, with the whole text, never without one",
148
+ async run(context) {
149
+ const display = await context.launch({
150
+ config: inline({ capabilities: { copyText: {} } }),
151
+ content: `<button id="copy">Copy the command</button>
152
+ <script>
153
+ document.getElementById("copy").addEventListener("click", () => {
154
+ copyText("npm test -- --run").then(${settled("result")});
155
+ });
156
+ copyText("copied on load").then(${settled("onload")});
157
+ </script>`,
158
+ });
159
+ expect(await bodyData(display, "onload")).toMatch(/^refused/u);
160
+ await click(display, "#copy");
161
+ expect(await bodyData(display, "result")).toBe("true");
162
+ expect(await display.calls()).toEqual([
163
+ { capability: "copyText", args: { text: "npm test -- --run" } },
164
+ ]);
165
+ },
166
+ },
167
+ ];
@@ -26,7 +26,7 @@ export interface Probe {
26
26
  readonly id: string;
27
27
  /**
28
28
  * The DESIGN.md egress-table item it proves; 0 for the shell's own wiring and interop, 11 for
29
- * behavior (streaming, sizing, theming), 12 for host-function gates.
29
+ * behavior (streaming, sizing, theming), 12 for host-function gates, 13 for the clipboard.
30
30
  */
31
31
  readonly item: number;
32
32
  readonly title: string;
@@ -3,6 +3,7 @@ import { presets } from "../presets/presets.js";
3
3
  import { resolveTheme } from "../theme/variables.js";
4
4
  import { attack, inline, js } from "./kit.js";
5
5
  import { BEHAVIOR_PROBES } from "./behavior.js";
6
+ import { CLIPBOARD_PROBES } from "./clipboard.js";
6
7
  import { readFileSync } from "node:fs";
7
8
  import { createRequire } from "node:module";
8
9
  import { httpOracle, settle, udpOracle, } from "./oracles.js";
@@ -817,6 +818,7 @@ export const PROBES = [
817
818
  },
818
819
  },
819
820
  ...BEHAVIOR_PROBES,
821
+ ...CLIPBOARD_PROBES,
820
822
  ];
821
823
  /** Every probe as a test entry (see `ConformanceTest`). */
822
824
  export function leakTests(host, options = {}) {
@@ -1,4 +1,4 @@
1
- import { capabilitySpec } from "../config/capabilities.js";
1
+ import { MAX_COPY_CHARS, capabilitySpec, } from "../config/capabilities.js";
2
2
  import { BUILT_IN_TOPICS } from "../config/validate.js";
3
3
  import { declaredCapabilities, effectiveGates, egressOrigins, sandboxTokens, } from "../compile/policy.js";
4
4
  import { shellPlan } from "../shell/plan.js";
@@ -67,7 +67,7 @@ function network(config) {
67
67
  const optsIn = (config) => config.mode === "artifact" && (config.theme.documents ?? "declared") === "declared";
68
68
  const loadsScripts = (config) => egressOrigins(config, "script").length > 0;
69
69
  /** What the sandbox withholds, from the sandbox tokens and the shell's own walls. */
70
- function forbidden(config) {
70
+ function forbidden(config, c) {
71
71
  const tokens = sandboxTokens(config);
72
72
  const items = ["position: fixed", "full screen"];
73
73
  if (!tokens.includes("allow-modals")) {
@@ -82,7 +82,7 @@ function forbidden(config) {
82
82
  if (!tokens.includes("allow-downloads")) {
83
83
  items.push("downloads");
84
84
  }
85
- items.push("clipboard writes", "frames (iframe, object, embed)", "WebRTC");
85
+ items.push(c.global("copyText") ? "the clipboard (copy buttons call copyText)" : "the clipboard", "frames (iframe, object, embed)", "WebRTC");
86
86
  return `Not allowed: ${items.join(", ")}.`;
87
87
  }
88
88
  function widget(c) {
@@ -102,7 +102,7 @@ function widget(c) {
102
102
  const smallest = Math.round(((13 * layout.minWidthPx) / viewBox) * 10) / 10;
103
103
  const largest = Math.round(((13 * 400) / viewBox) * 10) / 10;
104
104
  const textSize = themeDefault(config.theme.variables["--font-text-md-size"] ?? "14px", "light").replace(/^(\d+(?:\.\d+)?)px$/u, "$1 px");
105
- 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)} ${network(config)} Keep it under ${Math.floor(limits.maxBytes / 1000)} KB.`, loadsScripts(config)
105
+ 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)
106
106
  ? "\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."
107
107
  : null, "", optsIn(config)
108
108
  ? `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).`
@@ -189,6 +189,10 @@ const ENTRIES = {
189
189
  callTool: (_c, gates) => `- Tools: the MCP Apps request tools/call (with the App SDK, app.callServerTool). ${gateWords(gates, "the call")}`,
190
190
  readData: (_c, gates) => `- Data: the MCP Apps request resources/read (with the App SDK, app.readServerResource). ${gateWords(gates, "the request")}`,
191
191
  setModelContext: (_c, gates) => `- Context for the agent: the MCP Apps request ui/update-model-context (with the App SDK, app.updateModelContext). ${gateWords(gates, "it")}`,
192
+ copyText: (c, gates) => c.global("copyText")
193
+ ? `- 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.`
194
+ : `- 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")}`,
195
+ 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.`,
192
196
  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(),
193
197
  resize: () => "",
194
198
  };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Writing the clipboard from the host's page, for a `copyText` handler (P052): content never
3
+ * writes it itself (P051). The person's click in the frame gives the host's page user activation
4
+ * (it propagates to ancestors; measured on Chromium, WebKit and Firefox), which the write needs.
5
+ */
6
+ /**
7
+ * Writes `text` to the clipboard: the async Clipboard API, else a selected text area and the copy
8
+ * command. Resolves whether it worked; it can fail without a recent gesture, so a host shows the
9
+ * text either way (and offers its own Copy button when it failed).
10
+ */
11
+ export declare function writeClipboard(text: string, document: Document): Promise<boolean>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Writing the clipboard from the host's page, for a `copyText` handler (P052): content never
3
+ * writes it itself (P051). The person's click in the frame gives the host's page user activation
4
+ * (it propagates to ancestors; measured on Chromium, WebKit and Firefox), which the write needs.
5
+ */
6
+ /**
7
+ * Writes `text` to the clipboard: the async Clipboard API, else a selected text area and the copy
8
+ * command. Resolves whether it worked; it can fail without a recent gesture, so a host shows the
9
+ * text either way (and offers its own Copy button when it failed).
10
+ */
11
+ export async function writeClipboard(text, document) {
12
+ const view = document.defaultView;
13
+ try {
14
+ if (view?.navigator.clipboard !== undefined) {
15
+ await view.navigator.clipboard.writeText(text);
16
+ return true;
17
+ }
18
+ }
19
+ catch {
20
+ // Fall through to the copy command.
21
+ }
22
+ const focused = document.activeElement;
23
+ const area = document.createElement("textarea");
24
+ area.value = text;
25
+ area.setAttribute("readonly", "");
26
+ area.setAttribute("aria-hidden", "true");
27
+ area.style.cssText =
28
+ "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;border:0;padding:0";
29
+ try {
30
+ document.body.append(area);
31
+ area.select();
32
+ return document.execCommand("copy");
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ finally {
38
+ area.remove();
39
+ if (focused !== null && typeof focused.focus === "function") {
40
+ focused.focus({ preventScroll: true });
41
+ }
42
+ }
43
+ }
@@ -1,3 +1,5 @@
1
+ import { ANNOTATE_METHODS, } from "../annotate/types.js";
2
+ import { annotateMode, checkMarks, parseReport } from "../annotate/validate.js";
1
3
  import { createBridge } from "../bridge/bridge.js";
2
4
  import { compile } from "../compile/compile.js";
3
5
  import { declaredCapabilities } from "../compile/policy.js";
@@ -13,6 +15,14 @@ const DEFAULT_LOAD_TIMEOUT_MS = 15_000;
13
15
  /** How many times a display is started again after its content navigated its frame away. */
14
16
  const MAX_RESTARTS = 3;
15
17
  const BASE_KEYS = new Set(["hostContextChanged", "teardown", "close"]);
18
+ /** The frame's annotation reports, by method. */
19
+ const REPORTS = {
20
+ [ANNOTATE_METHODS.selected]: "selected",
21
+ [ANNOTATE_METHODS.unselected]: "unselected",
22
+ [ANNOTATE_METHODS.cancelled]: "cancelled",
23
+ [ANNOTATE_METHODS.anchored]: "anchored",
24
+ };
25
+ const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
16
26
  const STANDARD = new Set(MCP_APPS_STYLE_VARIABLES);
17
27
  /**
18
28
  * Compiles `config` once and returns a host that mounts displays under it. Throws a `ConfigError`
@@ -60,6 +70,125 @@ export function createHost(config, options = {}) {
60
70
  const view = container.ownerDocument.defaultView;
61
71
  // The adapter's own operations, the last call of each, replayed into every new frame.
62
72
  const replay = new Map();
73
+ // Annotations: the host's state, given to each frame once it finished its handshake.
74
+ const annotates = config.capabilities.annotate !== undefined;
75
+ let annotating = null;
76
+ let marks = [];
77
+ let activeMark = null;
78
+ let handshaken = false;
79
+ const toFrame = (method, params) => {
80
+ if (handshaken && surface !== null) {
81
+ surface.transport.send({ jsonrpc: "2.0", method, params });
82
+ }
83
+ };
84
+ const giveAnnotations = () => {
85
+ if (marks.length > 0) {
86
+ toFrame(ANNOTATE_METHODS.show, {
87
+ marks,
88
+ ...(activeMark === null ? {} : { active: activeMark }),
89
+ });
90
+ }
91
+ if (annotating !== null) {
92
+ toFrame(ANNOTATE_METHODS.start, { ...annotating });
93
+ }
94
+ };
95
+ /**
96
+ * A report from the frame: checked, taken only while the host asked for it (a selection
97
+ * only in select mode, results only for its own marks), then through the bridge's gates.
98
+ */
99
+ const onAnnotation = (raw, bridge) => {
100
+ if (!isRecord(raw) ||
101
+ raw.jsonrpc !== "2.0" ||
102
+ "id" in raw ||
103
+ typeof raw.method !== "string") {
104
+ return;
105
+ }
106
+ const type = REPORTS[raw.method];
107
+ if (type === undefined) {
108
+ return;
109
+ }
110
+ let report = parseReport({ ...(isRecord(raw.params) ? raw.params : {}), type });
111
+ if (report === null) {
112
+ void bridge.call("annotate", null);
113
+ return;
114
+ }
115
+ switch (report.type) {
116
+ case "selected": {
117
+ const kind = report.anchor.kind;
118
+ if (annotating === null ||
119
+ !(kind === "element" ? annotating.elements : annotating.text)) {
120
+ return;
121
+ }
122
+ if (kind === "element") {
123
+ annotating = annotating.text ? { elements: false, text: true } : null;
124
+ }
125
+ break;
126
+ }
127
+ case "unselected":
128
+ if (annotating?.text !== true) {
129
+ return;
130
+ }
131
+ break;
132
+ case "cancelled":
133
+ if (annotating?.elements !== true) {
134
+ return;
135
+ }
136
+ annotating = annotating.text ? { elements: false, text: true } : null;
137
+ break;
138
+ case "anchored": {
139
+ const known = new Set(marks.map((mark) => mark.id));
140
+ const results = report.results.filter((result) => known.has(result.id));
141
+ if (results.length === 0) {
142
+ return;
143
+ }
144
+ report = { type: "anchored", results };
145
+ break;
146
+ }
147
+ }
148
+ void bridge.call("annotate", report);
149
+ };
150
+ const needsAnnotate = (operation) => {
151
+ if (!annotates) {
152
+ throw new TypeError(`annotations.${operation}: the config doesn't declare annotate`);
153
+ }
154
+ };
155
+ const annotations = {
156
+ start(mode) {
157
+ needsAnnotate("start");
158
+ annotating = annotateMode(mode);
159
+ toFrame(ANNOTATE_METHODS.start, { ...annotating });
160
+ },
161
+ stop() {
162
+ needsAnnotate("stop");
163
+ annotating = null;
164
+ toFrame(ANNOTATE_METHODS.stop, {});
165
+ },
166
+ show(next) {
167
+ needsAnnotate("show");
168
+ marks = checkMarks(next);
169
+ if (activeMark !== null && !marks.some((mark) => mark.id === activeMark)) {
170
+ activeMark = null;
171
+ }
172
+ toFrame(ANNOTATE_METHODS.show, {
173
+ marks,
174
+ ...(activeMark === null ? {} : { active: activeMark }),
175
+ });
176
+ },
177
+ focus(id) {
178
+ needsAnnotate("focus");
179
+ if (!marks.some((mark) => mark.id === id)) {
180
+ throw new TypeError(`annotations.focus: no mark ${JSON.stringify(id)} is shown`);
181
+ }
182
+ activeMark = id;
183
+ toFrame(ANNOTATE_METHODS.focus, { id });
184
+ },
185
+ clear() {
186
+ needsAnnotate("clear");
187
+ marks = [];
188
+ activeMark = null;
189
+ toFrame(ANNOTATE_METHODS.clear, {});
190
+ },
191
+ };
63
192
  const hostContext = () => {
64
193
  const width = container.getBoundingClientRect().width;
65
194
  // The spec's names in `styles` (the App SDK refuses others there), the rest in our field.
@@ -107,9 +236,13 @@ export function createHost(config, options = {}) {
107
236
  case "muted":
108
237
  emit("muted", undefined);
109
238
  return;
239
+ case "annotation":
240
+ emit("annotation", event.report);
241
+ return;
110
242
  }
111
243
  };
112
244
  const stop = () => {
245
+ handshaken = false;
113
246
  abort?.abort();
114
247
  abort = null;
115
248
  outcomeWatch?.stop();
@@ -160,6 +293,9 @@ export function createHost(config, options = {}) {
160
293
  userActivation: () => created.userActivation(),
161
294
  onEvent: onBridge,
162
295
  });
296
+ if (annotates) {
297
+ created.transport.listen((raw) => onAnnotation(raw, bridge));
298
+ }
163
299
  const opened = adapter.connect({
164
300
  display: { id: mount.displayId, title: mount.title },
165
301
  compiled,
@@ -170,6 +306,10 @@ export function createHost(config, options = {}) {
170
306
  emit: (event) => {
171
307
  if (event.type === "initialized") {
172
308
  watch.initialized();
309
+ if (!handshaken) {
310
+ handshaken = true;
311
+ giveAnnotations();
312
+ }
173
313
  emit("initialized", undefined);
174
314
  }
175
315
  },
@@ -232,6 +372,7 @@ export function createHost(config, options = {}) {
232
372
  run();
233
373
  const controls = {
234
374
  displayId: mount.displayId,
375
+ annotations,
235
376
  setTheme(next) {
236
377
  scheme = next;
237
378
  surface?.setColorScheme(next);
@@ -1,3 +1,4 @@
1
+ import type { AnnotateMode, AnnotationMark, AnnotationReport } from "../annotate/types.ts";
1
2
  import type { Confirm, CapabilityHandlers, Refusal } from "../bridge/types.ts";
2
3
  import type { CompiledConfig } from "../compile/compile.ts";
3
4
  import type { CapabilityName } from "../config/capabilities.ts";
@@ -104,6 +105,27 @@ export interface DisplayEvents {
104
105
  };
105
106
  /** Too many declines: the display stops asking until it runs again. */
106
107
  readonly muted: undefined;
108
+ /**
109
+ * What the frame reported about annotations (`annotate`, P053), checked and taken only while
110
+ * the host asked for it: a selection, its clearing, a cancelled pick, where marks are. The
111
+ * content's word: UI hints, never permissions.
112
+ */
113
+ readonly annotation: AnnotationReport;
114
+ }
115
+ /**
116
+ * Annotations on a display whose config declares `annotate` (P053). The host's state is kept and
117
+ * given to every frame the display runs (a reload, a navigation recovered from).
118
+ */
119
+ export interface AnnotationControls {
120
+ /** Lets the person point at elements, select text, or both (default both). */
121
+ start(mode?: AnnotateMode): void;
122
+ stop(): void;
123
+ /** Marks these anchors (replacing the marks before); the frame reports where they are. */
124
+ show(marks: readonly AnnotationMark[]): void;
125
+ /** Brings a mark into view in the frame and makes it the active one. */
126
+ focus(id: string): void;
127
+ /** Removes every mark. */
128
+ clear(): void;
107
129
  }
108
130
  export interface DisplayControls {
109
131
  readonly displayId: string;
@@ -115,6 +137,8 @@ export interface DisplayControls {
115
137
  /** The host's theme values changed (same scheme): read them now and re-theme the frame. */
116
138
  refreshTheme(): void;
117
139
  on<E extends keyof DisplayEvents>(event: E, listener: (detail: DisplayEvents[E]) => void): () => void;
140
+ /** Annotations (`annotate`): throws on use where the config doesn't declare it. */
141
+ readonly annotations: AnnotationControls;
118
142
  /** Runs the content again from the start, in a new frame. */
119
143
  reload(): void;
120
144
  /** Tears the view down and removes the frame. */
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * `@botiverse/porthole`: the framework-agnostic core. One config (a preset's) compiles into the
3
3
  * frame's policies, the shell, and the agent manual; the host mounts displays under it.
4
4
  */
5
- export { CAPABILITIES, CAPABILITY_NAMES, capabilitySpec, isCapabilityName, type CapabilityArgs, type CapabilityName, type CapabilityResults, type CapabilitySpec, type Gate, type HandledCapability, } from "./config/capabilities.ts";
5
+ export { CAPABILITIES, CAPABILITY_NAMES, MAX_COPY_CHARS, capabilitySpec, isCapabilityName, type CapabilityArgs, type CapabilityName, type CapabilityResults, type CapabilitySpec, type Gate, type HandledCapability, } from "./config/capabilities.ts";
6
6
  export type { CapabilityConfig, CapabilityMap, ColorScheme, DisplayMode, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeConfig, ThemeValue, } from "./config/schema.ts";
7
7
  export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.ts";
8
8
  export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, type UrlPermission, type UrlSandboxExtra, } from "./config/schema.ts";
@@ -20,7 +20,10 @@ export type { FrameLocation, FrameSource, InstanceLocator, InstanceRef, ServedDo
20
20
  export type { PlatformHost, Surface, SurfaceSpec } from "./platform/types.ts";
21
21
  export type { Transport } from "./transport/types.ts";
22
22
  export type { AdapterConnection, AdapterEvent, AdapterSession, ProtocolAdapter, ShellConnection, } from "./protocol/adapter.ts";
23
- export { MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, type HostContext, } from "./protocol/mcp-apps.ts";
23
+ export { COPY_TEXT_METHOD, MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, type HostContext, } from "./protocol/mcp-apps.ts";
24
24
  export type { CallContext, CallOutcome, CapabilityHandler, CapabilityHandlers, Confirm, ConfirmAnswer, ConfirmRequest, Proceed, HostFunctionBridge, Refusal, } from "./bridge/types.ts";
25
25
  export { createHost } from "./host/create-host.ts";
26
- export type { DisplayControls, DisplayEvents, FrameBlock, FrameOutcome, DisplayHandle, HostOptions, MountOptions, PortholeHost, } from "./host/types.ts";
26
+ export { writeClipboard } from "./host/clipboard.ts";
27
+ export type { AnnotationControls, DisplayControls, DisplayEvents, FrameBlock, FrameOutcome, DisplayHandle, HostOptions, MountOptions, PortholeHost, } from "./host/types.ts";
28
+ export { ANNOTATE_METHODS, ANNOTATION_LIMITS, type AnchorResult, type AnchorState, type AnnotateMode, type AnnotationAnchor, type AnnotationMark, type AnnotationRect, type AnnotationReport, type AnnotationSelector, type CssSelector, type TextPositionSelector, type TextQuoteSelector, } from "./annotate/types.ts";
29
+ export { parseAnchor, parseRect, parseReport } from "./annotate/validate.ts";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * frame's policies, the shell, and the agent manual; the host mounts displays under it.
4
4
  */
5
5
  // The config and its vocabulary.
6
- export { CAPABILITIES, CAPABILITY_NAMES, capabilitySpec, isCapabilityName, } from "./config/capabilities.js";
6
+ export { CAPABILITIES, CAPABILITY_NAMES, MAX_COPY_CHARS, capabilitySpec, isCapabilityName, } from "./config/capabilities.js";
7
7
  export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.js";
8
8
  export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, } from "./config/schema.js";
9
9
  export { hostOriginProblem, isHostOrigin } from "./isolation/served.js";
@@ -18,5 +18,9 @@ export { generateGuide, generateToolDescription, } from "./guide/guide.js";
18
18
  export { GUIDE_EXAMPLES } from "./guide/examples.js";
19
19
  // Theme.
20
20
  export { CHART_PALETTE, DEFAULT_THEME, DEFAULT_THEME_EVENT, DEFAULT_THEME_VARIABLES, resolveTheme, themeDefault, } from "./theme/variables.js";
21
- export { MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, } from "./protocol/mcp-apps.js";
21
+ export { COPY_TEXT_METHOD, MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, } from "./protocol/mcp-apps.js";
22
22
  export { createHost } from "./host/create-host.js";
23
+ export { writeClipboard } from "./host/clipboard.js";
24
+ // Annotations (P053): anchors, what a frame reports, and the host's checks on both.
25
+ export { ANNOTATE_METHODS, ANNOTATION_LIMITS, } from "./annotate/types.js";
26
+ export { parseAnchor, parseRect, parseReport } from "./annotate/validate.js";
@@ -1,7 +1,7 @@
1
1
  import { egressOrigins } from "../compile/policy.js";
2
2
  import { JSON_RPC_ERRORS, parseJsonRpc, } from "../protocol/jsonrpc.js";
3
3
  import { MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, } from "../protocol/mcp-apps.js";
4
- import { MCP_APPS_STYLE_VARIABLES } from "../protocol/mcp-apps.js";
4
+ import { COPY_TEXT_METHOD, MCP_APPS_STYLE_VARIABLES } from "../protocol/mcp-apps.js";
5
5
  const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
6
6
  /** Levels of `notifications/message` that are errors: they go to the host as `reportError`. */
7
7
  const ERROR_LEVELS = new Set(["error", "critical", "alert", "emergency"]);
@@ -183,6 +183,15 @@ function connect(session, options) {
183
183
  : { structuredContent: params.structuredContent }),
184
184
  }, () => ({}));
185
185
  return;
186
+ case COPY_TEXT_METHOD:
187
+ // Porthole's own request (the standard has no clipboard request): the host copies, and
188
+ // shows the person what (P052).
189
+ if (typeof params.text !== "string") {
190
+ invalid("needs text");
191
+ return;
192
+ }
193
+ await call(request, "copyText", { text: params.text }, done);
194
+ return;
186
195
  case MCP_APPS_METHODS.requestDisplayMode: {
187
196
  if (typeof params.mode !== "string" || !DISPLAY_MODES.has(params.mode)) {
188
197
  invalid("needs a display mode");
@@ -47,6 +47,11 @@ export declare const MCP_APPS_STYLE_VARIABLES: readonly string[];
47
47
  * that doesn't know the field ignores it (the host context is open to extra fields).
48
48
  */
49
49
  export declare const PORTHOLE_STYLES = "porthole/styles";
50
+ /**
51
+ * Porthole's own request for copying text (`copyText`, P052): `{ text }`, answered `{}` when the
52
+ * host copied it and `{ isError: true }` when it didn't. The standard has no clipboard request.
53
+ */
54
+ export declare const COPY_TEXT_METHOD = "porthole/copy-text";
50
55
  /** The logger name the shell's error reports (`notifications/message`, level `error`) carry. */
51
56
  export declare const SHELL_LOGGER = "porthole";
52
57
  /**
@@ -95,5 +95,10 @@ export const MCP_APPS_STYLE_VARIABLES = [
95
95
  * that doesn't know the field ignores it (the host context is open to extra fields).
96
96
  */
97
97
  export const PORTHOLE_STYLES = "porthole/styles";
98
+ /**
99
+ * Porthole's own request for copying text (`copyText`, P052): `{ text }`, answered `{}` when the
100
+ * host copied it and `{ isError: true }` when it didn't. The standard has no clipboard request.
101
+ */
102
+ export const COPY_TEXT_METHOD = "porthole/copy-text";
98
103
  /** The logger name the shell's error reports (`notifications/message`, level `error`) carry. */
99
104
  export const SHELL_LOGGER = "porthole";