@botiverse/porthole 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +10 -3
- package/dist/compile/policy.js +16 -5
- package/dist/config/capabilities.d.ts +37 -4
- package/dist/config/capabilities.js +20 -0
- package/dist/config/schema.d.ts +11 -0
- package/dist/config/validate.js +12 -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/conformance/served.d.ts +8 -1
- package/dist/conformance/served.js +12 -8
- package/dist/guide/guide.js +11 -5
- package/dist/host/clipboard.d.ts +11 -0
- package/dist/host/clipboard.js +43 -0
- package/dist/host/create-host.js +165 -7
- package/dist/host/types.d.ts +68 -2
- package/dist/index.d.ts +8 -5
- package/dist/index.js +7 -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 +50 -2
- package/dist/react/frame.js +153 -11
- 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/naming.d.ts +6 -0
- package/dist/server/naming.js +38 -0
- package/dist/server/pages.js +2 -0
- package/dist/server/server.js +49 -17
- package/dist/server/types.d.ts +6 -3
- package/dist/shell/annotate.d.ts +20 -0
- package/dist/shell/annotate.js +700 -0
- package/dist/shell/document.d.ts +6 -0
- package/dist/shell/document.js +12 -3
- 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 +71 -3
- 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 +17 -5
- package/src/config/capabilities.ts +33 -3
- package/src/config/schema.ts +12 -0
- package/src/config/validate.ts +15 -0
- package/src/conformance/clipboard.ts +184 -0
- package/src/conformance/kit.ts +1 -1
- package/src/conformance/probes.ts +2 -0
- package/src/conformance/served.ts +24 -9
- package/src/guide/guide.ts +22 -5
- package/src/host/clipboard.ts +41 -0
- package/src/host/create-host.ts +181 -7
- package/src/host/types.ts +64 -2
- package/src/index.ts +24 -0
- 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 +231 -13
- package/src/react/index.ts +9 -1
- package/src/react/toast.tsx +96 -0
- package/src/server/naming.ts +41 -0
- package/src/server/pages.ts +2 -0
- package/src/server/server.ts +61 -20
- package/src/server/types.ts +6 -3
- package/src/shell/annotate.ts +700 -0
- package/src/shell/document.ts +21 -3
- package/src/shell/plan.ts +4 -1
- package/src/shell/runtime.ts +71 -3
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AnnotationReport } from "../annotate/types.ts";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* The capability vocabulary (PRINCIPLE 4): every power content has beyond drawing is one of these
|
|
3
5
|
* host functions, named in the config. A capability the config doesn't declare is not wired into
|
|
@@ -17,6 +19,8 @@ export const CAPABILITY_NAMES = [
|
|
|
17
19
|
"setModelContext",
|
|
18
20
|
"reportError",
|
|
19
21
|
"resize",
|
|
22
|
+
"copyText",
|
|
23
|
+
"annotate",
|
|
20
24
|
] as const;
|
|
21
25
|
|
|
22
26
|
export type CapabilityName = (typeof CAPABILITY_NAMES)[number];
|
|
@@ -44,14 +48,18 @@ export interface CapabilitySpec {
|
|
|
44
48
|
/** The global the shell defines for it (sugar over the protocol message), if any. */
|
|
45
49
|
readonly global?: string;
|
|
46
50
|
/**
|
|
47
|
-
* How content reaches it without the shell's global: the MCP Apps request
|
|
48
|
-
*
|
|
51
|
+
* How content reaches it without the shell's global: the MCP Apps request (or Porthole's own,
|
|
52
|
+
* `porthole/…`, where the standard has none), and the official App SDK's method for it, if any.
|
|
53
|
+
* Absent for what the shell does by itself (sizes, errors, annotations).
|
|
49
54
|
*/
|
|
50
|
-
readonly request?: { readonly method: string; readonly sdk
|
|
55
|
+
readonly request?: { readonly method: string; readonly sdk?: string };
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
const rate = (perMinute: number): Gate => ({ rateLimit: { perMinute } });
|
|
54
59
|
|
|
60
|
+
/** The most text one `copyText` call carries. */
|
|
61
|
+
export const MAX_COPY_CHARS = 20_000;
|
|
62
|
+
|
|
55
63
|
export const CAPABILITIES = {
|
|
56
64
|
sendPrompt: {
|
|
57
65
|
defaultGates: ["user-activation", "confirm"],
|
|
@@ -87,6 +95,22 @@ export const CAPABILITIES = {
|
|
|
87
95
|
},
|
|
88
96
|
reportError: { defaultGates: [rate(10)], served: "event" },
|
|
89
97
|
resize: { defaultGates: [], served: "event" },
|
|
98
|
+
/**
|
|
99
|
+
* Copies text to the person's clipboard, done by the host, which shows them what was copied
|
|
100
|
+
* (P052). Content can't write the clipboard any other way (P051).
|
|
101
|
+
*/
|
|
102
|
+
copyText: {
|
|
103
|
+
defaultGates: ["user-activation", rate(30)],
|
|
104
|
+
served: "handler",
|
|
105
|
+
global: "copyText",
|
|
106
|
+
request: { method: "porthole/copy-text" },
|
|
107
|
+
},
|
|
108
|
+
/**
|
|
109
|
+
* Annotations (P053): the host asks the shell to let the person point at an element or select
|
|
110
|
+
* text, and to mark anchors already made. What the frame reports back is the content's word
|
|
111
|
+
* (it shares the realm): UI hints for the host, never permissions.
|
|
112
|
+
*/
|
|
113
|
+
annotate: { defaultGates: [rate(120)], served: "event" },
|
|
90
114
|
} as const satisfies { readonly [N in CapabilityName]: CapabilitySpec };
|
|
91
115
|
|
|
92
116
|
/** The arguments a call carries, once the adapter has validated them. */
|
|
@@ -106,6 +130,9 @@ export interface CapabilityArgs {
|
|
|
106
130
|
};
|
|
107
131
|
readonly reportError: { readonly message: string };
|
|
108
132
|
readonly resize: { readonly height: number; readonly width?: number };
|
|
133
|
+
readonly copyText: { readonly text: string };
|
|
134
|
+
/** What the frame reported, once checked (`AnnotationReport`). */
|
|
135
|
+
readonly annotate: AnnotationReport;
|
|
109
136
|
}
|
|
110
137
|
|
|
111
138
|
/** What a call resolves to. Protocol-shaped results (a tool's result) stay opaque to the core. */
|
|
@@ -121,6 +148,9 @@ export interface CapabilityResults {
|
|
|
121
148
|
readonly setModelContext: undefined;
|
|
122
149
|
readonly reportError: undefined;
|
|
123
150
|
readonly resize: undefined;
|
|
151
|
+
/** Whether the text was copied. */
|
|
152
|
+
readonly copyText: boolean;
|
|
153
|
+
readonly annotate: undefined;
|
|
124
154
|
}
|
|
125
155
|
|
|
126
156
|
/** The capabilities a host serves with a handler. */
|
package/src/config/schema.ts
CHANGED
|
@@ -175,4 +175,16 @@ export interface PortholeConfig {
|
|
|
175
175
|
readonly sandbox?: readonly UrlSandboxExtra[];
|
|
176
176
|
/** Url mode only: features delegated to the frame (`URL_PERMISSIONS`); none by default. */
|
|
177
177
|
readonly permissions?: readonly UrlPermission[];
|
|
178
|
+
/**
|
|
179
|
+
* Domain isolation: how far a display's storage reaches, for the manual. Storage is per origin,
|
|
180
|
+
* and the host picks each display's origin (`InstanceRef.scope`); a host that gives several
|
|
181
|
+
* displays one scope (all of a conversation's widgets) names it here (`{ shared:
|
|
182
|
+
* "conversation" }`), so the manual says storage is shared across it and keys need a prefix.
|
|
183
|
+
*/
|
|
184
|
+
readonly storage?: StorageConfig;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface StorageConfig {
|
|
188
|
+
/** What the displays sharing one origin have in common, as a noun ("conversation"). */
|
|
189
|
+
readonly shared?: string;
|
|
178
190
|
}
|
package/src/config/validate.ts
CHANGED
|
@@ -131,6 +131,7 @@ export function validateConfig(input: unknown): PortholeConfig {
|
|
|
131
131
|
"embedder",
|
|
132
132
|
"sandbox",
|
|
133
133
|
"permissions",
|
|
134
|
+
"storage",
|
|
134
135
|
],
|
|
135
136
|
"config",
|
|
136
137
|
);
|
|
@@ -315,6 +316,20 @@ export function validateConfig(input: unknown): PortholeConfig {
|
|
|
315
316
|
});
|
|
316
317
|
}
|
|
317
318
|
|
|
319
|
+
if (config.storage !== undefined) {
|
|
320
|
+
const storage = object(config.storage, "config.storage");
|
|
321
|
+
only(storage, ["shared"], "config.storage");
|
|
322
|
+
if (config.isolation !== "domain") {
|
|
323
|
+
fail("config.storage", "only domain isolation keeps storage across displays");
|
|
324
|
+
}
|
|
325
|
+
if (
|
|
326
|
+
storage.shared !== undefined &&
|
|
327
|
+
(typeof storage.shared !== "string" || !/^[a-z][a-z -]{0,39}$/u.test(storage.shared))
|
|
328
|
+
) {
|
|
329
|
+
fail("config.storage.shared", 'must be a short lowercase noun ("conversation")');
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
318
333
|
if (config.permissions !== undefined) {
|
|
319
334
|
if (config.mode !== "url") {
|
|
320
335
|
fail("config.permissions", "only url mode may be delegated features");
|
|
@@ -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<
|
|
@@ -38,8 +38,17 @@ export interface ServedConformanceHost {
|
|
|
38
38
|
* Shows `instance` in `page` as the host does: a fresh ticket from its backend, framed under
|
|
39
39
|
* the config's sandbox, in an embedding page that sends the config's `embedderCsp`. The same page
|
|
40
40
|
* may show several, one after another.
|
|
41
|
+
*
|
|
42
|
+
* For a served shell the suite gives `options.content`, a line of HTML: a host that speaks to
|
|
43
|
+
* the shell (`createHost`, the React binding) sends it as the display's content, since a shell
|
|
44
|
+
* draws nothing (and reports no height) until it has content, and the cases click inside it. A
|
|
45
|
+
* host whose frame has a height of its own may ignore it.
|
|
41
46
|
*/
|
|
42
|
-
show(
|
|
47
|
+
show(
|
|
48
|
+
page: Page,
|
|
49
|
+
instance: InstanceRef,
|
|
50
|
+
options?: { readonly content?: string },
|
|
51
|
+
): Promise<ServedDisplay>;
|
|
43
52
|
/** A fresh ticketed location for `instance`, as the host's backend issues it. */
|
|
44
53
|
locate(instance: InstanceRef): Promise<FrameLocation>;
|
|
45
54
|
/** Navigates `page` to a page on an origin that is not one of the host's, framing `url`. */
|
|
@@ -74,6 +83,9 @@ type Fixtures = { page: Page; browserName: string };
|
|
|
74
83
|
|
|
75
84
|
const SETTLE_MS = 800;
|
|
76
85
|
|
|
86
|
+
/** What a served shell is given to draw (`ServedConformanceHost.show`'s `content`). */
|
|
87
|
+
const SHELL_CONTENT = '<p id="porthole-probe-content">Porthole conformance probe</p>';
|
|
88
|
+
|
|
77
89
|
/** What the frame can see of its host: everything here must be closed to it. */
|
|
78
90
|
async function reachIntoHost(frame: Frame): Promise<Record<string, string>> {
|
|
79
91
|
return frame.evaluate(() => {
|
|
@@ -185,13 +197,16 @@ export function servedTests(
|
|
|
185
197
|
["domain", (scope: string) => host.shell(scope)],
|
|
186
198
|
["url", (scope: string) => host.app(scope)],
|
|
187
199
|
] as const;
|
|
200
|
+
// A served shell draws (and has a height to click in) once its host gives it content.
|
|
201
|
+
const showMode = (page: Page, mode: "domain" | "url", instance: InstanceRef) =>
|
|
202
|
+
host.show(page, instance, mode === "domain" ? { content: SHELL_CONTENT } : {});
|
|
188
203
|
|
|
189
204
|
for (const [mode, instance] of modes) {
|
|
190
205
|
define(
|
|
191
206
|
`served.${mode}.origin`,
|
|
192
207
|
"content runs on its own origin, never the host's",
|
|
193
208
|
async ({ page }) => {
|
|
194
|
-
const display = await
|
|
209
|
+
const display = await showMode(page, mode, instance(`${mode}-origin`));
|
|
195
210
|
const frame = await display.frame();
|
|
196
211
|
const origin = await frame.evaluate(() => window.origin);
|
|
197
212
|
expect(origin).toBe(display.origin);
|
|
@@ -203,7 +218,7 @@ export function servedTests(
|
|
|
203
218
|
`served.${mode}.no-reach`,
|
|
204
219
|
"content can't reach the host's DOM, storage, cookies or URL",
|
|
205
220
|
async ({ page }) => {
|
|
206
|
-
const display = await
|
|
221
|
+
const display = await showMode(page, mode, instance(`${mode}-reach`));
|
|
207
222
|
await plantHostSecrets(page);
|
|
208
223
|
expect(await reachIntoHost(await display.frame())).toEqual(CLOSED);
|
|
209
224
|
},
|
|
@@ -213,7 +228,7 @@ export function servedTests(
|
|
|
213
228
|
`served.${mode}.no-top-navigation`,
|
|
214
229
|
"content can't navigate the host's page, even after a click",
|
|
215
230
|
async ({ page }) => {
|
|
216
|
-
const display = await
|
|
231
|
+
const display = await showMode(page, mode, instance(`${mode}-top`));
|
|
217
232
|
const frame = await display.frame();
|
|
218
233
|
const before = page.url();
|
|
219
234
|
const target = await host.canary(`${mode}-top`);
|
|
@@ -246,7 +261,7 @@ export function servedTests(
|
|
|
246
261
|
`served.${mode}.no-popups`,
|
|
247
262
|
"content can't open a window, even after a click",
|
|
248
263
|
async ({ page }) => {
|
|
249
|
-
const display = await
|
|
264
|
+
const display = await showMode(page, mode, instance(`${mode}-popup`));
|
|
250
265
|
const frame = await display.frame();
|
|
251
266
|
const target = await host.canary(`${mode}-popup`);
|
|
252
267
|
const opened: Page[] = [];
|
|
@@ -283,13 +298,13 @@ export function servedTests(
|
|
|
283
298
|
"served.domain.storage-scope",
|
|
284
299
|
"storage persists within a scope and is never shared across scopes",
|
|
285
300
|
async ({ page }) => {
|
|
286
|
-
const first = await host.show(page, host.shell("scope-a"));
|
|
301
|
+
const first = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
|
|
287
302
|
await (await first.frame()).evaluate(() => localStorage.setItem("kept", "a"));
|
|
288
303
|
await first.destroy();
|
|
289
|
-
const again = await host.show(page, host.shell("scope-a"));
|
|
304
|
+
const again = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
|
|
290
305
|
expect(await (await again.frame()).evaluate(() => localStorage.getItem("kept"))).toBe("a");
|
|
291
306
|
expect(again.origin).toBe(first.origin);
|
|
292
|
-
const other = await host.show(page, host.shell("scope-b"));
|
|
307
|
+
const other = await host.show(page, host.shell("scope-b"), { content: SHELL_CONTENT });
|
|
293
308
|
expect(other.origin).not.toBe(first.origin);
|
|
294
309
|
expect(await (await other.frame()).evaluate(() => localStorage.getItem("kept"))).toBeNull();
|
|
295
310
|
},
|
|
@@ -367,7 +382,7 @@ export function servedTests(
|
|
|
367
382
|
"served.domain.egress",
|
|
368
383
|
"the CSP in the document's headers closes egress",
|
|
369
384
|
async ({ page }) => {
|
|
370
|
-
const display = await host.show(page, host.shell("egress"));
|
|
385
|
+
const display = await host.show(page, host.shell("egress"), { content: SHELL_CONTENT });
|
|
371
386
|
const frame = await display.frame();
|
|
372
387
|
const fetched = await frame.evaluate(
|
|
373
388
|
async (url) => {
|
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: () => "",
|
|
@@ -353,7 +368,9 @@ function interactive(c: Context): string {
|
|
|
353
368
|
const state =
|
|
354
369
|
config.isolation === "opaque"
|
|
355
370
|
? `- State isn't saved: localStorage works but lasts only while the ${noun} is on screen, and a reload or another device starts fresh. Keep what matters in the ${noun}'s code.`
|
|
356
|
-
:
|
|
371
|
+
: config.storage?.shared === undefined
|
|
372
|
+
? `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`
|
|
373
|
+
: `- localStorage is shared by every ${noun} in this ${config.storage.shared}, in this browser (another device starts fresh): prefix your keys with a name of this ${noun}'s own ("budget:filters", not "filters"), or ${noun}s overwrite each other.`;
|
|
357
374
|
const none =
|
|
358
375
|
entries.length === 0
|
|
359
376
|
? `- It can only draw: it has no way to send anything to you or the app.`
|
|
@@ -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
|
+
}
|