@botiverse/porthole 0.1.1 → 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 (96) 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 +5 -3
  8. package/dist/compile/policy.js +8 -4
  9. package/dist/config/capabilities.d.ts +37 -4
  10. package/dist/config/capabilities.js +20 -0
  11. package/dist/config/schema.d.ts +8 -0
  12. package/dist/config/schema.js +5 -0
  13. package/dist/config/validate.js +15 -1
  14. package/dist/conformance/clipboard.d.ts +2 -0
  15. package/dist/conformance/clipboard.js +167 -0
  16. package/dist/conformance/kit.d.ts +1 -1
  17. package/dist/conformance/probes.js +2 -0
  18. package/dist/guide/guide.js +8 -4
  19. package/dist/host/clipboard.d.ts +11 -0
  20. package/dist/host/clipboard.js +43 -0
  21. package/dist/host/create-host.js +141 -0
  22. package/dist/host/outcome.d.ts +1 -0
  23. package/dist/host/outcome.js +19 -6
  24. package/dist/host/types.d.ts +34 -5
  25. package/dist/index.d.ts +8 -4
  26. package/dist/index.js +8 -3
  27. package/dist/mcp/adapter.js +10 -1
  28. package/dist/presets/presets.d.ts +3 -1
  29. package/dist/presets/presets.js +1 -0
  30. package/dist/protocol/mcp-apps.d.ts +5 -0
  31. package/dist/protocol/mcp-apps.js +5 -0
  32. package/dist/protocol/shell-adapter.js +6 -2
  33. package/dist/react/dialog.js +2 -0
  34. package/dist/react/frame.d.ts +18 -0
  35. package/dist/react/frame.js +82 -2
  36. package/dist/react/index.d.ts +2 -1
  37. package/dist/react/index.js +2 -1
  38. package/dist/react/toast.d.ts +33 -0
  39. package/dist/react/toast.js +42 -0
  40. package/dist/server/crypto.d.ts +19 -0
  41. package/dist/server/crypto.js +144 -0
  42. package/dist/server/index.d.ts +12 -4
  43. package/dist/server/index.js +11 -3
  44. package/dist/server/keys.js +12 -9
  45. package/dist/server/naming.d.ts +17 -0
  46. package/dist/server/naming.js +34 -0
  47. package/dist/server/pages.d.ts +1 -1
  48. package/dist/server/pages.js +13 -5
  49. package/dist/server/server.js +162 -18
  50. package/dist/server/tickets.d.ts +24 -0
  51. package/dist/server/tickets.js +46 -0
  52. package/dist/server/types.d.ts +76 -5
  53. package/dist/shell/annotate.d.ts +20 -0
  54. package/dist/shell/annotate.js +669 -0
  55. package/dist/shell/document.js +4 -1
  56. package/dist/shell/plan.d.ts +2 -0
  57. package/dist/shell/plan.js +2 -1
  58. package/dist/shell/runtime.d.ts +4 -1
  59. package/dist/shell/runtime.js +38 -2
  60. package/package.json +2 -1
  61. package/src/annotate/types.ts +126 -0
  62. package/src/annotate/validate.ts +186 -0
  63. package/src/bridge/bridge.ts +13 -0
  64. package/src/compile/policy.ts +8 -4
  65. package/src/config/capabilities.ts +33 -3
  66. package/src/config/schema.ts +10 -0
  67. package/src/config/validate.ts +16 -1
  68. package/src/conformance/clipboard.ts +184 -0
  69. package/src/conformance/kit.ts +1 -1
  70. package/src/conformance/probes.ts +2 -0
  71. package/src/guide/guide.ts +19 -4
  72. package/src/host/clipboard.ts +41 -0
  73. package/src/host/create-host.ts +155 -0
  74. package/src/host/outcome.ts +25 -7
  75. package/src/host/types.ts +39 -5
  76. package/src/index.ts +31 -1
  77. package/src/mcp/adapter.ts +10 -1
  78. package/src/presets/presets.ts +4 -0
  79. package/src/protocol/mcp-apps.ts +6 -0
  80. package/src/protocol/shell-adapter.ts +11 -2
  81. package/src/react/dialog.tsx +2 -0
  82. package/src/react/frame.tsx +117 -1
  83. package/src/react/index.ts +9 -1
  84. package/src/react/toast.tsx +96 -0
  85. package/src/server/crypto.ts +166 -0
  86. package/src/server/index.ts +20 -3
  87. package/src/server/keys.ts +21 -13
  88. package/src/server/naming.ts +42 -0
  89. package/src/server/pages.ts +13 -4
  90. package/src/server/server.ts +190 -20
  91. package/src/server/tickets.ts +65 -0
  92. package/src/server/types.ts +84 -7
  93. package/src/shell/annotate.ts +669 -0
  94. package/src/shell/document.ts +6 -1
  95. package/src/shell/plan.ts +4 -1
  96. package/src/shell/runtime.ts +38 -2
