@botiverse/porthole 0.1.2 → 0.1.4

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 (84) 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 +10 -3
  8. package/dist/compile/policy.js +16 -5
  9. package/dist/config/capabilities.d.ts +37 -4
  10. package/dist/config/capabilities.js +20 -0
  11. package/dist/config/schema.d.ts +11 -0
  12. package/dist/config/validate.js +12 -0
  13. package/dist/conformance/clipboard.d.ts +2 -0
  14. package/dist/conformance/clipboard.js +167 -0
  15. package/dist/conformance/kit.d.ts +1 -1
  16. package/dist/conformance/probes.js +2 -0
  17. package/dist/conformance/served.d.ts +8 -1
  18. package/dist/conformance/served.js +12 -8
  19. package/dist/guide/guide.js +11 -5
  20. package/dist/host/clipboard.d.ts +11 -0
  21. package/dist/host/clipboard.js +43 -0
  22. package/dist/host/create-host.js +165 -7
  23. package/dist/host/types.d.ts +68 -2
  24. package/dist/index.d.ts +8 -5
  25. package/dist/index.js +7 -3
  26. package/dist/mcp/adapter.js +10 -1
  27. package/dist/presets/presets.d.ts +3 -1
  28. package/dist/presets/presets.js +1 -0
  29. package/dist/protocol/mcp-apps.d.ts +5 -0
  30. package/dist/protocol/mcp-apps.js +5 -0
  31. package/dist/protocol/shell-adapter.js +6 -2
  32. package/dist/react/dialog.js +2 -0
  33. package/dist/react/frame.d.ts +50 -2
  34. package/dist/react/frame.js +153 -11
  35. package/dist/react/index.d.ts +2 -1
  36. package/dist/react/index.js +2 -1
  37. package/dist/react/toast.d.ts +33 -0
  38. package/dist/react/toast.js +42 -0
  39. package/dist/server/naming.d.ts +6 -0
  40. package/dist/server/naming.js +38 -0
  41. package/dist/server/pages.js +2 -0
  42. package/dist/server/server.js +49 -17
  43. package/dist/server/types.d.ts +6 -3
  44. package/dist/shell/annotate.d.ts +20 -0
  45. package/dist/shell/annotate.js +700 -0
  46. package/dist/shell/document.d.ts +6 -0
  47. package/dist/shell/document.js +12 -3
  48. package/dist/shell/plan.d.ts +2 -0
  49. package/dist/shell/plan.js +2 -1
  50. package/dist/shell/runtime.d.ts +4 -1
  51. package/dist/shell/runtime.js +71 -3
  52. package/package.json +1 -1
  53. package/src/annotate/types.ts +126 -0
  54. package/src/annotate/validate.ts +186 -0
  55. package/src/bridge/bridge.ts +13 -0
  56. package/src/compile/policy.ts +17 -5
  57. package/src/config/capabilities.ts +33 -3
  58. package/src/config/schema.ts +12 -0
  59. package/src/config/validate.ts +15 -0
  60. package/src/conformance/clipboard.ts +184 -0
  61. package/src/conformance/kit.ts +1 -1
  62. package/src/conformance/probes.ts +2 -0
  63. package/src/conformance/served.ts +24 -9
  64. package/src/guide/guide.ts +22 -5
  65. package/src/host/clipboard.ts +41 -0
  66. package/src/host/create-host.ts +181 -7
  67. package/src/host/types.ts +64 -2
  68. package/src/index.ts +24 -0
  69. package/src/mcp/adapter.ts +10 -1
  70. package/src/presets/presets.ts +4 -0
  71. package/src/protocol/mcp-apps.ts +6 -0
  72. package/src/protocol/shell-adapter.ts +11 -2
  73. package/src/react/dialog.tsx +2 -0
  74. package/src/react/frame.tsx +231 -13
  75. package/src/react/index.ts +9 -1
  76. package/src/react/toast.tsx +96 -0
  77. package/src/server/naming.ts +41 -0
  78. package/src/server/pages.ts +2 -0
  79. package/src/server/server.ts +61 -20
  80. package/src/server/types.ts +6 -3
  81. package/src/shell/annotate.ts +700 -0
  82. package/src/shell/document.ts +21 -3
  83. package/src/shell/plan.ts +4 -1
  84. package/src/shell/runtime.ts +71 -3
