@botiverse/porthole 0.0.1 → 0.1.0

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 (149) hide show
  1. package/LICENSE +217 -0
  2. package/dist/bridge/bridge.d.ts +39 -0
  3. package/dist/bridge/bridge.js +184 -0
  4. package/dist/bridge/types.d.ts +67 -0
  5. package/dist/bridge/types.js +1 -0
  6. package/dist/compile/compile.d.ts +35 -0
  7. package/dist/compile/compile.js +30 -0
  8. package/dist/compile/policy.d.ts +55 -0
  9. package/dist/compile/policy.js +106 -0
  10. package/dist/config/capabilities.d.ts +154 -0
  11. package/dist/config/capabilities.js +63 -0
  12. package/dist/config/schema.d.ts +131 -0
  13. package/dist/config/schema.js +1 -0
  14. package/dist/config/validate.d.ts +20 -0
  15. package/dist/config/validate.js +230 -0
  16. package/dist/conformance/behavior.d.ts +2 -0
  17. package/dist/conformance/behavior.js +376 -0
  18. package/dist/conformance/index.d.ts +95 -0
  19. package/dist/conformance/index.js +4 -0
  20. package/dist/conformance/kit.d.ts +42 -0
  21. package/dist/conformance/kit.js +24 -0
  22. package/dist/conformance/oracles.d.ts +35 -0
  23. package/dist/conformance/oracles.js +77 -0
  24. package/dist/conformance/probes.d.ts +9 -0
  25. package/dist/conformance/probes.js +865 -0
  26. package/dist/conformance/served.d.ts +68 -0
  27. package/dist/conformance/served.js +311 -0
  28. package/dist/guide/examples.d.ts +70 -0
  29. package/dist/guide/examples.js +69 -0
  30. package/dist/guide/guide.d.ts +16 -0
  31. package/dist/guide/guide.js +261 -0
  32. package/dist/host/create-host.d.ts +8 -0
  33. package/dist/host/create-host.js +248 -0
  34. package/dist/host/locate.d.ts +8 -0
  35. package/dist/host/locate.js +11 -0
  36. package/dist/host/types.d.ts +95 -0
  37. package/dist/host/types.js +1 -0
  38. package/dist/index.d.ts +23 -0
  39. package/dist/index.js +19 -0
  40. package/dist/isolation/served.d.ts +32 -0
  41. package/dist/isolation/served.js +97 -0
  42. package/dist/isolation/types.d.ts +87 -0
  43. package/dist/isolation/types.js +1 -0
  44. package/dist/mcp/adapter.d.ts +43 -0
  45. package/dist/mcp/adapter.js +327 -0
  46. package/dist/mcp/index.d.ts +27 -0
  47. package/dist/mcp/index.js +25 -0
  48. package/dist/mcp/proxy.d.ts +25 -0
  49. package/dist/mcp/proxy.js +106 -0
  50. package/dist/mcp/view.d.ts +31 -0
  51. package/dist/mcp/view.js +72 -0
  52. package/dist/platform/types.d.ts +44 -0
  53. package/dist/platform/types.js +1 -0
  54. package/dist/platform/web.d.ts +8 -0
  55. package/dist/platform/web.js +72 -0
  56. package/dist/presets/presets.d.ts +65 -0
  57. package/dist/presets/presets.js +125 -0
  58. package/dist/protocol/adapter.d.ts +73 -0
  59. package/dist/protocol/adapter.js +1 -0
  60. package/dist/protocol/jsonrpc.d.ts +43 -0
  61. package/dist/protocol/jsonrpc.js +50 -0
  62. package/dist/protocol/mcp-apps.d.ts +79 -0
  63. package/dist/protocol/mcp-apps.js +99 -0
  64. package/dist/protocol/shell-adapter.d.ts +5 -0
  65. package/dist/protocol/shell-adapter.js +228 -0
  66. package/dist/react/dialog.d.ts +20 -0
  67. package/dist/react/dialog.js +98 -0
  68. package/dist/react/frame.d.ts +75 -0
  69. package/dist/react/frame.js +221 -0
  70. package/dist/react/index.d.ts +13 -0
  71. package/dist/react/index.js +13 -0
  72. package/dist/react/live.d.ts +24 -0
  73. package/dist/react/live.js +57 -0
  74. package/dist/server/index.d.ts +27 -0
  75. package/dist/server/index.js +26 -0
  76. package/dist/server/keys.d.ts +19 -0
  77. package/dist/server/keys.js +78 -0
  78. package/dist/server/pages.d.ts +6 -0
  79. package/dist/server/pages.js +20 -0
  80. package/dist/server/proxy.d.ts +21 -0
  81. package/dist/server/proxy.js +107 -0
  82. package/dist/server/server.d.ts +7 -0
  83. package/dist/server/server.js +518 -0
  84. package/dist/server/tickets.d.ts +8 -0
  85. package/dist/server/tickets.js +28 -0
  86. package/dist/server/types.d.ts +97 -0
  87. package/dist/server/types.js +1 -0
  88. package/dist/shell/document.d.ts +28 -0
  89. package/dist/shell/document.js +84 -0
  90. package/dist/shell/plan.d.ts +35 -0
  91. package/dist/shell/plan.js +34 -0
  92. package/dist/shell/runtime.d.ts +28 -0
  93. package/dist/shell/runtime.js +832 -0
  94. package/dist/theme/variables.d.ts +73 -0
  95. package/dist/theme/variables.js +171 -0
  96. package/dist/transport/post-message.d.ts +9 -0
  97. package/dist/transport/post-message.js +38 -0
  98. package/dist/transport/types.d.ts +15 -0
  99. package/dist/transport/types.js +1 -0
  100. package/package.json +71 -1
  101. package/src/bridge/bridge.ts +256 -0
  102. package/src/bridge/types.ts +77 -0
  103. package/src/compile/compile.ts +68 -0
  104. package/src/compile/policy.ts +133 -0
  105. package/src/config/capabilities.ts +138 -0
  106. package/src/config/schema.ts +140 -0
  107. package/src/config/validate.ts +284 -0
  108. package/src/conformance/behavior.ts +430 -0
  109. package/src/conformance/index.ts +102 -0
  110. package/src/conformance/kit.ts +63 -0
  111. package/src/conformance/oracles.ts +126 -0
  112. package/src/conformance/probes.ts +1093 -0
  113. package/src/conformance/served.ts +472 -0
  114. package/src/guide/examples.ts +74 -0
  115. package/src/guide/guide.ts +424 -0
  116. package/src/host/create-host.ts +290 -0
  117. package/src/host/locate.ts +20 -0
  118. package/src/host/types.ts +95 -0
  119. package/src/index.ts +118 -0
  120. package/src/isolation/served.ts +127 -0
  121. package/src/isolation/types.ts +77 -0
  122. package/src/mcp/adapter.ts +441 -0
  123. package/src/mcp/index.ts +50 -0
  124. package/src/mcp/proxy.ts +117 -0
  125. package/src/mcp/view.ts +97 -0
  126. package/src/platform/types.ts +48 -0
  127. package/src/platform/web.ts +78 -0
  128. package/src/presets/presets.ts +193 -0
  129. package/src/protocol/adapter.ts +67 -0
  130. package/src/protocol/jsonrpc.ts +83 -0
  131. package/src/protocol/mcp-apps.ts +138 -0
  132. package/src/protocol/shell-adapter.ts +261 -0
  133. package/src/react/dialog.tsx +158 -0
  134. package/src/react/frame.tsx +422 -0
  135. package/src/react/index.ts +20 -0
  136. package/src/react/live.ts +76 -0
  137. package/src/server/index.ts +37 -0
  138. package/src/server/keys.ts +100 -0
  139. package/src/server/pages.ts +31 -0
  140. package/src/server/proxy.ts +131 -0
  141. package/src/server/server.ts +617 -0
  142. package/src/server/tickets.ts +30 -0
  143. package/src/server/types.ts +104 -0
  144. package/src/shell/document.ts +120 -0
  145. package/src/shell/plan.ts +66 -0
  146. package/src/shell/runtime.ts +832 -0
  147. package/src/theme/variables.ts +190 -0
  148. package/src/transport/post-message.ts +45 -0
  149. package/src/transport/types.ts +16 -0