@@ -1,4 +1,4 @@
1
- import { capabilitySpec } from "../config/capabilities.js";
1
+ import { MAX_COPY_CHARS, capabilitySpec } from "../config/capabilities.js";
2
2
  import { declaredCapabilities, effectiveGates } from "../compile/policy.js";
3
3
  import { MCP_APPS_PROTOCOL_VERSION } from "../protocol/mcp-apps.js";
4
4
  export function shellPlan(config) {
@@ -30,6 +30,7 @@ export function shellPlan(config) {
30
30
  storage: config.isolation === "opaque" ? "memory" : "native",
31
31
  maxErrorReports: config.limits.maxErrorReports,
32
32
  maxPromptChars: config.limits.maxPromptChars,
33
+ maxCopyChars: MAX_COPY_CHARS,
33
34
  protocolVersion: MCP_APPS_PROTOCOL_VERSION,
34
35
  };
35
36
  }
@@ -13,7 +13,10 @@
13
13
  * engine enforces `frame-src 'none'` for `srcdoc`/`about:blank` children); and the resource
14
14
  * hints and refreshes a CSP doesn't govern;
15
15
  * 4. closes the parser entry points that could build a shadow root step 3 can't see (declarative
16
- * shadow DOM through `setHTMLUnsafe`, `parseHTMLUnsafe`, `document.write`).
16
+ * shadow DOM through `setHTMLUnsafe`, `parseHTMLUnsafe`, `document.write`);
17
+ * 5. keeps content off the clipboard: the async Clipboard API refuses, `execCommand` won't copy or
18
+ * cut, and copy and cut events never reach content, so what a person copies is what they
19
+ * selected (P051). Copy buttons go through the host (`copyText`, P052).
17
20
  * A whole document (an artifact page, an MCP Apps view) is never written in: it is parsed inert,
18
21
  * imported, and its scripts run in order, as a fragment's are.
19
22
  *
@@ -13,7 +13,10 @@
13
13
  * engine enforces `frame-src 'none'` for `srcdoc`/`about:blank` children); and the resource
14
14
  * hints and refreshes a CSP doesn't govern;
15
15
  * 4. closes the parser entry points that could build a shadow root step 3 can't see (declarative
16
- * shadow DOM through `setHTMLUnsafe`, `parseHTMLUnsafe`, `document.write`).
16
+ * shadow DOM through `setHTMLUnsafe`, `parseHTMLUnsafe`, `document.write`);
17
+ * 5. keeps content off the clipboard: the async Clipboard API refuses, `execCommand` won't copy or
18
+ * cut, and copy and cut events never reach content, so what a person copies is what they
19
+ * selected (P051). Copy buttons go through the host (`copyText`, P052).
17
20
  * A whole document (an artifact page, an MCP Apps view) is never written in: it is parsed inert,
18
21
  * imported, and its scripts run in order, as a fragment's are.
19
22
  *