@@ -1,4 +1,5 @@
1
1
  import { hostOriginProblem } from "../isolation/served.ts";
2
+ import { ANNOTATE_RUNTIME } from "./annotate.ts";
2
3
  import { SHELL_RUNTIME } from "./runtime.ts";
3
4
  import type { ShellPlan } from "./plan.ts";
4
5
 
@@ -24,8 +25,17 @@ export interface ShellDocumentOptions {
24
25
  * both apply, and the stricter wins.
25
26
  */
26
27
  readonly cspMeta?: boolean;
28
+ /**
29
+ * The embedding page's CSP nonce, where its `script-src` is nonce-based (P054): a `srcdoc` frame
30
+ * inherits the embedding page's policy, so the shell's script and style carry it, and so does
31
+ * every script the shell runs for the content. The frame can read it.
32
+ */
33
+ readonly nonce?: string;
27
34
  }
28
35
 
36
+ /** A CSP nonce: base64 or base64url characters, 8 to 256 of them. */
37
+ const NONCE = /^[A-Za-z0-9+/_=-]{8,256}$/u;
38
+
29
39
  const escapeAttribute = (value: string): string =>
30
40
  value.replace(/&/gu, "&amp;").replace(/"/gu, "&quot;").replace(/</gu, "&lt;");
31
41
 
@@ -85,7 +95,15 @@ export function shellDocument(plan: ShellPlan, csp: string, options: ShellDocume
85
95
  throw new TypeError(`shellDocument: ${problem}`);
86
96
  }
87
97
  }
