@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.
- package/dist/annotate/types.d.ts +120 -0
- package/dist/annotate/types.js +42 -0
- package/dist/annotate/validate.d.ts +11 -0
- package/dist/annotate/validate.js +156 -0
- package/dist/bridge/bridge.d.ts +4 -0
- package/dist/bridge/bridge.js +12 -1
- package/dist/compile/policy.d.ts +5 -3
- package/dist/compile/policy.js +8 -4
- package/dist/config/capabilities.d.ts +37 -4
- package/dist/config/capabilities.js +20 -0
- package/dist/config/schema.d.ts +8 -0
- package/dist/config/schema.js +5 -0
- package/dist/config/validate.js +15 -1
- package/dist/conformance/clipboard.d.ts +2 -0
- package/dist/conformance/clipboard.js +167 -0
- package/dist/conformance/kit.d.ts +1 -1
- package/dist/conformance/probes.js +2 -0
- package/dist/guide/guide.js +8 -4
- package/dist/host/clipboard.d.ts +11 -0
- package/dist/host/clipboard.js +43 -0
- package/dist/host/create-host.js +141 -0
- package/dist/host/outcome.d.ts +1 -0
- package/dist/host/outcome.js +19 -6
- package/dist/host/types.d.ts +34 -5
- package/dist/index.d.ts +8 -4
- package/dist/index.js +8 -3
- package/dist/mcp/adapter.js +10 -1
- package/dist/presets/presets.d.ts +3 -1
- package/dist/presets/presets.js +1 -0
- package/dist/protocol/mcp-apps.d.ts +5 -0
- package/dist/protocol/mcp-apps.js +5 -0
- package/dist/protocol/shell-adapter.js +6 -2
- package/dist/react/dialog.js +2 -0
- package/dist/react/frame.d.ts +18 -0
- package/dist/react/frame.js +82 -2
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +2 -1
- package/dist/react/toast.d.ts +33 -0
- package/dist/react/toast.js +42 -0
- package/dist/server/crypto.d.ts +19 -0
- package/dist/server/crypto.js +144 -0
- package/dist/server/index.d.ts +12 -4
- package/dist/server/index.js +11 -3
- package/dist/server/keys.js +12 -9
- package/dist/server/naming.d.ts +17 -0
- package/dist/server/naming.js +34 -0
- package/dist/server/pages.d.ts +1 -1
- package/dist/server/pages.js +13 -5
- package/dist/server/server.js +162 -18
- package/dist/server/tickets.d.ts +24 -0
- package/dist/server/tickets.js +46 -0
- package/dist/server/types.d.ts +76 -5
- package/dist/shell/annotate.d.ts +20 -0
- package/dist/shell/annotate.js +669 -0
- package/dist/shell/document.js +4 -1
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +2 -1
- package/dist/shell/runtime.d.ts +4 -1
- package/dist/shell/runtime.js +38 -2
- package/package.json +2 -1
- package/src/annotate/types.ts +126 -0
- package/src/annotate/validate.ts +186 -0
- package/src/bridge/bridge.ts +13 -0
- package/src/compile/policy.ts +8 -4
- package/src/config/capabilities.ts +33 -3
- package/src/config/schema.ts +10 -0
- package/src/config/validate.ts +16 -1
- package/src/conformance/clipboard.ts +184 -0
- package/src/conformance/kit.ts +1 -1
- package/src/conformance/probes.ts +2 -0
- package/src/guide/guide.ts +19 -4
- package/src/host/clipboard.ts +41 -0
- package/src/host/create-host.ts +155 -0
- package/src/host/outcome.ts +25 -7
- package/src/host/types.ts +39 -5
- package/src/index.ts +31 -1
- package/src/mcp/adapter.ts +10 -1
- package/src/presets/presets.ts +4 -0
- package/src/protocol/mcp-apps.ts +6 -0
- package/src/protocol/shell-adapter.ts +11 -2
- package/src/react/dialog.tsx +2 -0
- package/src/react/frame.tsx +117 -1
- package/src/react/index.ts +9 -1
- package/src/react/toast.tsx +96 -0
- package/src/server/crypto.ts +166 -0
- package/src/server/index.ts +20 -3
- package/src/server/keys.ts +21 -13
- package/src/server/naming.ts +42 -0
- package/src/server/pages.ts +13 -4
- package/src/server/server.ts +190 -20
- package/src/server/tickets.ts +65 -0
- package/src/server/types.ts +84 -7
- package/src/shell/annotate.ts +669 -0
- package/src/shell/document.ts +6 -1
- package/src/shell/plan.ts +4 -1
- package/src/shell/runtime.ts +38 -2
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { expect, type Page } from "@playwright/test";
|
|
2
|
+
import type { LaunchedDisplay } from "./index.ts";
|
|
3
|
+
import { inline, type Probe } from "./kit.ts";
|
|
4
|
+
import { settle } from "./oracles.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The clipboard (item 13, P051): content can't write it, whatever it tries in a person's click,
|
|
8
|
+
* and what a person copies themselves is what they selected. `copyText` is the way copy buttons
|
|
9
|
+
* work instead (item 12, P052). The oracle is the person's own clipboard: known text put on it
|
|
10
|
+
* from the host's page, then pasted back into a text area there.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const SEED = "porthole-seed";
|
|
14
|
+
|
|
15
|
+
/** A text area of the oracle's own on the host's page. */
|
|
16
|
+
async function area(page: Page, id: string): Promise<void> {
|
|
17
|
+
await page.evaluate((each) => {
|
|
18
|
+
if (document.getElementById(each) === null) {
|
|
19
|
+
const element = document.createElement("textarea");
|
|
20
|
+
element.id = each;
|
|
21
|
+
element.style.cssText = "display:block;width:200px;height:40px";
|
|
22
|
+
document.body.prepend(element);
|
|
23
|
+
}
|
|
24
|
+
}, id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Puts `text` on the clipboard the way a person does: selects it on the host's page, copies. */
|
|
28
|
+
async function seed(page: Page, text: string): Promise<void> {
|
|
29
|
+
await area(page, "porthole-oracle-seed");
|
|
30
|
+
await page.evaluate((each) => {
|
|
31
|
+
(document.getElementById("porthole-oracle-seed") as HTMLTextAreaElement).value = each;
|
|
32
|
+
}, text);
|
|
33
|
+
await page.locator("#porthole-oracle-seed").click();
|
|
34
|
+
await page.keyboard.press("ControlOrMeta+A");
|
|
35
|
+
await page.keyboard.press("ControlOrMeta+C");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What is on the clipboard: pasted into a text area on the host's page. */
|
|
39
|
+
async function pasted(page: Page): Promise<string> {
|
|
40
|
+
await area(page, "porthole-oracle-paste");
|
|
41
|
+
await page.evaluate(() => {
|
|
42
|
+
(document.getElementById("porthole-oracle-paste") as HTMLTextAreaElement).value = "";
|
|
43
|
+
});
|
|
44
|
+
await page.locator("#porthole-oracle-paste").click();
|
|
45
|
+
await page.keyboard.press("ControlOrMeta+V");
|
|
46
|
+
await settle(200);
|
|
47
|
+
return page.evaluate(
|
|
48
|
+
() => (document.getElementById("porthole-oracle-paste") as HTMLTextAreaElement).value,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Clicks `selector` in the display (a real click: the user activation a person gives). */
|
|
53
|
+
async function click(display: LaunchedDisplay, selector: string): Promise<void> {
|
|
54
|
+
await (await display.frame()).locator(selector).click();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function bodyData(display: LaunchedDisplay, name: string): Promise<string> {
|
|
58
|
+
const frame = await display.frame();
|
|
59
|
+
await expect
|
|
60
|
+
.poll(() => frame.locator("body").getAttribute(`data-${name}`), { timeout: 5_000 })
|
|
61
|
+
.not.toBeNull();
|
|
62
|
+
return (await frame.locator("body").getAttribute(`data-${name}`)) ?? "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const settled = (name: string) =>
|
|
66
|
+
`(value) => (document.body.dataset.${name} = String(value)), (error) => (document.body.dataset.${name} = "refused: " + (error && error.name))`;
|
|
67
|
+
|
|
68
|
+
export const CLIPBOARD_PROBES: readonly Probe[] = [
|
|
69
|
+
{
|
|
70
|
+
id: "13.clipboard-api",
|
|
71
|
+
item: 13,
|
|
72
|
+
title:
|
|
73
|
+
"content can't write (or read) the clipboard through the async Clipboard API, even in a click",
|
|
74
|
+
async run(context) {
|
|
75
|
+
const display = await context.launch({
|
|
76
|
+
config: inline(),
|
|
77
|
+
content: `<button id="go">copy</button>
|
|
78
|
+
<script>
|
|
79
|
+
document.getElementById("go").addEventListener("click", () => {
|
|
80
|
+
navigator.clipboard.writeText("porthole-pwned-text").then(${settled("text")});
|
|
81
|
+
if (typeof ClipboardItem === "function") {
|
|
82
|
+
navigator.clipboard.write([new ClipboardItem({ "text/plain": new Blob(["porthole-pwned-item"], { type: "text/plain" }) })]).then(${settled("item")});
|
|
83
|
+
} else {
|
|
84
|
+
document.body.dataset.item = "refused: no ClipboardItem";
|
|
85
|
+
}
|
|
86
|
+
navigator.clipboard.readText().then(${settled("read")});
|
|
87
|
+
});
|
|
88
|
+
</script>`,
|
|
89
|
+
});
|
|
90
|
+
await seed(context.page, SEED);
|
|
91
|
+
await click(display, "#go");
|
|
92
|
+
expect(await bodyData(display, "text")).toMatch(/^refused/u);
|
|
93
|
+
expect(await bodyData(display, "item")).toMatch(/^refused/u);
|
|
94
|
+
expect(await bodyData(display, "read")).toMatch(/^refused/u);
|
|
95
|
+
expect(await pasted(context.page), "the clipboard still holds the person's own").toBe(SEED);
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "13.clipboard-command",
|
|
100
|
+
item: 13,
|
|
101
|
+
title:
|
|
102
|
+
"content can't copy or cut its own text onto the clipboard with execCommand, even in a click",
|
|
103
|
+
async run(context) {
|
|
104
|
+
const display = await context.launch({
|
|
105
|
+
config: inline(),
|
|
106
|
+
content: `<button id="go">copy</button>
|
|
107
|
+
<script>
|
|
108
|
+
document.getElementById("go").addEventListener("click", () => {
|
|
109
|
+
const area = document.createElement("textarea");
|
|
110
|
+
area.value = "porthole-pwned-command";
|
|
111
|
+
area.style.cssText = "position:absolute;left:-9999px";
|
|
112
|
+
document.body.append(area);
|
|
113
|
+
area.select();
|
|
114
|
+
let copied = "threw";
|
|
115
|
+
let cut = "threw";
|
|
116
|
+
try { copied = document.execCommand("copy"); } catch (_) {}
|
|
117
|
+
area.select();
|
|
118
|
+
try { cut = document.execCommand("cut"); } catch (_) {}
|
|
119
|
+
document.body.dataset.copied = String(copied) + "," + String(cut);
|
|
120
|
+
});
|
|
121
|
+
</script>`,
|
|
122
|
+
});
|
|
123
|
+
await seed(context.page, SEED);
|
|
124
|
+
await click(display, "#go");
|
|
125
|
+
expect(await bodyData(display, "copied")).not.toContain("true");
|
|
126
|
+
expect(await pasted(context.page), "the clipboard still holds the person's own").toBe(SEED);
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "13.clipboard-event",
|
|
131
|
+
item: 13,
|
|
132
|
+
title:
|
|
133
|
+
"what a person copies is what they selected: no copy listener of the content's can swap it",
|
|
134
|
+
async run(context) {
|
|
135
|
+
const display = await context.launch({
|
|
136
|
+
config: inline(),
|
|
137
|
+
content: `<p id="safe">porthole-selected-text</p>
|
|
138
|
+
<script>
|
|
139
|
+
const swap = (event) => {
|
|
140
|
+
event.clipboardData.setData("text/plain", "porthole-pwned-event");
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
document.body.dataset.ran = "yes";
|
|
143
|
+
};
|
|
144
|
+
document.addEventListener("copy", swap);
|
|
145
|
+
document.addEventListener("copy", swap, true);
|
|
146
|
+
window.addEventListener("copy", swap);
|
|
147
|
+
document.getElementById("safe").oncopy = swap;
|
|
148
|
+
</script>`,
|
|
149
|
+
});
|
|
150
|
+
await seed(context.page, SEED);
|
|
151
|
+
const frame = await display.frame();
|
|
152
|
+
await frame.locator("#safe").click({ clickCount: 3 });
|
|
153
|
+
await context.page.keyboard.press("ControlOrMeta+C");
|
|
154
|
+
await settle(200);
|
|
155
|
+
// The positive control: the person's own copy works, and copies what they selected.
|
|
156
|
+
expect((await pasted(context.page)).trim()).toBe("porthole-selected-text");
|
|
157
|
+
expect(await frame.locator("body").getAttribute("data-ran")).toBeNull();
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: "12.copy-text",
|
|
162
|
+
item: 12,
|
|
163
|
+
title:
|
|
164
|
+
"copyText reaches the host's handler from a click, with the whole text, never without one",
|
|
165
|
+
async run(context) {
|
|
166
|
+
const display = await context.launch({
|
|
167
|
+
config: inline({ capabilities: { copyText: {} } }),
|
|
168
|
+
content: `<button id="copy">Copy the command</button>
|
|
169
|
+
<script>
|
|
170
|
+
document.getElementById("copy").addEventListener("click", () => {
|
|
171
|
+
copyText("npm test -- --run").then(${settled("result")});
|
|
172
|
+
});
|
|
173
|
+
copyText("copied on load").then(${settled("onload")});
|
|
174
|
+
</script>`,
|
|
175
|
+
});
|
|
176
|
+
expect(await bodyData(display, "onload")).toMatch(/^refused/u);
|
|
177
|
+
await click(display, "#copy");
|
|
178
|
+
expect(await bodyData(display, "result")).toBe("true");
|
|
179
|
+
expect(await display.calls()).toEqual([
|
|
180
|
+
{ capability: "copyText", args: { text: "npm test -- --run" } },
|
|
181
|
+
]);
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
];
|
package/src/conformance/kit.ts
CHANGED
|
@@ -29,7 +29,7 @@ export interface Probe {
|
|
|
29
29
|
readonly id: string;
|
|
30
30
|
/**
|
|
31
31
|
* The DESIGN.md egress-table item it proves; 0 for the shell's own wiring and interop, 11 for
|
|
32
|
-
* behavior (streaming, sizing, theming), 12 for host-function gates.
|
|
32
|
+
* behavior (streaming, sizing, theming), 12 for host-function gates, 13 for the clipboard.
|
|
33
33
|
*/
|
|
34
34
|
readonly item: number;
|
|
35
35
|
readonly title: string;
|
|
@@ -13,6 +13,7 @@ import { resolveTheme } from "../theme/variables.ts";
|
|
|
13
13
|
import type { ConformanceHost, LaunchedDisplay, LeakSuiteOptions } from "./index.ts";
|
|
14
14
|
import { attack, inline, js, type Probe, type ProbeContext } from "./kit.ts";
|
|
15
15
|
import { BEHAVIOR_PROBES } from "./behavior.ts";
|
|
16
|
+
import { CLIPBOARD_PROBES } from "./clipboard.ts";
|
|
16
17
|
import { readFileSync } from "node:fs";
|
|
17
18
|
import { createRequire } from "node:module";
|
|
18
19
|
import {
|
|
@@ -1025,6 +1026,7 @@ export const PROBES: readonly Probe[] = [
|
|
|
1025
1026
|
},
|
|
1026
1027
|
},
|
|
1027
1028
|
...BEHAVIOR_PROBES,
|
|
1029
|
+
...CLIPBOARD_PROBES,
|
|
1028
1030
|
];
|
|
1029
1031
|
|
|
1030
1032
|
type AnyTest = TestType<
|
package/src/guide/guide.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
MAX_COPY_CHARS,
|
|
3
|
+
capabilitySpec,
|
|
4
|
+
type CapabilityName,
|
|
5
|
+
type Gate,
|
|
6
|
+
} from "../config/capabilities.ts";
|
|
2
7
|
import type { PortholeConfig } from "../config/schema.ts";
|
|
3
8
|
import { BUILT_IN_TOPICS } from "../config/validate.ts";
|
|
4
9
|
import {
|
|
@@ -115,7 +120,7 @@ const loadsScripts = (config: PortholeConfig): boolean =>
|
|
|
115
120
|
egressOrigins(config, "script").length > 0;
|
|
116
121
|
|
|
117
122
|
/** What the sandbox withholds, from the sandbox tokens and the shell's own walls. */
|
|
118
|
-
function forbidden(config: PortholeConfig): string {
|
|
123
|
+
function forbidden(config: PortholeConfig, c: Context): string {
|
|
119
124
|
const tokens = sandboxTokens(config);
|
|
120
125
|
const items = ["position: fixed", "full screen"];
|
|
121
126
|
if (!tokens.includes("allow-modals")) {
|
|
@@ -130,7 +135,11 @@ function forbidden(config: PortholeConfig): string {
|
|
|
130
135
|
if (!tokens.includes("allow-downloads")) {
|
|
131
136
|
items.push("downloads");
|
|
132
137
|
}
|
|
133
|
-
items.push(
|
|
138
|
+
items.push(
|
|
139
|
+
c.global("copyText") ? "the clipboard (copy buttons call copyText)" : "the clipboard",
|
|
140
|
+
"frames (iframe, object, embed)",
|
|
141
|
+
"WebRTC",
|
|
142
|
+
);
|
|
134
143
|
return `Not allowed: ${items.join(", ")}.`;
|
|
135
144
|
}
|
|
136
145
|
|
|
@@ -172,7 +181,7 @@ function widget(c: Context): string {
|
|
|
172
181
|
"- 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, …).",
|
|
173
182
|
"- Touch: anything clickable is at least 44 px tall. Phones don't hover: what a hover shows, a tap (click) shows too.",
|
|
174
183
|
"",
|
|
175
|
-
`${forbidden(config)} ${network(config)} Keep it under ${Math.floor(limits.maxBytes / 1000)} KB.`,
|
|
184
|
+
`${forbidden(config, c)} ${network(config)} Keep it under ${Math.floor(limits.maxBytes / 1000)} KB.`,
|
|
176
185
|
loadsScripts(config)
|
|
177
186
|
? "\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."
|
|
178
187
|
: null,
|
|
@@ -337,6 +346,12 @@ const ENTRIES: { readonly [N in CapabilityName]: (c: Context, gates: readonly Ga
|
|
|
337
346
|
`- Data: the MCP Apps request resources/read (with the App SDK, app.readServerResource). ${gateWords(gates, "the request")}`,
|
|
338
347
|
setModelContext: (_c, gates) =>
|
|
339
348
|
`- Context for the agent: the MCP Apps request ui/update-model-context (with the App SDK, app.updateModelContext). ${gateWords(gates, "it")}`,
|
|
349
|
+
copyText: (c, gates) =>
|
|
350
|
+
c.global("copyText")
|
|
351
|
+
? `- 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.`
|
|
352
|
+
: `- 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")}`,
|
|
353
|
+
annotate: (c) =>
|
|
354
|
+
`- 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.`,
|
|
340
355
|
reportError: (c, gates) =>
|
|
341
356
|
`- 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(),
|
|
342
357
|
resize: () => "",
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
/**
|
|
8
|
+
* Writes `text` to the clipboard: the async Clipboard API, else a selected text area and the copy
|
|
9
|
+
* command. Resolves whether it worked; it can fail without a recent gesture, so a host shows the
|
|
10
|
+
* text either way (and offers its own Copy button when it failed).
|
|
11
|
+
*/
|
|
12
|
+
export async function writeClipboard(text: string, document: Document): Promise<boolean> {
|
|
13
|
+
const view = document.defaultView;
|
|
14
|
+
try {
|
|
15
|
+
if (view?.navigator.clipboard !== undefined) {
|
|
16
|
+
await view.navigator.clipboard.writeText(text);
|
|
17
|
+
return true;
|
|
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
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
} finally {
|
|
36
|
+
area.remove();
|
|
37
|
+
if (focused !== null && typeof (focused as HTMLElement).focus === "function") {
|
|
38
|
+
(focused as HTMLElement).focus({ preventScroll: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/host/create-host.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ANNOTATE_METHODS,
|
|
3
|
+
type AnnotateMode,
|
|
4
|
+
type AnnotationMark,
|
|
5
|
+
type AnnotationReport,
|
|
6
|
+
} from "../annotate/types.ts";
|
|
7
|
+
import { annotateMode, checkMarks, parseReport } from "../annotate/validate.ts";
|
|
1
8
|
import { createBridge, type BridgeEvent } from "../bridge/bridge.ts";
|
|
9
|
+
import type { HostFunctionBridge } from "../bridge/types.ts";
|
|
2
10
|
import { compile } from "../compile/compile.ts";
|
|
3
11
|
import { declaredCapabilities } from "../compile/policy.ts";
|
|
4
12
|
import { capabilitySpec } from "../config/capabilities.ts";
|
|
@@ -17,6 +25,7 @@ import {
|
|
|
17
25
|
import { shellAdapter } from "../protocol/shell-adapter.ts";
|
|
18
26
|
import { resolveTheme } from "../theme/variables.ts";
|
|
19
27
|
import type {
|
|
28
|
+
AnnotationControls,
|
|
20
29
|
DisplayEvents,
|
|
21
30
|
DisplayHandle,
|
|
22
31
|
HostOptions,
|
|
@@ -31,6 +40,17 @@ const DEFAULT_LOAD_TIMEOUT_MS = 15_000;
|
|
|
31
40
|
const MAX_RESTARTS = 3;
|
|
32
41
|
|
|
33
42
|
const BASE_KEYS = new Set(["hostContextChanged", "teardown", "close"]);
|
|
43
|
+
|
|
44
|
+
/** The frame's annotation reports, by method. */
|
|
45
|
+
const REPORTS: Readonly<Record<string, AnnotationReport["type"]>> = {
|
|
46
|
+
[ANNOTATE_METHODS.selected]: "selected",
|
|
47
|
+
[ANNOTATE_METHODS.unselected]: "unselected",
|
|
48
|
+
[ANNOTATE_METHODS.cancelled]: "cancelled",
|
|
49
|
+
[ANNOTATE_METHODS.anchored]: "anchored",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
53
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
34
54
|
const STANDARD = new Set(MCP_APPS_STYLE_VARIABLES);
|
|
35
55
|
|
|
36
56
|
/**
|
|
@@ -90,6 +110,129 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
90
110
|
const view = container.ownerDocument.defaultView;
|
|
91
111
|
// The adapter's own operations, the last call of each, replayed into every new frame.
|
|
92
112
|
const replay = new Map<string, unknown[]>();
|
|
113
|
+
// Annotations: the host's state, given to each frame once it finished its handshake.
|
|
114
|
+
const annotates = config.capabilities.annotate !== undefined;
|
|
115
|
+
let annotating: Required<AnnotateMode> | null = null;
|
|
116
|
+
let marks: AnnotationMark[] = [];
|
|
117
|
+
let activeMark: string | null = null;
|
|
118
|
+
let handshaken = false;
|
|
119
|
+
const toFrame = (method: string, params: Record<string, unknown>) => {
|
|
120
|
+
if (handshaken && surface !== null) {
|
|
121
|
+
surface.transport.send({ jsonrpc: "2.0", method, params });
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const giveAnnotations = () => {
|
|
125
|
+
if (marks.length > 0) {
|
|
126
|
+
toFrame(ANNOTATE_METHODS.show, {
|
|
127
|
+
marks,
|
|
128
|
+
...(activeMark === null ? {} : { active: activeMark }),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (annotating !== null) {
|
|
132
|
+
toFrame(ANNOTATE_METHODS.start, { ...annotating });
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* A report from the frame: checked, taken only while the host asked for it (a selection
|
|
137
|
+
* only in select mode, results only for its own marks), then through the bridge's gates.
|
|
138
|
+
*/
|
|
139
|
+
const onAnnotation = (raw: unknown, bridge: HostFunctionBridge) => {
|
|
140
|
+
if (
|
|
141
|
+
!isRecord(raw) ||
|
|
142
|
+
raw.jsonrpc !== "2.0" ||
|
|
143
|
+
"id" in raw ||
|
|
144
|
+
typeof raw.method !== "string"
|
|
145
|
+
) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const type = REPORTS[raw.method];
|
|
149
|
+
if (type === undefined) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let report = parseReport({ ...(isRecord(raw.params) ? raw.params : {}), type });
|
|
153
|
+
if (report === null) {
|
|
154
|
+
void bridge.call("annotate", null);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
switch (report.type) {
|
|
158
|
+
case "selected": {
|
|
159
|
+
const kind = report.anchor.kind;
|
|
160
|
+
if (
|
|
161
|
+
annotating === null ||
|
|
162
|
+
!(kind === "element" ? annotating.elements : annotating.text)
|
|
163
|
+
) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (kind === "element") {
|
|
167
|
+
annotating = annotating.text ? { elements: false, text: true } : null;
|
|
168
|
+
}
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "unselected":
|
|
172
|
+
if (annotating?.text !== true) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
case "cancelled":
|
|
177
|
+
if (annotating?.elements !== true) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
annotating = annotating.text ? { elements: false, text: true } : null;
|
|
181
|
+
break;
|
|
182
|
+
case "anchored": {
|
|
183
|
+
const known = new Set(marks.map((mark) => mark.id));
|
|
184
|
+
const results = report.results.filter((result) => known.has(result.id));
|
|
185
|
+
if (results.length === 0) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
report = { type: "anchored", results };
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
void bridge.call("annotate", report);
|
|
193
|
+
};
|
|
194
|
+
const needsAnnotate = (operation: string) => {
|
|
195
|
+
if (!annotates) {
|
|
196
|
+
throw new TypeError(`annotations.${operation}: the config doesn't declare annotate`);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const annotations: AnnotationControls = {
|
|
200
|
+
start(mode) {
|
|
201
|
+
needsAnnotate("start");
|
|
202
|
+
annotating = annotateMode(mode);
|
|
203
|
+
toFrame(ANNOTATE_METHODS.start, { ...annotating });
|
|
204
|
+
},
|
|
205
|
+
stop() {
|
|
206
|
+
needsAnnotate("stop");
|
|
207
|
+
annotating = null;
|
|
208
|
+
toFrame(ANNOTATE_METHODS.stop, {});
|
|
209
|
+
},
|
|
210
|
+
show(next) {
|
|
211
|
+
needsAnnotate("show");
|
|
212
|
+
marks = checkMarks(next);
|
|
213
|
+
if (activeMark !== null && !marks.some((mark) => mark.id === activeMark)) {
|
|
214
|
+
activeMark = null;
|
|
215
|
+
}
|
|
216
|
+
toFrame(ANNOTATE_METHODS.show, {
|
|
217
|
+
marks,
|
|
218
|
+
...(activeMark === null ? {} : { active: activeMark }),
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
focus(id) {
|
|
222
|
+
needsAnnotate("focus");
|
|
223
|
+
if (!marks.some((mark) => mark.id === id)) {
|
|
224
|
+
throw new TypeError(`annotations.focus: no mark ${JSON.stringify(id)} is shown`);
|
|
225
|
+
}
|
|
226
|
+
activeMark = id;
|
|
227
|
+
toFrame(ANNOTATE_METHODS.focus, { id });
|
|
228
|
+
},
|
|
229
|
+
clear() {
|
|
230
|
+
needsAnnotate("clear");
|
|
231
|
+
marks = [];
|
|
232
|
+
activeMark = null;
|
|
233
|
+
toFrame(ANNOTATE_METHODS.clear, {});
|
|
234
|
+
},
|
|
235
|
+
};
|
|
93
236
|
|
|
94
237
|
const hostContext = (): HostContext => {
|
|
95
238
|
const width = container.getBoundingClientRect().width;
|
|
@@ -142,10 +285,14 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
142
285
|
case "muted":
|
|
143
286
|
emit("muted", undefined);
|
|
144
287
|
return;
|
|
288
|
+
case "annotation":
|
|
289
|
+
emit("annotation", event.report);
|
|
290
|
+
return;
|
|
145
291
|
}
|
|
146
292
|
};
|
|
147
293
|
|
|
148
294
|
const stop = () => {
|
|
295
|
+
handshaken = false;
|
|
149
296
|
abort?.abort();
|
|
150
297
|
abort = null;
|
|
151
298
|
outcomeWatch?.stop();
|
|
@@ -197,6 +344,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
197
344
|
userActivation: () => created.userActivation(),
|
|
198
345
|
onEvent: onBridge,
|
|
199
346
|
});
|
|
347
|
+
if (annotates) {
|
|
348
|
+
created.transport.listen((raw) => onAnnotation(raw, bridge));
|
|
349
|
+
}
|
|
200
350
|
const opened = adapter.connect({
|
|
201
351
|
display: { id: mount.displayId, title: mount.title },
|
|
202
352
|
compiled,
|
|
@@ -207,6 +357,10 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
207
357
|
emit: (event) => {
|
|
208
358
|
if (event.type === "initialized") {
|
|
209
359
|
watch.initialized();
|
|
360
|
+
if (!handshaken) {
|
|
361
|
+
handshaken = true;
|
|
362
|
+
giveAnnotations();
|
|
363
|
+
}
|
|
210
364
|
emit("initialized", undefined);
|
|
211
365
|
}
|
|
212
366
|
},
|
|
@@ -274,6 +428,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
274
428
|
|
|
275
429
|
const controls = {
|
|
276
430
|
displayId: mount.displayId,
|
|
431
|
+
annotations,
|
|
277
432
|
setTheme(next: ColorScheme) {
|
|
278
433
|
scheme = next;
|
|
279
434
|
surface?.setColorScheme(next);
|
package/src/host/outcome.ts
CHANGED
|
@@ -25,6 +25,7 @@ const BLOCKS: readonly FrameBlock[] = [
|
|
|
25
25
|
"cookies",
|
|
26
26
|
"session",
|
|
27
27
|
"upstream",
|
|
28
|
+
"denied",
|
|
28
29
|
"not-found",
|
|
29
30
|
"error",
|
|
30
31
|
];
|
|
@@ -33,6 +34,7 @@ interface FrameStatus {
|
|
|
33
34
|
readonly state: "entering" | "blocked";
|
|
34
35
|
readonly reason?: FrameBlock;
|
|
35
36
|
readonly status?: number;
|
|
37
|
+
readonly code?: string;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
/** A frame-status message, or null for anything else. */
|
|
@@ -44,7 +46,12 @@ export function parseFrameStatus(data: unknown): FrameStatus | null {
|
|
|
44
46
|
if (message.jsonrpc !== "2.0" || message.method !== FRAME_STATUS_METHOD) {
|
|
45
47
|
return null;
|
|
46
48
|
}
|
|
47
|
-
const params = message.params as {
|
|
49
|
+
const params = message.params as {
|
|
50
|
+
state?: unknown;
|
|
51
|
+
reason?: unknown;
|
|
52
|
+
status?: unknown;
|
|
53
|
+
code?: unknown;
|
|
54
|
+
} | null;
|
|
48
55
|
if (params === null || typeof params !== "object") {
|
|
49
56
|
return null;
|
|
50
57
|
}
|
|
@@ -63,6 +70,9 @@ export function parseFrameStatus(data: unknown): FrameStatus | null {
|
|
|
63
70
|
...(typeof params.status === "number" && Number.isInteger(params.status)
|
|
64
71
|
? { status: params.status }
|
|
65
72
|
: {}),
|
|
73
|
+
...(typeof params.code === "string" && /^[a-z0-9-]{1,40}$/u.test(params.code)
|
|
74
|
+
? { code: params.code }
|
|
75
|
+
: {}),
|
|
66
76
|
};
|
|
67
77
|
}
|
|
68
78
|
|
|
@@ -82,7 +92,8 @@ export function watchOutcome(options: {
|
|
|
82
92
|
}): OutcomeWatch {
|
|
83
93
|
const { surface, source, speaks } = options;
|
|
84
94
|
const reports = source.kind === "url" && source.reports === true;
|
|
85
|
-
|
|
95
|
+
// The first outcome; after a load, only a later block (a status page, access revoked) is told.
|
|
96
|
+
let decided: FrameOutcome["state"] | null = null;
|
|
86
97
|
let heard = false;
|
|
87
98
|
let entering = false;
|
|
88
99
|
let loads = 0;
|
|
@@ -94,18 +105,24 @@ export function watchOutcome(options: {
|
|
|
94
105
|
}, ms);
|
|
95
106
|
timers.add(timer);
|
|
96
107
|
};
|
|
97
|
-
const
|
|
108
|
+
const clearTimers = () => {
|
|
98
109
|
for (const timer of timers) {
|
|
99
110
|
clearTimeout(timer);
|
|
100
111
|
}
|
|
101
112
|
timers.clear();
|
|
113
|
+
};
|
|
114
|
+
const stop = () => {
|
|
115
|
+
clearTimers();
|
|
102
116
|
unlisten();
|
|
103
117
|
unload();
|
|
104
118
|
};
|
|
105
119
|
const decide = (outcome: FrameOutcome) => {
|
|
106
|
-
if (
|
|
107
|
-
decided =
|
|
108
|
-
|
|
120
|
+
if (decided === null) {
|
|
121
|
+
decided = outcome.state;
|
|
122
|
+
clearTimers();
|
|
123
|
+
options.onOutcome(outcome);
|
|
124
|
+
} else if (decided === "loaded" && outcome.state === "blocked") {
|
|
125
|
+
decided = "blocked";
|
|
109
126
|
options.onOutcome(outcome);
|
|
110
127
|
}
|
|
111
128
|
};
|
|
@@ -119,6 +136,7 @@ export function watchOutcome(options: {
|
|
|
119
136
|
state: "blocked",
|
|
120
137
|
reason: status.reason ?? "error",
|
|
121
138
|
...(status.status === undefined ? {} : { status: status.status }),
|
|
139
|
+
...(status.code === undefined ? {} : { code: status.code }),
|
|
122
140
|
});
|
|
123
141
|
}
|
|
124
142
|
});
|
|
@@ -130,7 +148,7 @@ export function watchOutcome(options: {
|
|
|
130
148
|
});
|
|
131
149
|
const unload = surface.onLoad(() => {
|
|
132
150
|
loads += 1;
|
|
133
|
-
if (decided) {
|
|
151
|
+
if (decided !== null) {
|
|
134
152
|
return;
|
|
135
153
|
}
|
|
136
154
|
if (speaks) {
|
package/src/host/types.ts
CHANGED
|
@@ -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";
|
|
@@ -62,18 +63,27 @@ export type FrameBlock =
|
|
|
62
63
|
| "session"
|
|
63
64
|
/** Its app didn't answer. */
|
|
64
65
|
| "upstream"
|
|
66
|
+
/** The host's own check refused it (revoked, no longer a member): `code` says which. */
|
|
67
|
+
| "denied"
|
|
65
68
|
| "not-found"
|
|
66
69
|
| "error";
|
|
67
70
|
|
|
68
71
|
/**
|
|
69
|
-
* How a display's
|
|
70
|
-
*
|
|
71
|
-
* `limits.loadTimeoutMs`.
|
|
72
|
-
* loaded.
|
|
72
|
+
* How a display's document went: it loaded (a shell or view finished its handshake; a url-mode
|
|
73
|
+
* app's document loaded), it was blocked (and why), or nothing settled in
|
|
74
|
+
* `limits.loadTimeoutMs`. Once per run, plus a later `blocked` if the frame shows a status page
|
|
75
|
+
* after it loaded (access revoked mid-session). A host shows its own fallback ("Open in a new
|
|
76
|
+
* tab") on anything but loaded.
|
|
73
77
|
*/
|
|
74
78
|
export type FrameOutcome =
|
|
75
79
|
| { readonly state: "loaded" }
|
|
76
|
-
| {
|
|
80
|
+
| {
|
|
81
|
+
readonly state: "blocked";
|
|
82
|
+
readonly reason: FrameBlock;
|
|
83
|
+
readonly status?: number;
|
|
84
|
+
/** The host's own reason code, for `denied` (`Authorizer`). */
|
|
85
|
+
readonly code?: string;
|
|
86
|
+
}
|
|
77
87
|
| { readonly state: "timeout" };
|
|
78
88
|
|
|
79
89
|
export interface DisplayEvents {
|
|
@@ -91,6 +101,28 @@ export interface DisplayEvents {
|
|
|
91
101
|
readonly refused: { readonly capability: CapabilityName; readonly refusal: Refusal };
|
|
92
102
|
/** Too many declines: the display stops asking until it runs again. */
|
|
93
103
|
readonly muted: undefined;
|
|
104
|
+
/**
|
|
105
|
+
* What the frame reported about annotations (`annotate`, P053), checked and taken only while
|
|
106
|
+
* the host asked for it: a selection, its clearing, a cancelled pick, where marks are. The
|
|
107
|
+
* content's word: UI hints, never permissions.
|
|
108
|
+
*/
|
|
109
|
+
readonly annotation: AnnotationReport;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Annotations on a display whose config declares `annotate` (P053). The host's state is kept and
|
|
114
|
+
* given to every frame the display runs (a reload, a navigation recovered from).
|
|
115
|
+
*/
|
|
116
|
+
export interface AnnotationControls {
|
|
117
|
+
/** Lets the person point at elements, select text, or both (default both). */
|
|
118
|
+
start(mode?: AnnotateMode): void;
|
|
119
|
+
stop(): void;
|
|
120
|
+
/** Marks these anchors (replacing the marks before); the frame reports where they are. */
|
|
121
|
+
show(marks: readonly AnnotationMark[]): void;
|
|
122
|
+
/** Brings a mark into view in the frame and makes it the active one. */
|
|
123
|
+
focus(id: string): void;
|
|
124
|
+
/** Removes every mark. */
|
|
125
|
+
clear(): void;
|
|
94
126
|
}
|
|
95
127
|
|
|
96
128
|
export interface DisplayControls {
|
|
@@ -106,6 +138,8 @@ export interface DisplayControls {
|
|
|
106
138
|
event: E,
|
|
107
139
|
listener: (detail: DisplayEvents[E]) => void,
|
|
108
140
|
): () => void;
|
|
141
|
+
/** Annotations (`annotate`): throws on use where the config doesn't declare it. */
|
|
142
|
+
readonly annotations: AnnotationControls;
|
|
109
143
|
/** Runs the content again from the start, in a new frame. */
|
|
110
144
|
reload(): void;
|
|
111
145
|
/** Tears the view down and removes the frame. */
|