@@ -263,13 +266,32 @@ export const SHELL_RUNTIME = String.raw `(() => {
263
266
  this.innerHTML = String(html);
264
267
  });
265
268
  }
269
+ // insertHTML parses markup outside these guards; copy, cut and paste reach the clipboard (P051).
270
+ const REFUSED_COMMANDS = ["inserthtml", "copy", "cut", "paste"];
266
271
  const execCommand = Document.prototype.execCommand;
267
272
  if (typeof execCommand === "function") {
268
273
  lock(Document.prototype, "execCommand", function (command, ...rest) {
269
- return String(command).toLowerCase() === "inserthtml" ? false : execCommand.call(this, command, ...rest);
274
+ return REFUSED_COMMANDS.indexOf(String(command).toLowerCase()) !== -1 ? false : execCommand.call(this, command, ...rest);
270
275
  });
271
276
  }
272
277
 
278
+ // The clipboard is the host's to write (P051): content can neither write nor read it. A copy
279
+ // the person makes (their keys, their menu) copies their selection as the browser makes it: no
280
+ // copy or cut listener of the content's runs, so none can swap what goes on the clipboard.
281
+ const noClipboard = function () {
282
+ return Promise.reject(new DOMException("Content can't use the clipboard here" + (declared("copyText") ? ": call copyText(text) from a click" : ""), "NotAllowedError"));
283
+ };
284
+ if (typeof Clipboard === "function") {
285
+ for (const name of ["write", "writeText", "read", "readText"]) {
286
+ if (typeof Clipboard.prototype[name] === "function") {
287
+ lock(Clipboard.prototype, name, noClipboard);
288
+ }
289
+ }
290
+ }
291
+ for (const type of ["copy", "cut"]) {
292
+ window.addEventListener(type, (event) => event.stopImmediatePropagation(), true);
293
+ }
294
+
273
295
  // A connected link or meta can't become a guarded one: the change is tried on a detached copy
274
296
  // first, and if the copy would be guarded, the element leaves the document before it changes.
275
297
  const watched = (element) => element && element.nodeType === 1 && (element.localName === "link" || element.localName === "meta");
@@ -534,6 +556,8 @@ export const SHELL_RUNTIME = String.raw `(() => {
534
556
  }
535
557
  };
536
558
 