88
- const runtime = SHELL_RUNTIME.replace("__PLAN__", () =>
98
+ if (options.nonce !== undefined && !NONCE.test(options.nonce)) {
99
+ throw new TypeError("shellDocument: a nonce is 8 to 256 base64 characters");
100
+ }
101
+ const nonce = options.nonce === undefined ? "" : ` nonce="${options.nonce}"`;
102
+ // The annotation runtime only where it is declared; spliced in before the plan, which may carry
103
+ // any text (a document) and must not be searched.
104
+ const runtime = SHELL_RUNTIME.replace("__ANNOTATE__", () =>
105
+ plan.capabilities.annotate === undefined ? "" : ANNOTATE_RUNTIME,
106
+ ).replace("__PLAN__", () =>
89
107
  scriptJson({
90
108
  ...plan,
91
109
  origins: options.origins,
@@ -108,13 +126,13 @@ export function shellDocument(plan: ShellPlan, csp: string, options: ShellDocume
108
126
  ${head.join("\n")}
109
127
  <meta name="viewport" content="width=device-width, initial-scale=1">
110
128
  <title>${escapeText(options.title ?? "Content")}</title>
111
- <style>
129
+ <style${nonce}>
112
130
  ${BASE_STYLE}
113
131
  </style>
114
132
  </head>
115
133
  <body>
116
134
  <div id="porthole-root"></div>
117
- <script>${runtime}</script>
135
+ <script${nonce}>${runtime}</script>
118
136
  </body>
119
137
  </html>
120
138
  `;
package/src/shell/plan.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { capabilitySpec, type CapabilityName } from "../config/capabilities.ts";
1
+ import { MAX_COPY_CHARS, capabilitySpec, type CapabilityName } from "../config/capabilities.ts";
2
2
  import type { PortholeConfig } from "../config/schema.ts";
3
3
  import { declaredCapabilities, effectiveGates } from "../compile/policy.ts";
4
4
  import { MCP_APPS_PROTOCOL_VERSION } from "../protocol/mcp-apps.ts";
@@ -31,6 +31,8 @@ export interface ShellPlan {
31
31
  readonly storage: "memory" | "native";
32
32
  readonly maxErrorReports: number;
33
33
  readonly maxPromptChars: number;
34
+ /** The most text `copyText` carries. */
35
+ readonly maxCopyChars: number;
34
36
  readonly protocolVersion: string;
35
37
  }
36
38
 
@@ -64,6 +66,7 @@ export function shellPlan(config: PortholeConfig): ShellPlan | null {
64
66
  storage: config.isolation === "opaque" ? "memory" : "native",
65
67
  maxErrorReports: config.limits.maxErrorReports,
66
68
  maxPromptChars: config.limits.maxPromptChars,
69
+ maxCopyChars: MAX_COPY_CHARS,
67
70
  protocolVersion: MCP_APPS_PROTOCOL_VERSION,
68
71
  };
69
72
  }
@@ -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
  *
@@ -28,6 +31,9 @@
28
31
  export const SHELL_RUNTIME = String.raw`(() => {
29
32
  "use strict";
30
33
  const plan = __PLAN__;
34
+ // The embedding page's nonce, where it gave one (a srcdoc frame inherits its policy): every
35
+ // script the shell runs for the content carries it.
36
+ const nonce = document.currentScript && document.currentScript.nonce ? document.currentScript.nonce : "";
31
37
  const declared = (name) => Object.prototype.hasOwnProperty.call(plan.capabilities, name);
32
38
  // Fixed in place: content can neither restore what is removed nor replace what is wrapped.
33
39
  const lock = (target, name, value) => {
@@ -263,13 +269,32 @@ export const SHELL_RUNTIME = String.raw`(() => {
263
269
  this.innerHTML = String(html);
264
270
  });
265
271
  }
272
+ // insertHTML parses markup outside these guards; copy, cut and paste reach the clipboard (P051).
273
+ const REFUSED_COMMANDS = ["inserthtml", "copy", "cut", "paste"];
266
274
  const execCommand = Document.prototype.execCommand;
267
275
  if (typeof execCommand === "function") {
268
276
  lock(Document.prototype, "execCommand", function (command, ...rest) {
269
- return String(command).toLowerCase() === "inserthtml" ? false : execCommand.call(this, command, ...rest);
277
+ return REFUSED_COMMANDS.indexOf(String(command).toLowerCase()) !== -1 ? false : execCommand.call(this, command, ...rest);
270
278
  });
271
279
  }
272
280
 
281
+ // The clipboard is the host's to write (P051): content can neither write nor read it. A copy
282
+ // the person makes (their keys, their menu) copies their selection as the browser makes it: no
283
+ // copy or cut listener of the content's runs, so none can swap what goes on the clipboard.
284
+ const noClipboard = function () {
285
+ return Promise.reject(new DOMException("Content can't use the clipboard here" + (declared("copyText") ? ": call copyText(text) from a click" : ""), "NotAllowedError"));
286
+ };
287
+ if (typeof Clipboard === "function") {
288
+ for (const name of ["write", "writeText", "read", "readText"]) {
289
+ if (typeof Clipboard.prototype[name] === "function") {
290
+ lock(Clipboard.prototype, name, noClipboard);
291
+ }
292
+ }
293
+ }
294
+ for (const type of ["copy", "cut"]) {
295
+ window.addEventListener(type, (event) => event.stopImmediatePropagation(), true);
296
+ }
297
+
273
298
  // A connected link or meta can't become a guarded one: the change is tried on a detached copy
274
299
  // first, and if the copy would be guarded, the element leaves the document before it changes.
275
300
  const watched = (element) => element && element.nodeType === 1 && (element.localName === "link" || element.localName === "meta");
@@ -463,6 +488,9 @@ export const SHELL_RUNTIME = String.raw`(() => {
463
488
  }
464
489
  // A script made by a script is async unless told otherwise: keep the page's order.
465
490
  live.async = script.hasAttribute("async");
491
+ if (nonce !== "") {
492
+ live.nonce = nonce;
493
+ }
466
494
  live.textContent = script.textContent;
467
495
  const external = live.hasAttribute("src") && !live.async && live.type !== "module";
468
496
  const loaded = external
@@ -487,6 +515,27 @@ export const SHELL_RUNTIME = String.raw`(() => {
487
515
  window.dispatchEvent(new Event("load"));
488
516
  };
489
517
 
518
+ // Whether the content is in place: a fragment or page the shell draws, once drawn whole; a
519
+ // view the plan carries, once rendered; a view that is the document itself, from the start.
520
+ const contentHooks = [];
521
+ let contentIn = typeof plan.document !== "string" && plan.protocol !== "shell";
522
+ const contentIsIn = () => contentIn;
523
+ const whenContentIsIn = (run) => {
524
+ if (contentIn) {
525
+ run();
526
+ } else {
527
+ contentHooks.push(run);
528
+ }
529
+ };
530
+ const contentArrived = () => {
531
+ if (!contentIn) {
532
+ contentIn = true;
533
+ for (const run of contentHooks.splice(0)) {
534
+ run();
535
+ }
536
+ }
537
+ };
538
+
490
539
  // A fragment takes the person's scheme; a whole document only if it declares that it supports
491
540
  // both (or the config says always), else it shows as its author styled it (P039).
492
541
  let lastContext = null;
@@ -522,6 +571,7 @@ export const SHELL_RUNTIME = String.raw`(() => {
522
571
  schemeFollows();
523
572
  await runScripts(scripts);
524
573
  announceLoaded();
574
+ contentArrived();
525
575
  };
526
576
 
527
577
  // A document the plan carries (a served page, an MCP Apps view) is rendered once the shell's own
@@ -534,6 +584,8 @@ export const SHELL_RUNTIME = String.raw`(() => {
534
584
  }
535
585
  };
536
586
 
587
+ __ANNOTATE__
588
+
537
589
  if (plan.protocol !== "shell") {
538
590
  // A view that speaks for itself.
539
591
  if (typeof plan.document === "string") {
@@ -624,6 +676,18 @@ export const SHELL_RUNTIME = String.raw`(() => {
624
676
  );
625
677
  });
626
678
  }
679
+ if (declared("copyText")) {
680
+ lock(window, "copyText", (text) => {
681
+ if (!gestured("copyText")) {
682
+ return refuse("copyText needs a click: call it from a button's handler");
683
+ }
684
+ const words = String(text);
685
+ if (words === "" || words.length > plan.maxCopyChars) {
686
+ return refuse("copyText takes 1 to " + plan.maxCopyChars + " characters");
687
+ }
688
+ return request("porthole/copy-text", { text: words }).then((result) => !(result && result.isError));
689
+ });
690
+ }
627
691
  if (declared("openLink")) {
628
692
  lock(window, "openLink", (url) => {
629
693
  if (!gestured("openLink")) {
@@ -636,7 +700,10 @@ export const SHELL_RUNTIME = String.raw`(() => {
636
700
  window.addEventListener("error", (event) => {
637
701
  const error = event.error;
638
702
  const where = event.lineno ? " (line " + event.lineno + ")" : "";
639
- report(error && error.stack ? String(error.stack).split("\n").slice(0, 3).join(" ") : event.message + where);
703
+ // V8's stack starts with the message; WebKit's and Firefox's don't, so it goes first there.
704
+ const stack = error && error.stack ? String(error.stack).split("\n").slice(0, 3).join(" ") : "";
705
+ const said = error && error.message !== undefined ? String(error.name || "Error") + ": " + String(error.message) : event.message;
706
+ report(stack === "" ? said + where : stack.indexOf(String(error.message)) === -1 ? said + " " + stack : stack);
640
707
  });
641
708
  window.addEventListener("unhandledrejection", (event) => {
642
709
  const reason = event.reason;
@@ -768,6 +835,7 @@ export const SHELL_RUNTIME = String.raw`(() => {
768
835
  schedule();
769
836
  await runScripts(scripts);
770
837
  announceLoaded();
838
+ contentArrived();
771
839
  schedule();
772
840
  };
773
841