@@ -0,0 +1,73 @@
1
+ import type { ColorScheme, ThemeConfig, ThemeValue } from "../config/schema.ts";
2
+ /**
3
+ * A data palette for charts: eight categorical slots in a fixed order (it keeps adjacent series
4
+ * apart for color-blind readers), a sequential blue ramp (1 is the step nearest zero, nearest the
5
+ * surface in each scheme), and a diverging pair around a gray midpoint. Each scheme's values are
6
+ * chosen for its own surface, not flipped.
7
+ */
8
+ export declare const CHART_PALETTE: {
9
+ readonly categorical: readonly [{
10
+ readonly hue: "blue";
11
+ readonly light: "#2a78d6";
12
+ readonly dark: "#3987e5";
13
+ }, {
14
+ readonly hue: "orange";
15
+ readonly light: "#eb6834";
16
+ readonly dark: "#d95926";
17
+ }, {
18
+ readonly hue: "aqua";
19
+ readonly light: "#1baf7a";
20
+ readonly dark: "#199e70";
21
+ }, {
22
+ readonly hue: "yellow";
23
+ readonly light: "#eda100";
24
+ readonly dark: "#c98500";
25
+ }, {
26
+ readonly hue: "magenta";
27
+ readonly light: "#e87ba4";
28
+ readonly dark: "#d55181";
29
+ }, {
30
+ readonly hue: "green";
31
+ readonly light: "#008300";
32
+ readonly dark: "#008300";
33
+ }, {
34
+ readonly hue: "violet";
35
+ readonly light: "#4a3aa7";
36
+ readonly dark: "#9085e9";
37
+ }, {
38
+ readonly hue: "red";
39
+ readonly light: "#e34948";
40
+ readonly dark: "#e66767";
41
+ }];
42
+ readonly sequential: {
43
+ readonly light: readonly ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"];
44
+ readonly dark: readonly ["#0d366b", "#184f95", "#256abf", "#3987e5", "#6da7ec", "#9ec5f4", "#cde2fb"];
45
+ };
46
+ readonly diverging: {
47
+ readonly neg: {
48
+ readonly light: "#e34948";
49
+ readonly dark: "#e66767";
50
+ };
51
+ readonly mid: {
52
+ readonly light: "#f0efec";
53
+ readonly dark: "#383835";
54
+ };
55
+ readonly pos: {
56
+ readonly light: "#2a78d6";
57
+ readonly dark: "#3987e5";
58
+ };
59
+ };
60
+ };
61
+ /** Every default variable, in the order the manual lists them. */
62
+ export declare const DEFAULT_THEME_VARIABLES: Readonly<Record<string, ThemeValue>>;
63
+ /** The window event the shell fires after a theme change, by default. */
64
+ export declare const DEFAULT_THEME_EVENT = "porthole:theme";
65
+ export declare const DEFAULT_THEME: ThemeConfig;
66
+ /** A variable's default for `scheme`. */
67
+ export declare function themeDefault(value: ThemeValue, scheme: ColorScheme): string;
68
+ /**
69
+ * The values the frame gets for `scheme`: for each of the config's variables, the host's live
70
+ * value when it has one (a non-empty string), else the variable's default. Names the config
71
+ * doesn't declare are dropped, so the frame only ever holds what the manual teaches.
72
+ */
73
+ export declare function resolveTheme(theme: ThemeConfig, scheme: ColorScheme, live?: Readonly<Record<string, string | null | undefined>>): Record<string, string>;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The default theme vocabulary: the MCP Apps style variable names (`styles.variables`,
3
+ * 2026-01-26), so content written for any MCP Apps host reads here too, plus Porthole's own accent
4
+ * and chart colors. Each has a neutral default per scheme; a host supplies its live values for the
5
+ * same names (`HostOptions.theme`). The shell injects exactly the config's names and the manual
6
+ * teaches exactly them (PRINCIPLE 3).
7
+ */
8
+ const both = (value) => value;
9
+ const pair = (light, dark) => ({ light, dark });
10
+ /**
11
+ * A data palette for charts: eight categorical slots in a fixed order (it keeps adjacent series
12
+ * apart for color-blind readers), a sequential blue ramp (1 is the step nearest zero, nearest the
13
+ * surface in each scheme), and a diverging pair around a gray midpoint. Each scheme's values are
14
+ * chosen for its own surface, not flipped.
15
+ */
16
+ export const CHART_PALETTE = {
17
+ categorical: [
18
+ { hue: "blue", light: "#2a78d6", dark: "#3987e5" },
19
+ { hue: "orange", light: "#eb6834", dark: "#d95926" },
20
+ { hue: "aqua", light: "#1baf7a", dark: "#199e70" },
21
+ { hue: "yellow", light: "#eda100", dark: "#c98500" },
22
+ { hue: "magenta", light: "#e87ba4", dark: "#d55181" },
23
+ { hue: "green", light: "#008300", dark: "#008300" },
24
+ { hue: "violet", light: "#4a3aa7", dark: "#9085e9" },
25
+ { hue: "red", light: "#e34948", dark: "#e66767" },
26
+ ],
27
+ sequential: {
28
+ light: ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"],
29
+ dark: ["#0d366b", "#184f95", "#256abf", "#3987e5", "#6da7ec", "#9ec5f4", "#cde2fb"],
30
+ },
31
+ diverging: {
32
+ neg: { light: "#e34948", dark: "#e66767" },
33
+ mid: { light: "#f0efec", dark: "#383835" },
34
+ pos: { light: "#2a78d6", dark: "#3987e5" },
35
+ },
36
+ };
37
+ const families = (kind, values) => Object.entries(values).map(([family, value]) => [`--color-${kind}-${family}`, value]);
38
+ const heading = (size, px, line) => [
39
+ [`--font-heading-${size}-size`, both(`${px}px`)],
40
+ [`--font-heading-${size}-line-height`, both(`${line}px`)],
41
+ ];
42
+ /** Every default variable, in the order the manual lists them. */
43
+ export const DEFAULT_THEME_VARIABLES = Object.fromEntries([
44
+ // Surfaces. The content's own background stays transparent: it sits on the host's surface,
45
+ // which is --color-background-primary.
46
+ ["--color-background-primary", pair("#f9f9f7", "#191918")],
47
+ ["--color-background-secondary", pair("#ffffff", "#232321")],
48
+ ["--color-background-tertiary", pair("#f3f3f1", "#2b2b29")],
49
+ ["--color-background-inverse", pair("#1a1a19", "#f4f4f2")],
50
+ ["--color-background-ghost", pair("rgba(11, 11, 11, 0.04)", "rgba(255, 255, 255, 0.05)")],
51
+ ["--color-background-disabled", pair("#efefec", "#2e2e2c")],
52
+ ...families("background", {
53
+ info: pair("#e8f1fc", "#16283d"),
54
+ success: pair("#e7f5ec", "#15301f"),
55
+ warning: pair("#fdf3e1", "#3a2a10"),
56
+ danger: pair("#fdecec", "#3a1a18"),
57
+ }),
58
+ // Text.
59
+ ["--color-text-primary", pair("#0b0b0b", "#f4f4f2")],
60
+ ["--color-text-secondary", pair("#52514e", "#b5b4ae")],
61
+ ["--color-text-tertiary", pair("#898781", "#8f8e88")],
62
+ ["--color-text-inverse", pair("#ffffff", "#0b0b0b")],
63
+ ["--color-text-ghost", pair("#a3a29c", "#6e6d68")],
64
+ ["--color-text-disabled", pair("#b5b4ae", "#5c5b57")],
65
+ ...families("text", {
66
+ info: pair("#1f5fae", "#8cbcf5"),
67
+ success: pair("#1d7a3e", "#86d3a2"),
68
+ warning: pair("#8a5a00", "#e8b85c"),
69
+ danger: pair("#b42318", "#f19a92"),
70
+ }),
71
+ // Borders and focus rings.
72
+ ["--color-border-primary", pair("#c3c2b7", "#4a4a46")],
73
+ ["--color-border-secondary", pair("#e1e0d9", "#393936")],
74
+ ["--color-border-tertiary", pair("#ebeae5", "#2f2f2d")],
75
+ ["--color-border-inverse", pair("#3a3a37", "#d6d5cf")],
76
+ ["--color-border-ghost", pair("rgba(11, 11, 11, 0.06)", "rgba(255, 255, 255, 0.08)")],
77
+ ["--color-border-disabled", pair("#e6e5e0", "#333331")],
78
+ ...families("border", {
79
+ info: pair("#a9c8f0", "#2e5584"),
80
+ success: pair("#a6d9b8", "#2c6141"),
81
+ warning: pair("#f0cf8c", "#6e5221"),
82
+ danger: pair("#f1b0ab", "#7a2f29"),
83
+ }),
84
+ ["--color-ring-primary", pair("#2a78d6", "#3987e5")],
85
+ ["--color-ring-secondary", pair("#898781", "#8f8e88")],
86
+ ["--color-ring-inverse", pair("#ffffff", "#0b0b0b")],
87
+ ...families("ring", {
88
+ info: pair("#2a78d6", "#3987e5"),
89
+ success: pair("#1d7a3e", "#86d3a2"),
90
+ warning: pair("#b07400", "#e8b85c"),
91
+ danger: pair("#d92d20", "#f19a92"),
92
+ }),
93
+ // Porthole's own: the accent (a button), and the chart colors.
94
+ ["--color-accent", pair("#2a78d6", "#3987e5")],
95
+ ["--color-accent-soft", pair("#e8f1fc", "#16283d")],
96
+ ...CHART_PALETTE.categorical.map((slot, index) => [
97
+ `--color-chart-${index + 1}`,
98
+ pair(slot.light, slot.dark),
99
+ ]),
100
+ ...CHART_PALETTE.sequential.light.map((light, index) => [
101
+ `--color-chart-seq-${index + 1}`,
102
+ pair(light, CHART_PALETTE.sequential.dark[index] ?? light),
103
+ ]),
104
+ ...["neg", "mid", "pos"].map((pole) => [
105
+ `--color-chart-${pole}`,
106
+ CHART_PALETTE.diverging[pole],
107
+ ]),
108
+ // Type.
109
+ ["--font-sans", both('system-ui, -apple-system, "Segoe UI", Roboto, sans-serif')],
110
+ ["--font-mono", both('ui-monospace, "SF Mono", Menlo, Consolas, monospace')],
111
+ ["--font-weight-normal", both("400")],
112
+ ["--font-weight-medium", both("500")],
113
+ ["--font-weight-semibold", both("600")],
114
+ ["--font-weight-bold", both("700")],
115
+ ["--font-text-xs-size", both("12px")],
116
+ ["--font-text-xs-line-height", both("16px")],
117
+ ["--font-text-sm-size", both("13px")],
118
+ ["--font-text-sm-line-height", both("18px")],
119
+ ["--font-text-md-size", both("14px")],
120
+ ["--font-text-md-line-height", both("20px")],
121
+ ["--font-text-lg-size", both("16px")],
122
+ ["--font-text-lg-line-height", both("24px")],
123
+ ...heading("xs", 14, 20),
124
+ ...heading("sm", 16, 24),
125
+ ...heading("md", 20, 28),
126
+ ...heading("lg", 24, 32),
127
+ ...heading("xl", 28, 36),
128
+ ...heading("2xl", 32, 40),
129
+ ...heading("3xl", 40, 48),
130
+ // Shape.
131
+ ["--border-radius-xs", both("4px")],
132
+ ["--border-radius-sm", both("6px")],
133
+ ["--border-radius-md", both("8px")],
134
+ ["--border-radius-lg", both("12px")],
135
+ ["--border-radius-xl", both("16px")],
136
+ ["--border-radius-full", both("9999px")],
137
+ ["--border-width-regular", both("1px")],
138
+ [
139
+ "--shadow-hairline",
140
+ pair("0 0 0 1px rgba(11, 11, 11, 0.06)", "0 0 0 1px rgba(255, 255, 255, 0.08)"),
141
+ ],
142
+ ["--shadow-sm", pair("0 1px 2px rgba(11, 11, 11, 0.06)", "0 1px 2px rgba(0, 0, 0, 0.4)")],
143
+ ["--shadow-md", pair("0 2px 8px rgba(11, 11, 11, 0.08)", "0 2px 8px rgba(0, 0, 0, 0.45)")],
144
+ ["--shadow-lg", pair("0 8px 24px rgba(11, 11, 11, 0.12)", "0 8px 24px rgba(0, 0, 0, 0.5)")],
145
+ ]);
146
+ /** The window event the shell fires after a theme change, by default. */
147
+ export const DEFAULT_THEME_EVENT = "porthole:theme";
148
+ export const DEFAULT_THEME = {
149
+ variables: DEFAULT_THEME_VARIABLES,
150
+ event: DEFAULT_THEME_EVENT,
151
+ };
152
+ /** A variable's default for `scheme`. */
153
+ export function themeDefault(value, scheme) {
154
+ return typeof value === "string" ? value : value[scheme];
155
+ }
156
+ /**
157
+ * The values the frame gets for `scheme`: for each of the config's variables, the host's live
158
+ * value when it has one (a non-empty string), else the variable's default. Names the config
159
+ * doesn't declare are dropped, so the frame only ever holds what the manual teaches.
160
+ */
161
+ export function resolveTheme(theme, scheme, live) {
162
+ const values = {};
163
+ for (const [name, value] of Object.entries(theme.variables)) {
164
+ const given = live?.[name]?.trim();
165
+ values[name] =
166
+ given === undefined || given === "" || /[;{}<>]/u.test(given)
167
+ ? themeDefault(value, scheme)
168
+ : given;
169
+ }
170
+ return values;
171
+ }
@@ -0,0 +1,9 @@
1
+ import type { Transport } from "./types.ts";
2
+ /**
3
+ * A transport to one frame over `postMessage`. A message counts as the frame's only if it comes
4
+ * from the frame's own window AND from the expected origin: `"null"` for an opaque frame (every
5
+ * opaque frame says "null", so the window is what identifies it), the served origin otherwise.
6
+ * Messages go to the frame's window addressed to that origin; an opaque frame can only be
7
+ * addressed as `"*"`, so nothing secret is ever sent.
8
+ */
9
+ export declare function postMessageTransport(frameWindow: () => Window | null, expectedOrigin: string, host?: Window): Transport;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A transport to one frame over `postMessage`. A message counts as the frame's only if it comes
3
+ * from the frame's own window AND from the expected origin: `"null"` for an opaque frame (every
4
+ * opaque frame says "null", so the window is what identifies it), the served origin otherwise.
5
+ * Messages go to the frame's window addressed to that origin; an opaque frame can only be
6
+ * addressed as `"*"`, so nothing secret is ever sent.
7
+ */
8
+ export function postMessageTransport(frameWindow, expectedOrigin, host = window) {
9
+ const listeners = new Set();
10
+ let closed = false;
11
+ const onMessage = (event) => {
12
+ const source = frameWindow();
13
+ if (closed || source === null || event.source !== source || event.origin !== expectedOrigin) {
14
+ return;
15
+ }
16
+ for (const listener of listeners) {
17
+ listener(event.data);
18
+ }
19
+ };
20
+ host.addEventListener("message", onMessage);
21
+ const target = expectedOrigin === "null" ? "*" : expectedOrigin;
22
+ return {
23
+ send(message) {
24
+ if (!closed) {
25
+ frameWindow()?.postMessage(message, target);
26
+ }
27
+ },
28
+ listen(listener) {
29
+ listeners.add(listener);
30
+ return () => listeners.delete(listener);
31
+ },
32
+ close() {
33
+ closed = true;
34
+ listeners.clear();
35
+ host.removeEventListener("message", onMessage);
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,15 @@
1
+ import type { JsonRpcMessage } from "../protocol/jsonrpc.ts";
2
+ /**
3
+ * A message channel to one frame. The platform host makes it (on the web, `postMessage` to the
4
+ * frame's window); adapters speak through it. It checks WHO sent a message (this frame's window,
5
+ * from its expected origin: `"null"` for an opaque frame, the served origin otherwise) and drops
6
+ * everything else; adapters check WHAT it says.
7
+ */
8
+ export interface Transport {
9
+ /** Sends one message to the frame. Never secret: an opaque frame can only be addressed as `*`. */
10
+ send(message: JsonRpcMessage): void;
11
+ /** Every message from this frame, still unchecked in shape. Returns an unsubscribe function. */
12
+ listen(listener: (message: unknown) => void): () => void;
13
+ /** Stops listening and sending. */
14
+ close(): void;
15
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1 +1,71 @@
1
- {"name":"@botiverse/porthole","version":"0.0.1","license":"Apache-2.0","description":"placeholder"}
1
+ {
2
+ "name": "@botiverse/porthole",
3
+ "version": "0.1.0",
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
+ "license": "Apache-2.0",
6
+ "files": [
7
+ "src",
8
+ "dist"
9
+ ],
10
+ "type": "module",
11
+ "sideEffects": false,
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./react": {
18
+ "types": "./dist/react/index.d.ts",
19
+ "default": "./dist/react/index.js"
20
+ },
21
+ "./mcp": {
22
+ "types": "./dist/mcp/index.d.ts",
23
+ "default": "./dist/mcp/index.js"
24
+ },
25
+ "./conformance": {
26
+ "types": "./dist/conformance/index.d.ts",
27
+ "default": "./dist/conformance/index.js"
28
+ },
29
+ "./server": {
30
+ "types": "./dist/server/index.d.ts",
31
+ "default": "./dist/server/index.js"
32
+ }
33
+ },
34
+ "devDependencies": {
35
+ "@modelcontextprotocol/ext-apps": "2.0.3",
36
+ "@modelcontextprotocol/server-basic-react": "2.0.3",
37
+ "@modelcontextprotocol/server-basic-vanillajs": "2.0.3",
38
+ "@modelcontextprotocol/server-threejs": "2.0.3",
39
+ "@playwright/test": "1.63.0",
40
+ "@types/node": "26.6.3",
41
+ "@types/react": "19.3.0",
42
+ "@types/react-dom": "19.3.0",
43
+ "esbuild": "0.28.2",
44
+ "react": "19.3.0",
45
+ "react-dom": "19.3.0",
46
+ "typescript": "7.0.2"
47
+ },
48
+ "peerDependencies": {
49
+ "@modelcontextprotocol/ext-apps": ">=2.0.0",
50
+ "@playwright/test": ">=1.63.0",
51
+ "react": ">=19"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "@playwright/test": {
55
+ "optional": true
56
+ },
57
+ "react": {
58
+ "optional": true
59
+ },
60
+ "@modelcontextprotocol/ext-apps": {
61
+ "optional": true
62
+ }
63
+ },
64
+ "engines": {
65
+ "node": ">=24"
66
+ },
67
+ "scripts": {
68
+ "build": "tsc -p tsconfig.build.json",
69
+ "test:leak": "playwright test -c leak/playwright.config.ts"
70
+ }
71
+ }
@@ -0,0 +1,256 @@
1
+ import {
2
+ capabilitySpec,
3
+ type CapabilityArgs,
4
+ type CapabilityName,
5
+ type CapabilityResults,
6
+ } from "../config/capabilities.ts";
7
+ import type { PortholeConfig } from "../config/schema.ts";
8
+ import { effectiveGates } from "../compile/policy.ts";
9
+ import type {
10
+ CallOutcome,
11
+ CapabilityHandler,
12
+ CapabilityHandlers,
13
+ Confirm,
14
+ ConfirmRequest,
15
+ HostFunctionBridge,
16
+ Refusal,
17
+ } from "./types.ts";
18
+
19
+ /**
20
+ * The host-function bridge: every capability call from a frame passes here, and nowhere else
21
+ * reaches a host handler. In order: declared → arguments well formed → not muted → within its
22
+ * rate → a user gesture, if gated so → a person's confirmation, if gated so → the handler.
23
+ */
24
+
25
+ export type BridgeEvent =
26
+ | { readonly type: "resize"; readonly height: number; readonly width?: number }
27
+ | { readonly type: "error"; readonly message: string }
28
+ | { readonly type: "refused"; readonly capability: CapabilityName; readonly refusal: Refusal }
29
+ | { readonly type: "muted" };
30
+
31
+ export interface BridgeOptions {
32
+ readonly config: PortholeConfig;
33
+ readonly display: { readonly id: string; readonly title: string };
34
+ readonly handlers: CapabilityHandlers;
35
+ readonly confirm?: Confirm;
36
+ /** Whether the embedding page has user activation now; undefined where it can't tell. */
37
+ readonly userActivation?: () => boolean | undefined;
38
+ readonly onEvent: (event: BridgeEvent) => void;
39
+ /** The clock, in milliseconds (tests pass their own). */
40
+ readonly now?: () => number;
41
+ }
42
+
43
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
44
+ value !== null && typeof value === "object" && !Array.isArray(value);
45
+
46
+ const MAX_URL = 4_096;
47
+
48
+ /** An http(s) URL without credentials in it (`https://bank.example@evil.example` reads as a lie). */
49
+ export function linkTarget(value: unknown): string | null {
50
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_URL) {
51
+ return null;
52
+ }
53
+ let url: URL;
54
+ try {
55
+ url = new URL(value.trim());
56
+ } catch {
57
+ return null;
58
+ }
59
+ if (
60
+ (url.protocol !== "https:" && url.protocol !== "http:") ||
61
+ url.username !== "" ||
62
+ url.password !== ""
63
+ ) {
64
+ return null;
65
+ }
66
+ return url.href;
67
+ }
68
+
69
+ type Validator<N extends CapabilityName> = (
70
+ args: unknown,
71
+ config: PortholeConfig,
72
+ ) => CapabilityArgs[N] | null;
73
+
74
+ const VALIDATORS: { readonly [N in CapabilityName]: Validator<N> } = {
75
+ sendPrompt: (args, config) => {
76
+ const text = isRecord(args) ? args.text : undefined;
77
+ return typeof text === "string" &&
78
+ text.trim() !== "" &&
79
+ text.length <= config.limits.maxPromptChars
80
+ ? { text }
81
+ : null;
82
+ },
83
+ openLink: (args) => {
84
+ const url = linkTarget(isRecord(args) ? args.url : undefined);
85
+ return url === null ? null : { url };
86
+ },
87
+ downloadFile: (args) => {
88
+ const contents = isRecord(args) ? args.contents : undefined;
89
+ return Array.isArray(contents) && contents.length > 0 && contents.length <= 100
90
+ ? { contents }
91
+ : null;
92
+ },
93
+ callTool: (args) => {
94
+ if (
95
+ !isRecord(args) ||
96
+ typeof args.name !== "string" ||
97
+ args.name === "" ||
98
+ args.name.length > 256
99
+ ) {
100
+ return null;
101
+ }
102
+ const given = args.arguments ?? {};
103
+ return isRecord(given) ? { name: args.name, arguments: given } : null;
104
+ },
105
+ readData: (args) => {
106
+ const uri = isRecord(args) ? args.uri : undefined;
107
+ return typeof uri === "string" && uri !== "" && uri.length <= MAX_URL ? { uri } : null;
108
+ },
109
+ setModelContext: (args) => {
110
+ if (!isRecord(args)) {
111
+ return null;
112
+ }
113
+ const { content, structuredContent } = args;
114
+ if (
115
+ (content !== undefined && !Array.isArray(content)) ||
116
+ (structuredContent !== undefined && !isRecord(structuredContent))
117
+ ) {
118
+ return null;
119
+ }
120
+ return {
121
+ ...(content === undefined ? {} : { content }),
122
+ ...(structuredContent === undefined ? {} : { structuredContent }),
123
+ };
124
+ },
125
+ reportError: (args) => {
126
+ const message = isRecord(args) ? args.message : undefined;
127
+ return typeof message === "string" && message !== ""
128
+ ? { message: message.slice(0, 2_000) }
129
+ : null;
130
+ },
131
+ resize: (args) => {
132
+ if (
133
+ !isRecord(args) ||
134
+ typeof args.height !== "number" ||
135
+ !Number.isFinite(args.height) ||
136
+ args.height < 0
137
+ ) {
138
+ return null;
139
+ }
140
+ const width = args.width;
141
+ return typeof width === "number" && Number.isFinite(width) && width >= 0
142
+ ? { height: args.height, width }
143
+ : { height: args.height };
144
+ },
145
+ };
146
+
147
+ export function createBridge(options: BridgeOptions): HostFunctionBridge {
148
+ const { config, display, handlers, onEvent } = options;
149
+ const now = options.now ?? (() => Date.now());
150
+ const calls = new Map<CapabilityName, number[]>();
151
+ let declines = 0;
152
+ let muted = false;
153
+
154
+ const refuse = <T>(
155
+ capability: CapabilityName,
156
+ refusal: Refusal,
157
+ message?: string,
158
+ ): CallOutcome<T> => {
159
+ onEvent({ type: "refused", capability, refusal });
160
+ return { ok: false, refusal, ...(message === undefined ? {} : { message }) };
161
+ };
162
+
163
+ const withinRate = (capability: CapabilityName, perMinute: number): boolean => {
164
+ const at = now();
165
+ const recent = (calls.get(capability) ?? []).filter((time) => at - time < 60_000);
166
+ if (recent.length >= perMinute) {
167
+ calls.set(capability, recent);
168
+ return false;
169
+ }
170
+ recent.push(at);
171
+ calls.set(capability, recent);
172
+ return true;
173
+ };
174
+
175
+ return {
176
+ declared: (capability) => config.capabilities[capability] !== undefined,
177
+
178
+ async call<N extends CapabilityName>(
179
+ capability: N,
180
+ raw: unknown,
181
+ callOptions?: { readonly userActivated?: boolean },
182
+ ): Promise<CallOutcome<CapabilityResults[N]>> {
183
+ type Result = CapabilityResults[N];
184
+ if (config.capabilities[capability] === undefined) {
185
+ return refuse<Result>(capability, "not-declared");
186
+ }
187
+ const args = (VALIDATORS[capability] as Validator<N>)(raw, config);
188
+ if (args === null) {
189
+ return refuse<Result>(capability, "invalid");
190
+ }
191
+ const gates = effectiveGates(config, capability);
192
+ const confirms = gates.includes("confirm");
193
+ if (confirms && muted) {
194
+ return refuse<Result>(capability, "muted");
195
+ }
196
+ for (const gate of gates) {
197
+ if (typeof gate === "object" && !withinRate(capability, gate.rateLimit.perMinute)) {
198
+ return refuse<Result>(capability, "rate-limited");
199
+ }
200
+ }
201
+ if (gates.includes("user-activation")) {
202
+ const activated = callOptions?.userActivated ?? options.userActivation?.();
203
+ if (activated === false) {
204
+ return refuse<Result>(capability, "no-user-activation");
205
+ }
206
+ }
207
+ if (confirms) {
208
+ if (options.confirm === undefined) {
209
+ return refuse<Result>(capability, "no-confirm");
210
+ }
211
+ let accepted = false;
212
+ try {
213
+ accepted = await options.confirm({ capability, args, display } as ConfirmRequest);
214
+ } catch {
215
+ accepted = false;
216
+ }
217
+ if (!accepted) {
218
+ declines += 1;
219
+ if (declines >= config.limits.declinesBeforeMute && !muted) {
220
+ muted = true;
221
+ onEvent({ type: "muted" });
222
+ }
223
+ return refuse<Result>(capability, "declined");
224
+ }
225
+ }
226
+
227
+ if (capabilitySpec(capability).served === "event") {
228
+ if (capability === "resize") {
229
+ const size = args as CapabilityArgs["resize"];
230
+ onEvent({ type: "resize", ...size });
231
+ } else {
232
+ onEvent({ type: "error", message: (args as CapabilityArgs["reportError"]).message });
233
+ }
234
+ return { ok: true, value: undefined as Result };
235
+ }
236
+ const handler = (handlers as Record<string, CapabilityHandler<N> | undefined>)[capability];
237
+ if (handler === undefined) {
238
+ return refuse<Result>(capability, "not-declared");
239
+ }
240
+ try {
241
+ const value = await handler(args, {
242
+ displayId: display.id,
243
+ title: display.title,
244
+ capability,
245
+ });
246
+ return { ok: true, value };
247
+ } catch (error) {
248
+ return refuse<Result>(
249
+ capability,
250
+ "failed",
251
+ error instanceof Error ? error.message : String(error),
252
+ );
253
+ }
254
+ },
255
+ };
256
+ }