559
+ __ANNOTATE__
560
+
537
561
  if (plan.protocol !== "shell") {
538
562
  // A view that speaks for itself.
539
563
  if (typeof plan.document === "string") {
@@ -624,6 +648,18 @@ export const SHELL_RUNTIME = String.raw `(() => {
624
648
  );
625
649
  });
626
650
  }
651
+ if (declared("copyText")) {
652
+ lock(window, "copyText", (text) => {
653
+ if (!gestured("copyText")) {
654
+ return refuse("copyText needs a click: call it from a button's handler");
655
+ }
656
+ const words = String(text);
657
+ if (words === "" || words.length > plan.maxCopyChars) {
658
+ return refuse("copyText takes 1 to " + plan.maxCopyChars + " characters");
659
+ }
660
+ return request("porthole/copy-text", { text: words }).then((result) => !(result && result.isError));
661
+ });
662
+ }
627
663
  if (declared("openLink")) {
628
664
  lock(window, "openLink", (url) => {
629
665
  if (!gestured("openLink")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/porthole",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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": [
@@ -41,6 +41,7 @@
41
41
  "@types/react": "19.3.0",
42
42
  "@types/react-dom": "19.3.0",
43
43
  "esbuild": "0.28.2",
44
+ "miniflare": "4.20260730.0",
44
45
  "react": "19.3.0",
45
46
  "react-dom": "19.3.0",
46
47
  "typescript": "7.0.2"
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Annotations (P053): a person points at part of a display (an element, or text they select) to
3
+ * comment on it, and the host marks the anchors already made. The host drives the shell with
4
+ * Porthole's own messages (`porthole/annotate.*`); the shell answers with anchors and where they
5
+ * are. An anchor is a set of W3C Web Annotation selectors, so it can be found again after the
6
+ * content changes; finding it is best-effort, and says so (`orphaned`).
7
+ *
8
+ * Everything the frame reports is the content's word: the shell shares its realm with the
9
+ * content, which can interfere or fake a report. The host checks each report's shape and size
10
+ * and takes reports only while it asked for them. Use them as UI hints (where to draw a comment,
11
+ * what to quote), never as permissions.
12
+ */
13
+
14
+ /** The element a CSS selector matches (W3C `CssSelector`). */
15
+ export interface CssSelector {
16
+ readonly type: "CssSelector";
17
+ readonly value: string;
18
+ }
19
+
20
+ /** Text, with a little of what comes before and after it (W3C `TextQuoteSelector`). */
21
+ export interface TextQuoteSelector {
22
+ readonly type: "TextQuoteSelector";
23
+ readonly exact: string;
24
+ readonly prefix?: string;
25
+ readonly suffix?: string;
26
+ }
27
+
28
+ /**
29
+ * Offsets into the document's text: the text of the body's text nodes, in order, without
30
+ * scripts, styles and templates (W3C `TextPositionSelector`).
31
+ */
32
+ export interface TextPositionSelector {
33
+ readonly type: "TextPositionSelector";
34
+ readonly start: number;
35
+ readonly end: number;
36
+ }
37
+
38
+ export type AnnotationSelector = CssSelector | TextQuoteSelector | TextPositionSelector;
39
+
40
+ export interface AnnotationAnchor {
41
+ /** What the person pointed at: an element, or a text selection. */
42
+ readonly kind: "element" | "text";
43
+ /** At most one of each type; tried in turn when the anchor is found again. */
44
+ readonly selectors: readonly AnnotationSelector[];
45
+ /** A short description for the host to show ("h2#revenue Revenue grew 12%"); the content's. */
46
+ readonly label: string;
47
+ }
48
+
49
+ /** A box in the frame's viewport, CSS px: add the frame element's position to place it on the page. */
50
+ export interface AnnotationRect {
51
+ readonly x: number;
52
+ readonly y: number;
53
+ readonly width: number;
54
+ readonly height: number;
55
+ }
56
+
57
+ /** What the person may point at while the host is asking. */
58
+ export interface AnnotateMode {
59
+ /** Elements, picked with a click or tap (the one under the pointer is outlined); one pick. */
60
+ readonly elements?: boolean;
61
+ /** Text, by selecting it: each settled selection is reported, and its clearing. */
62
+ readonly text?: boolean;
63
+ }
64
+
65
+ /** An anchor the host marks in the frame, under its own id. */
66
+ export interface AnnotationMark {
67
+ readonly id: string;
68
+ readonly anchor: AnnotationAnchor;
69
+ }
70
+
71
+ /** Whether a mark's anchor was found in the content as it is now. */
72
+ export type AnchorState = "attached" | "orphaned";
73
+
74
+ export interface AnchorResult {
75
+ readonly id: string;
76
+ readonly state: AnchorState;
77
+ /** Where it is now, when attached. */
78
+ readonly rect?: AnnotationRect;
79
+ }
80
+
81
+ /** What the frame reports, once the host has checked it. */
82
+ export type AnnotationReport =
83
+ /** The person pointed at an element or settled a text selection. */
84
+ | {
85
+ readonly type: "selected";
86
+ readonly anchor: AnnotationAnchor;
87
+ readonly rect: AnnotationRect;
88
+ }
89
+ /** The text selection went away. */
90
+ | { readonly type: "unselected" }
91
+ /** The person pressed Escape while picking an element. */
92
+ | { readonly type: "cancelled" }
93
+ /** Where the marked anchors are now, after `show`, `focus`, or a change of the content. */
94
+ | { readonly type: "anchored"; readonly results: readonly AnchorResult[] };
95
+
96
+ /** Porthole's own messages for annotations (own-prefixed, P002). */
97
+ export const ANNOTATE_METHODS = {
98
+ /** Host → frame: let the person point (`AnnotateMode`). */
99
+ start: "porthole/annotate.start",
100
+ /** Host → frame: stop. */
101
+ stop: "porthole/annotate.stop",
102
+ /** Host → frame: mark these anchors (replacing what was marked). */
103
+ show: "porthole/annotate.show",
104
+ /** Host → frame: bring one mark into view and make it the active one. */
105
+ focus: "porthole/annotate.focus",
106
+ /** Host → frame: remove every mark. */
107
+ clear: "porthole/annotate.clear",
108
+ /** Frame → host. */
109
+ selected: "porthole/annotate.selected",
110
+ unselected: "porthole/annotate.unselected",
111
+ cancelled: "porthole/annotate.cancelled",
112
+ anchored: "porthole/annotate.anchored",
113
+ } as const;
114
+
115
+ /** The most of each part of an anchor or report the host takes. */
116
+ export const ANNOTATION_LIMITS = {
117
+ label: 120,
118
+ cssSelector: 1_000,
119
+ exact: 4_000,
120
+ context: 64,
121
+ /** The most marks one `show` carries, and results one report. */
122
+ marks: 500,
123
+ id: 128,
124
+ position: 50_000_000,
125
+ coordinate: 10_000_000,
126
+ } as const;
@@ -0,0 +1,186 @@
1
+ import {
2
+ ANNOTATION_LIMITS as LIMITS,
3
+ type AnchorResult,
4
+ type AnnotateMode,
5
+ type AnnotationAnchor,
6
+ type AnnotationMark,
7
+ type AnnotationRect,
8
+ type AnnotationReport,
9
+ type AnnotationSelector,
10
+ } from "./types.ts";
11
+
12
+ /**
13
+ * The host's checks on what a frame says about annotations (P053), and on anchors a host stores
14
+ * and sends back. Each rebuilds what it accepts from known fields only, within `ANNOTATION_LIMITS`,
15
+ * and answers null for anything else: a report is dropped whole, never repaired.
16
+ */
17
+
18
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
19
+ value !== null && typeof value === "object" && !Array.isArray(value);
20
+
21
+ const text = (value: unknown, max: number, min = 0): value is string =>
22
+ typeof value === "string" && value.length >= min && value.length <= max;
23
+
24
+ const count = (value: unknown, max: number): value is number =>
25
+ typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= max;
26
+
27
+ function selector(value: unknown): AnnotationSelector | null {
28
+ if (!isRecord(value)) {
29
+ return null;
30
+ }
31
+ switch (value.type) {
32
+ case "CssSelector":
33
+ return text(value.value, LIMITS.cssSelector, 1)
34
+ ? { type: "CssSelector", value: value.value }
35
+ : null;
36
+ case "TextQuoteSelector": {
37
+ const { exact, prefix, suffix } = value;
38
+ if (
39
+ !text(exact, LIMITS.exact, 1) ||
40
+ (prefix !== undefined && !text(prefix, LIMITS.context)) ||
41
+ (suffix !== undefined && !text(suffix, LIMITS.context))
42
+ ) {
43
+ return null;
44
+ }
45
+ return {
46
+ type: "TextQuoteSelector",
47
+ exact,
48
+ ...(prefix === undefined || prefix === "" ? {} : { prefix }),
49
+ ...(suffix === undefined || suffix === "" ? {} : { suffix }),
50
+ };
51
+ }
52
+ case "TextPositionSelector":
53
+ return count(value.start, LIMITS.position) &&
54
+ count(value.end, LIMITS.position) &&
55
+ value.start <= value.end
56
+ ? { type: "TextPositionSelector", start: value.start, end: value.end }
57
+ : null;
58
+ default:
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /** An anchor, rebuilt from its known fields; null if any part is malformed or too large. */
64
+ export function parseAnchor(value: unknown): AnnotationAnchor | null {
65
+ if (!isRecord(value) || (value.kind !== "element" && value.kind !== "text")) {
66
+ return null;
67
+ }
68
+ if (!Array.isArray(value.selectors) || value.selectors.length === 0) {
69
+ return null;
70
+ }
71
+ if (value.selectors.length > 3 || !text(value.label, LIMITS.label)) {
72
+ return null;
73
+ }
74
+ const selectors: AnnotationSelector[] = [];
75
+ for (const each of value.selectors) {
76
+ const parsed = selector(each);
77
+ if (parsed === null || selectors.some((other) => other.type === parsed.type)) {
78
+ return null;
79
+ }
80
+ selectors.push(parsed);
81
+ }
82
+ // A text anchor is found by its words; an element one needs a way to find the element.
83
+ const quote = selectors.some((each) => each.type === "TextQuoteSelector");
84
+ const css = selectors.some((each) => each.type === "CssSelector");
85
+ if (value.kind === "text" ? !quote : !css && !quote) {
86
+ return null;
87
+ }
88
+ return { kind: value.kind, selectors, label: value.label };
89
+ }
90
+
91
+ const coordinate = (value: unknown): value is number =>
92
+ typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= LIMITS.coordinate;
93
+
94
+ /** A box in the frame's viewport; null unless every number is finite and sizes aren't negative. */
95
+ export function parseRect(value: unknown): AnnotationRect | null {
96
+ if (!isRecord(value)) {
97
+ return null;
98
+ }
99
+ const { x, y, width, height } = value;
100
+ if (!coordinate(x) || !coordinate(y) || !coordinate(width) || !coordinate(height)) {
101
+ return null;
102
+ }
103
+ if (width < 0 || height < 0) {
104
+ return null;
105
+ }
106
+ return { x, y, width, height };
107
+ }
108
+
109
+ const markId = (value: unknown): value is string => text(value, LIMITS.id, 1);
110
+
111
+ function results(value: unknown): AnchorResult[] | null {
112
+ if (!Array.isArray(value) || value.length > LIMITS.marks) {
113
+ return null;
114
+ }
115
+ const out: AnchorResult[] = [];
116
+ for (const each of value) {
117
+ if (!isRecord(each) || !markId(each.id)) {
118
+ return null;
119
+ }
120
+ if (each.state === "orphaned") {
121
+ out.push({ id: each.id, state: "orphaned" });
122
+ continue;
123
+ }
124
+ if (each.state !== "attached") {
125
+ return null;
126
+ }
127
+ const rect = each.rect === undefined ? undefined : parseRect(each.rect);
128
+ if (rect === null) {
129
+ return null;
130
+ }
131
+ out.push({ id: each.id, state: "attached", ...(rect === undefined ? {} : { rect }) });
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /** A report from the frame, checked; null if malformed (it is then dropped). */
137
+ export function parseReport(value: unknown): AnnotationReport | null {
138
+ if (!isRecord(value)) {
139
+ return null;
140
+ }
141
+ switch (value.type) {
142
+ case "selected": {
143
+ const anchor = parseAnchor(value.anchor);
144
+ const rect = parseRect(value.rect);
145
+ return anchor === null || rect === null ? null : { type: "selected", anchor, rect };
146
+ }
147
+ case "unselected":
148
+ return { type: "unselected" };
149
+ case "cancelled":
150
+ return { type: "cancelled" };
151
+ case "anchored": {
152
+ const checked = results(value.results);
153
+ return checked === null ? null : { type: "anchored", results: checked };
154
+ }
155
+ default:
156
+ return null;
157
+ }
158
+ }
159
+
160
+ /** What the host may start with: elements, text, or both (default both). */
161
+ export function annotateMode(mode: AnnotateMode | undefined): Required<AnnotateMode> {
162
+ const elements = mode?.elements ?? true;
163
+ const words = mode?.text ?? true;
164
+ if (typeof elements !== "boolean" || typeof words !== "boolean" || (!elements && !words)) {
165
+ throw new TypeError("annotations.start: elements, text, or both");
166
+ }
167
+ return { elements, text: words };
168
+ }
169
+
170
+ /** The host's marks, checked before they go to the frame; throws on the first bad one. */
171
+ export function checkMarks(marks: readonly AnnotationMark[]): AnnotationMark[] {
172
+ if (!Array.isArray(marks) || marks.length > LIMITS.marks) {
173
+ throw new TypeError(`annotations.show: at most ${LIMITS.marks} marks`);
174
+ }
175
+ const seen = new Set<string>();
176
+ return marks.map((mark, index) => {
177
+ const anchor = parseAnchor(mark?.anchor);
178
+ if (!markId(mark?.id) || seen.has(mark.id) || anchor === null) {
179
+ throw new TypeError(
180
+ `annotations.show: mark ${index} needs a unique id (1 to ${LIMITS.id} characters) and a valid anchor`,
181
+ );
182
+ }
183
+ seen.add(mark.id);
184
+ return { id: mark.id, anchor };
185
+ });
186
+ }
@@ -1,9 +1,12 @@
1
1
  import {
2
+ MAX_COPY_CHARS,
2
3
  capabilitySpec,
3
4
  type CapabilityArgs,
4
5
  type CapabilityName,
5
6
  type CapabilityResults,
6
7
  } from "../config/capabilities.ts";
8
+ import type { AnnotationReport } from "../annotate/types.ts";
9
+ import { parseReport } from "../annotate/validate.ts";
7
10
  import type { PortholeConfig } from "../config/schema.ts";
8
11
  import { effectiveGates } from "../compile/policy.ts";
9
12
  import type {
@@ -26,6 +29,7 @@ import type {
26
29
  export type BridgeEvent =
27
30
  | { readonly type: "resize"; readonly height: number; readonly width?: number }
28
31
  | { readonly type: "error"; readonly message: string }
32
+ | { readonly type: "annotation"; readonly report: AnnotationReport }
29
33
  | { readonly type: "refused"; readonly capability: CapabilityName; readonly refusal: Refusal }
30
34
  | { readonly type: "muted" };
31
35
 
@@ -129,6 +133,13 @@ const VALIDATORS: { readonly [N in CapabilityName]: Validator<N> } = {
129
133
  ? { message: message.slice(0, 2_000) }
130
134
  : null;
131
135
  },
136
+ copyText: (args) => {
137
+ const text = isRecord(args) ? args.text : undefined;
138
+ return typeof text === "string" && text !== "" && text.length <= MAX_COPY_CHARS
139
+ ? { text }
140
+ : null;
141
+ },
142
+ annotate: (args) => parseReport(args),
132
143
  resize: (args) => {
133
144
  if (
134
145
  !isRecord(args) ||
@@ -250,6 +261,8 @@ export function createBridge(options: BridgeOptions): HostFunctionBridge {
250
261
  if (capability === "resize") {
251
262
  const size = args as CapabilityArgs["resize"];
252
263
  onEvent({ type: "resize", ...size });
264
+ } else if (capability === "annotate") {
265
+ onEvent({ type: "annotation", report: args as CapabilityArgs["annotate"] });
253
266
  } else {
254
267
  onEvent({ type: "error", message: (args as CapabilityArgs["reportError"]).message });
255
268
  }
@@ -99,11 +99,15 @@ export function sandboxTokens(config: PortholeConfig): string[] {
99
99
  }
100
100
 
101
101
  /**
102
- * The frame's `allow` attribute (Permissions Policy). Empty: no camera, microphone, geolocation,
103
- * clipboard, fullscreen or other powerful feature is delegated to the frame.
102
+ * The frame's `allow` attribute (Permissions Policy). No camera, microphone, geolocation,
103
+ * fullscreen or other powerful feature is delegated to the frame, and the clipboard is denied by
104
+ * name (P051): content never writes it, the host does (`copyText`). A url-mode config may
105
+ * delegate clipboard writes and full screen to the app's own origin (P049).
104
106
  */
105
- export function allowAttribute(_config: PortholeConfig): string {
106
- return "";
107
+ export function allowAttribute(config: PortholeConfig): string {
108
+ return config.mode === "url"
109
+ ? (config.permissions ?? []).join("; ")
110
+ : "clipboard-read 'none'; clipboard-write 'none'";
107
111
  }
108
112
 
109
113
  /**
@@ -1,3 +1,5 @@
1
+ import type { AnnotationReport } from "../annotate/types.ts";
2
+
1
3
  /**
2
4
  * The capability vocabulary (PRINCIPLE 4): every power content has beyond drawing is one of these
3
5
  * host functions, named in the config. A capability the config doesn't declare is not wired into
@@ -17,6 +19,8 @@ export const CAPABILITY_NAMES = [
17
19
  "setModelContext",
18
20
  "reportError",
19
21
  "resize",
22
+ "copyText",
23
+ "annotate",
20
24
  ] as const;
21
25
 
22
26
  export type CapabilityName = (typeof CAPABILITY_NAMES)[number];
@@ -44,14 +48,18 @@ export interface CapabilitySpec {
44
48
  /** The global the shell defines for it (sugar over the protocol message), if any. */
45
49
  readonly global?: string;
46
50
  /**
47
- * How content reaches it without the shell's global: the MCP Apps request, and the official
48
- * App SDK's method for it. Absent for what the shell does by itself (sizes, errors).
51
+ * How content reaches it without the shell's global: the MCP Apps request (or Porthole's own,
52
+ * `porthole/…`, where the standard has none), and the official App SDK's method for it, if any.
53
+ * Absent for what the shell does by itself (sizes, errors, annotations).
49
54
  */
50
- readonly request?: { readonly method: string; readonly sdk: string };
55
+ readonly request?: { readonly method: string; readonly sdk?: string };
51
56
  }
52
57
 
53
58
  const rate = (perMinute: number): Gate => ({ rateLimit: { perMinute } });
54
59
 
60
+ /** The most text one `copyText` call carries. */
61
+ export const MAX_COPY_CHARS = 20_000;
62
+
55
63
  export const CAPABILITIES = {
56
64
  sendPrompt: {
57
65
  defaultGates: ["user-activation", "confirm"],
@@ -87,6 +95,22 @@ export const CAPABILITIES = {
87
95
  },
88
96
  reportError: { defaultGates: [rate(10)], served: "event" },
89
97
  resize: { defaultGates: [], served: "event" },
98
+ /**
99
+ * Copies text to the person's clipboard, done by the host, which shows them what was copied
100
+ * (P052). Content can't write the clipboard any other way (P051).
101
+ */
102
+ copyText: {
103
+ defaultGates: ["user-activation", rate(30)],
104
+ served: "handler",
105
+ global: "copyText",
106
+ request: { method: "porthole/copy-text" },
107
+ },
108
+ /**
109
+ * Annotations (P053): the host asks the shell to let the person point at an element or select
110
+ * text, and to mark anchors already made. What the frame reports back is the content's word
111
+ * (it shares the realm): UI hints for the host, never permissions.
112
+ */
113
+ annotate: { defaultGates: [rate(120)], served: "event" },
90
114
  } as const satisfies { readonly [N in CapabilityName]: CapabilitySpec };
91
115
 
92
116
  /** The arguments a call carries, once the adapter has validated them. */
@@ -106,6 +130,9 @@ export interface CapabilityArgs {
106
130
  };
107
131
  readonly reportError: { readonly message: string };
108
132
  readonly resize: { readonly height: number; readonly width?: number };
133
+ readonly copyText: { readonly text: string };
134
+ /** What the frame reported, once checked (`AnnotationReport`). */
135
+ readonly annotate: AnnotationReport;
109
136
  }
110
137
 
111
138
  /** What a call resolves to. Protocol-shaped results (a tool's result) stay opaque to the core. */
@@ -121,6 +148,9 @@ export interface CapabilityResults {
121
148
  readonly setModelContext: undefined;
122
149
  readonly reportError: undefined;
123
150
  readonly resize: undefined;
151
+ /** Whether the text was copied. */
152
+ readonly copyText: boolean;
153
+ readonly annotate: undefined;
124
154
  }
125
155
 
126
156
  /** The capabilities a host serves with a handler. */
@@ -116,6 +116,14 @@ export const URL_SANDBOX_EXTRAS = [
116
116
 
117
117
  export type UrlSandboxExtra = (typeof URL_SANDBOX_EXTRAS)[number];
118
118
 
119
+ /**
120
+ * Powerful features a url-mode frame may be delegated, by the host's choice (P049): writing the
121
+ * clipboard (a "copy" button in the person's app) and going full screen. Nothing for other modes.
122
+ */
123
+ export const URL_PERMISSIONS = ["clipboard-write", "fullscreen"] as const;
124
+
125
+ export type UrlPermission = (typeof URL_PERMISSIONS)[number];
126
+
119
127
  /** Where the host shows the content, for the manual's size rules. */
120
128
  export interface LayoutConfig {
121
129
  /** The narrowest the frame ever is (a small phone). */
@@ -165,4 +173,6 @@ export interface PortholeConfig {
165
173
  readonly embedder: EmbedderConfig;
166
174
  /** Url mode only: sandbox tokens added to its baseline (`URL_SANDBOX_EXTRAS`). */
167
175
  readonly sandbox?: readonly UrlSandboxExtra[];
176
+ /** Url mode only: features delegated to the frame (`URL_PERMISSIONS`); none by default. */
177
+ readonly permissions?: readonly UrlPermission[];
168
178
  }
@@ -1,5 +1,5 @@
1
1
  import { CAPABILITY_NAMES, isCapabilityName, type Gate } from "./capabilities.ts";
2
- import { URL_SANDBOX_EXTRAS, type PortholeConfig } from "./schema.ts";
2
+ import { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, type PortholeConfig } from "./schema.ts";
3
3
 
4
4
  /**
5
5
  * Checks a config before anything is derived from it. Every string that ends up in a CSP, an
@@ -130,6 +130,7 @@ export function validateConfig(input: unknown): PortholeConfig {
130
130
  "contentOrigins",
131
131
  "embedder",
132
132
  "sandbox",
133
+ "permissions",
133
134
  ],
134
135
  "config",
135
136
  );
@@ -314,5 +315,19 @@ export function validateConfig(input: unknown): PortholeConfig {
314
315
  });
315
316
  }
316
317
 
318
+ if (config.permissions !== undefined) {
319
+ if (config.mode !== "url") {
320
+ fail("config.permissions", "only url mode may be delegated features");
321
+ }
322
+ if (!Array.isArray(config.permissions)) {
323
+ fail("config.permissions", "must be an array of features");
324
+ }
325
+ (config.permissions as unknown[]).forEach((feature, index) => {
326
+ if (!(URL_PERMISSIONS as readonly unknown[]).includes(feature)) {
327
+ fail(`config.permissions[${index}]`, `must be one of ${URL_PERMISSIONS.join(", ")}`);
328
+ }
329
+ });
330
+ }
331
+
317
332
  return input as PortholeConfig;
318
333
  }