@botiverse/porthole 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +4 -3
- package/dist/compile/policy.js +7 -4
- package/dist/config/capabilities.d.ts +37 -4
- package/dist/config/capabilities.js +20 -0
- 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/types.d.ts +24 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +6 -2
- package/dist/mcp/adapter.js +10 -1
- 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/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 +1 -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 +7 -4
- package/src/config/capabilities.ts +33 -3
- 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/types.ts +25 -0
- package/src/index.ts +22 -0
- package/src/mcp/adapter.ts +10 -1
- 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/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/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";
|
|
@@ -100,6 +101,28 @@ export interface DisplayEvents {
|
|
|
100
101
|
readonly refused: { readonly capability: CapabilityName; readonly refusal: Refusal };
|
|
101
102
|
/** Too many declines: the display stops asking until it runs again. */
|
|
102
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;
|
|
103
126
|
}
|
|
104
127
|
|
|
105
128
|
export interface DisplayControls {
|
|
@@ -115,6 +138,8 @@ export interface DisplayControls {
|
|
|
115
138
|
event: E,
|
|
116
139
|
listener: (detail: DisplayEvents[E]) => void,
|
|
117
140
|
): () => void;
|
|
141
|
+
/** Annotations (`annotate`): throws on use where the config doesn't declare it. */
|
|
142
|
+
readonly annotations: AnnotationControls;
|
|
118
143
|
/** Runs the content again from the start, in a new frame. */
|
|
119
144
|
reload(): void;
|
|
120
145
|
/** Tears the view down and removes the frame. */
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
export {
|
|
8
8
|
CAPABILITIES,
|
|
9
9
|
CAPABILITY_NAMES,
|
|
10
|
+
MAX_COPY_CHARS,
|
|
10
11
|
capabilitySpec,
|
|
11
12
|
isCapabilityName,
|
|
12
13
|
type CapabilityArgs,
|
|
@@ -105,6 +106,7 @@ export type {
|
|
|
105
106
|
ShellConnection,
|
|
106
107
|
} from "./protocol/adapter.ts";
|
|
107
108
|
export {
|
|
109
|
+
COPY_TEXT_METHOD,
|
|
108
110
|
MCP_APPS_METHODS,
|
|
109
111
|
MCP_APPS_PROTOCOL_VERSION,
|
|
110
112
|
MCP_APPS_STYLE_VARIABLES,
|
|
@@ -124,7 +126,9 @@ export type {
|
|
|
124
126
|
Refusal,
|
|
125
127
|
} from "./bridge/types.ts";
|
|
126
128
|
export { createHost } from "./host/create-host.ts";
|
|
129
|
+
export { writeClipboard } from "./host/clipboard.ts";
|
|
127
130
|
export type {
|
|
131
|
+
AnnotationControls,
|
|
128
132
|
DisplayControls,
|
|
129
133
|
DisplayEvents,
|
|
130
134
|
FrameBlock,
|
|
@@ -134,3 +138,21 @@ export type {
|
|
|
134
138
|
MountOptions,
|
|
135
139
|
PortholeHost,
|
|
136
140
|
} from "./host/types.ts";
|
|
141
|
+
|
|
142
|
+
// Annotations (P053): anchors, what a frame reports, and the host's checks on both.
|
|
143
|
+
export {
|
|
144
|
+
ANNOTATE_METHODS,
|
|
145
|
+
ANNOTATION_LIMITS,
|
|
146
|
+
type AnchorResult,
|
|
147
|
+
type AnchorState,
|
|
148
|
+
type AnnotateMode,
|
|
149
|
+
type AnnotationAnchor,
|
|
150
|
+
type AnnotationMark,
|
|
151
|
+
type AnnotationRect,
|
|
152
|
+
type AnnotationReport,
|
|
153
|
+
type AnnotationSelector,
|
|
154
|
+
type CssSelector,
|
|
155
|
+
type TextPositionSelector,
|
|
156
|
+
type TextQuoteSelector,
|
|
157
|
+
} from "./annotate/types.ts";
|
|
158
|
+
export { parseAnchor, parseRect, parseReport } from "./annotate/validate.ts";
|
package/src/mcp/adapter.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
MCP_APPS_PROTOCOL_VERSION,
|
|
15
15
|
type HostContext,
|
|
16
16
|
} from "../protocol/mcp-apps.ts";
|
|
17
|
-
import { MCP_APPS_STYLE_VARIABLES } from "../protocol/mcp-apps.ts";
|
|
17
|
+
import { COPY_TEXT_METHOD, MCP_APPS_STYLE_VARIABLES } from "../protocol/mcp-apps.ts";
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* The MCP Apps adapter (P004, P007): the host side of the protocol for full MCP Apps views, written
|
|
@@ -283,6 +283,15 @@ function connect(session: AdapterSession, options: McpAppsAdapterOptions): McpAp
|
|
|
283
283
|
() => ({}),
|
|
284
284
|
);
|
|
285
285
|
return;
|
|
286
|
+
case COPY_TEXT_METHOD:
|
|
287
|
+
// Porthole's own request (the standard has no clipboard request): the host copies, and
|
|
288
|
+
// shows the person what (P052).
|
|
289
|
+
if (typeof params.text !== "string") {
|
|
290
|
+
invalid("needs text");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
await call(request, "copyText", { text: params.text }, done);
|
|
294
|
+
return;
|
|
286
295
|
case MCP_APPS_METHODS.requestDisplayMode: {
|
|
287
296
|
if (typeof params.mode !== "string" || !DISPLAY_MODES.has(params.mode)) {
|
|
288
297
|
invalid("needs a display mode");
|
package/src/protocol/mcp-apps.ts
CHANGED
|
@@ -115,6 +115,12 @@ export const MCP_APPS_STYLE_VARIABLES: readonly string[] = [
|
|
|
115
115
|
*/
|
|
116
116
|
export const PORTHOLE_STYLES = "porthole/styles";
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Porthole's own request for copying text (`copyText`, P052): `{ text }`, answered `{}` when the
|
|
120
|
+
* host copied it and `{ isError: true }` when it didn't. The standard has no clipboard request.
|
|
121
|
+
*/
|
|
122
|
+
export const COPY_TEXT_METHOD = "porthole/copy-text";
|
|
123
|
+
|
|
118
124
|
/** The logger name the shell's error reports (`notifications/message`, level `error`) carry. */
|
|
119
125
|
export const SHELL_LOGGER = "porthole";
|
|
120
126
|
|
|
@@ -8,7 +8,12 @@ import {
|
|
|
8
8
|
type JsonRpcRequest,
|
|
9
9
|
type JsonRpcResponse,
|
|
10
10
|
} from "./jsonrpc.ts";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
COPY_TEXT_METHOD,
|
|
13
|
+
MCP_APPS_METHODS as M,
|
|
14
|
+
MCP_APPS_PROTOCOL_VERSION,
|
|
15
|
+
type HostContext,
|
|
16
|
+
} from "./mcp-apps.ts";
|
|
12
17
|
|
|
13
18
|
/**
|
|
14
19
|
* The core's adapter: the host side of the MCP Apps subset the shell speaks, which content using
|
|
@@ -42,6 +47,7 @@ const REQUESTS: Readonly<
|
|
|
42
47
|
}),
|
|
43
48
|
},
|
|
44
49
|
[M.openLink]: { capability: "openLink", args: (params) => ({ url: params.url }) },
|
|
50
|
+
[COPY_TEXT_METHOD]: { capability: "copyText", args: (params) => ({ text: params.text }) },
|
|
45
51
|
[M.downloadFile]: {
|
|
46
52
|
capability: "downloadFile",
|
|
47
53
|
args: (params) => ({ contents: params.contents }),
|
|
@@ -63,7 +69,10 @@ function answer(
|
|
|
63
69
|
outcome: CallOutcome<unknown>,
|
|
64
70
|
): { result: unknown } | { error: { code: number; message: string } } {
|
|
65
71
|
const flagged =
|
|
66
|
-
capability === "sendPrompt" ||
|
|
72
|
+
capability === "sendPrompt" ||
|
|
73
|
+
capability === "openLink" ||
|
|
74
|
+
capability === "downloadFile" ||
|
|
75
|
+
capability === "copyText";
|
|
67
76
|
if (!outcome.ok) {
|
|
68
77
|
return flagged
|
|
69
78
|
? { result: { isError: true } }
|