@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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Annotations (P053): a person points at part of a display (an element, or text they select) to
|
|
3
|
+
* comment on it, and the host marks the anchors already made. The host drives the shell with
|
|
4
|
+
* Porthole's own messages (`porthole/annotate.*`); the shell answers with anchors and where they
|
|
5
|
+
* are. An anchor is a set of W3C Web Annotation selectors, so it can be found again after the
|
|
6
|
+
* content changes; finding it is best-effort, and says so (`orphaned`).
|
|
7
|
+
*
|
|
8
|
+
* Everything the frame reports is the content's word: the shell shares its realm with the
|
|
9
|
+
* content, which can interfere or fake a report. The host checks each report's shape and size
|
|
10
|
+
* and takes reports only while it asked for them. Use them as UI hints (where to draw a comment,
|
|
11
|
+
* what to quote), never as permissions.
|
|
12
|
+
*/
|
|
13
|
+
/** The element a CSS selector matches (W3C `CssSelector`). */
|
|
14
|
+
export interface CssSelector {
|
|
15
|
+
readonly type: "CssSelector";
|
|
16
|
+
readonly value: string;
|
|
17
|
+
}
|
|
18
|
+
/** Text, with a little of what comes before and after it (W3C `TextQuoteSelector`). */
|
|
19
|
+
export interface TextQuoteSelector {
|
|
20
|
+
readonly type: "TextQuoteSelector";
|
|
21
|
+
readonly exact: string;
|
|
22
|
+
readonly prefix?: string;
|
|
23
|
+
readonly suffix?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Offsets into the document's text: the text of the body's text nodes, in order, without
|
|
27
|
+
* scripts, styles and templates (W3C `TextPositionSelector`).
|
|
28
|
+
*/
|
|
29
|
+
export interface TextPositionSelector {
|
|
30
|
+
readonly type: "TextPositionSelector";
|
|
31
|
+
readonly start: number;
|
|
32
|
+
readonly end: number;
|
|
33
|
+
}
|
|
34
|
+
export type AnnotationSelector = CssSelector | TextQuoteSelector | TextPositionSelector;
|
|
35
|
+
export interface AnnotationAnchor {
|
|
36
|
+
/** What the person pointed at: an element, or a text selection. */
|
|
37
|
+
readonly kind: "element" | "text";
|
|
38
|
+
/** At most one of each type; tried in turn when the anchor is found again. */
|
|
39
|
+
readonly selectors: readonly AnnotationSelector[];
|
|
40
|
+
/** A short description for the host to show ("h2#revenue Revenue grew 12%"); the content's. */
|
|
41
|
+
readonly label: string;
|
|
42
|
+
}
|
|
43
|
+
/** A box in the frame's viewport, CSS px: add the frame element's position to place it on the page. */
|
|
44
|
+
export interface AnnotationRect {
|
|
45
|
+
readonly x: number;
|
|
46
|
+
readonly y: number;
|
|
47
|
+
readonly width: number;
|
|
48
|
+
readonly height: number;
|
|
49
|
+
}
|
|
50
|
+
/** What the person may point at while the host is asking. */
|
|
51
|
+
export interface AnnotateMode {
|
|
52
|
+
/** Elements, picked with a click or tap (the one under the pointer is outlined); one pick. */
|
|
53
|
+
readonly elements?: boolean;
|
|
54
|
+
/** Text, by selecting it: each settled selection is reported, and its clearing. */
|
|
55
|
+
readonly text?: boolean;
|
|
56
|
+
}
|
|
57
|
+
/** An anchor the host marks in the frame, under its own id. */
|
|
58
|
+
export interface AnnotationMark {
|
|
59
|
+
readonly id: string;
|
|
60
|
+
readonly anchor: AnnotationAnchor;
|
|
61
|
+
}
|
|
62
|
+
/** Whether a mark's anchor was found in the content as it is now. */
|
|
63
|
+
export type AnchorState = "attached" | "orphaned";
|
|
64
|
+
export interface AnchorResult {
|
|
65
|
+
readonly id: string;
|
|
66
|
+
readonly state: AnchorState;
|
|
67
|
+
/** Where it is now, when attached. */
|
|
68
|
+
readonly rect?: AnnotationRect;
|
|
69
|
+
}
|
|
70
|
+
/** What the frame reports, once the host has checked it. */
|
|
71
|
+
export type AnnotationReport =
|
|
72
|
+
/** The person pointed at an element or settled a text selection. */
|
|
73
|
+
{
|
|
74
|
+
readonly type: "selected";
|
|
75
|
+
readonly anchor: AnnotationAnchor;
|
|
76
|
+
readonly rect: AnnotationRect;
|
|
77
|
+
}
|
|
78
|
+
/** The text selection went away. */
|
|
79
|
+
| {
|
|
80
|
+
readonly type: "unselected";
|
|
81
|
+
}
|
|
82
|
+
/** The person pressed Escape while picking an element. */
|
|
83
|
+
| {
|
|
84
|
+
readonly type: "cancelled";
|
|
85
|
+
}
|
|
86
|
+
/** Where the marked anchors are now, after `show`, `focus`, or a change of the content. */
|
|
87
|
+
| {
|
|
88
|
+
readonly type: "anchored";
|
|
89
|
+
readonly results: readonly AnchorResult[];
|
|
90
|
+
};
|
|
91
|
+
/** Porthole's own messages for annotations (own-prefixed, P002). */
|
|
92
|
+
export declare const ANNOTATE_METHODS: {
|
|
93
|
+
/** Host → frame: let the person point (`AnnotateMode`). */
|
|
94
|
+
readonly start: "porthole/annotate.start";
|
|
95
|
+
/** Host → frame: stop. */
|
|
96
|
+
readonly stop: "porthole/annotate.stop";
|
|
97
|
+
/** Host → frame: mark these anchors (replacing what was marked). */
|
|
98
|
+
readonly show: "porthole/annotate.show";
|
|
99
|
+
/** Host → frame: bring one mark into view and make it the active one. */
|
|
100
|
+
readonly focus: "porthole/annotate.focus";
|
|
101
|
+
/** Host → frame: remove every mark. */
|
|
102
|
+
readonly clear: "porthole/annotate.clear";
|
|
103
|
+
/** Frame → host. */
|
|
104
|
+
readonly selected: "porthole/annotate.selected";
|
|
105
|
+
readonly unselected: "porthole/annotate.unselected";
|
|
106
|
+
readonly cancelled: "porthole/annotate.cancelled";
|
|
107
|
+
readonly anchored: "porthole/annotate.anchored";
|
|
108
|
+
};
|
|
109
|
+
/** The most of each part of an anchor or report the host takes. */
|
|
110
|
+
export declare const ANNOTATION_LIMITS: {
|
|
111
|
+
readonly label: 120;
|
|
112
|
+
readonly cssSelector: 1000;
|
|
113
|
+
readonly exact: 4000;
|
|
114
|
+
readonly context: 64;
|
|
115
|
+
/** The most marks one `show` carries, and results one report. */
|
|
116
|
+
readonly marks: 500;
|
|
117
|
+
readonly id: 128;
|
|
118
|
+
readonly position: 50000000;
|
|
119
|
+
readonly coordinate: 10000000;
|
|
120
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Annotations (P053): a person points at part of a display (an element, or text they select) to
|
|
3
|
+
* comment on it, and the host marks the anchors already made. The host drives the shell with
|
|
4
|
+
* Porthole's own messages (`porthole/annotate.*`); the shell answers with anchors and where they
|
|
5
|
+
* are. An anchor is a set of W3C Web Annotation selectors, so it can be found again after the
|
|
6
|
+
* content changes; finding it is best-effort, and says so (`orphaned`).
|
|
7
|
+
*
|
|
8
|
+
* Everything the frame reports is the content's word: the shell shares its realm with the
|
|
9
|
+
* content, which can interfere or fake a report. The host checks each report's shape and size
|
|
10
|
+
* and takes reports only while it asked for them. Use them as UI hints (where to draw a comment,
|
|
11
|
+
* what to quote), never as permissions.
|
|
12
|
+
*/
|
|
13
|
+
/** Porthole's own messages for annotations (own-prefixed, P002). */
|
|
14
|
+
export const ANNOTATE_METHODS = {
|
|
15
|
+
/** Host → frame: let the person point (`AnnotateMode`). */
|
|
16
|
+
start: "porthole/annotate.start",
|
|
17
|
+
/** Host → frame: stop. */
|
|
18
|
+
stop: "porthole/annotate.stop",
|
|
19
|
+
/** Host → frame: mark these anchors (replacing what was marked). */
|
|
20
|
+
show: "porthole/annotate.show",
|
|
21
|
+
/** Host → frame: bring one mark into view and make it the active one. */
|
|
22
|
+
focus: "porthole/annotate.focus",
|
|
23
|
+
/** Host → frame: remove every mark. */
|
|
24
|
+
clear: "porthole/annotate.clear",
|
|
25
|
+
/** Frame → host. */
|
|
26
|
+
selected: "porthole/annotate.selected",
|
|
27
|
+
unselected: "porthole/annotate.unselected",
|
|
28
|
+
cancelled: "porthole/annotate.cancelled",
|
|
29
|
+
anchored: "porthole/annotate.anchored",
|
|
30
|
+
};
|
|
31
|
+
/** The most of each part of an anchor or report the host takes. */
|
|
32
|
+
export const ANNOTATION_LIMITS = {
|
|
33
|
+
label: 120,
|
|
34
|
+
cssSelector: 1_000,
|
|
35
|
+
exact: 4_000,
|
|
36
|
+
context: 64,
|
|
37
|
+
/** The most marks one `show` carries, and results one report. */
|
|
38
|
+
marks: 500,
|
|
39
|
+
id: 128,
|
|
40
|
+
position: 50_000_000,
|
|
41
|
+
coordinate: 10_000_000,
|
|
42
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type AnnotateMode, type AnnotationAnchor, type AnnotationMark, type AnnotationRect, type AnnotationReport } from "./types.ts";
|
|
2
|
+
/** An anchor, rebuilt from its known fields; null if any part is malformed or too large. */
|
|
3
|
+
export declare function parseAnchor(value: unknown): AnnotationAnchor | null;
|
|
4
|
+
/** A box in the frame's viewport; null unless every number is finite and sizes aren't negative. */
|
|
5
|
+
export declare function parseRect(value: unknown): AnnotationRect | null;
|
|
6
|
+
/** A report from the frame, checked; null if malformed (it is then dropped). */
|
|
7
|
+
export declare function parseReport(value: unknown): AnnotationReport | null;
|
|
8
|
+
/** What the host may start with: elements, text, or both (default both). */
|
|
9
|
+
export declare function annotateMode(mode: AnnotateMode | undefined): Required<AnnotateMode>;
|
|
10
|
+
/** The host's marks, checked before they go to the frame; throws on the first bad one. */
|
|
11
|
+
export declare function checkMarks(marks: readonly AnnotationMark[]): AnnotationMark[];
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { ANNOTATION_LIMITS as LIMITS, } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The host's checks on what a frame says about annotations (P053), and on anchors a host stores
|
|
4
|
+
* and sends back. Each rebuilds what it accepts from known fields only, within `ANNOTATION_LIMITS`,
|
|
5
|
+
* and answers null for anything else: a report is dropped whole, never repaired.
|
|
6
|
+
*/
|
|
7
|
+
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
8
|
+
const text = (value, max, min = 0) => typeof value === "string" && value.length >= min && value.length <= max;
|
|
9
|
+
const count = (value, max) => typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= max;
|
|
10
|
+
function selector(value) {
|
|
11
|
+
if (!isRecord(value)) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
switch (value.type) {
|
|
15
|
+
case "CssSelector":
|
|
16
|
+
return text(value.value, LIMITS.cssSelector, 1)
|
|
17
|
+
? { type: "CssSelector", value: value.value }
|
|
18
|
+
: null;
|
|
19
|
+
case "TextQuoteSelector": {
|
|
20
|
+
const { exact, prefix, suffix } = value;
|
|
21
|
+
if (!text(exact, LIMITS.exact, 1) ||
|
|
22
|
+
(prefix !== undefined && !text(prefix, LIMITS.context)) ||
|
|
23
|
+
(suffix !== undefined && !text(suffix, LIMITS.context))) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
type: "TextQuoteSelector",
|
|
28
|
+
exact,
|
|
29
|
+
...(prefix === undefined || prefix === "" ? {} : { prefix }),
|
|
30
|
+
...(suffix === undefined || suffix === "" ? {} : { suffix }),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
case "TextPositionSelector":
|
|
34
|
+
return count(value.start, LIMITS.position) &&
|
|
35
|
+
count(value.end, LIMITS.position) &&
|
|
36
|
+
value.start <= value.end
|
|
37
|
+
? { type: "TextPositionSelector", start: value.start, end: value.end }
|
|
38
|
+
: null;
|
|
39
|
+
default:
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** An anchor, rebuilt from its known fields; null if any part is malformed or too large. */
|
|
44
|
+
export function parseAnchor(value) {
|
|
45
|
+
if (!isRecord(value) || (value.kind !== "element" && value.kind !== "text")) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (!Array.isArray(value.selectors) || value.selectors.length === 0) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (value.selectors.length > 3 || !text(value.label, LIMITS.label)) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const selectors = [];
|
|
55
|
+
for (const each of value.selectors) {
|
|
56
|
+
const parsed = selector(each);
|
|
57
|
+
if (parsed === null || selectors.some((other) => other.type === parsed.type)) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
selectors.push(parsed);
|
|
61
|
+
}
|
|
62
|
+
// A text anchor is found by its words; an element one needs a way to find the element.
|
|
63
|
+
const quote = selectors.some((each) => each.type === "TextQuoteSelector");
|
|
64
|
+
const css = selectors.some((each) => each.type === "CssSelector");
|
|
65
|
+
if (value.kind === "text" ? !quote : !css && !quote) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return { kind: value.kind, selectors, label: value.label };
|
|
69
|
+
}
|
|
70
|
+
const coordinate = (value) => typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= LIMITS.coordinate;
|
|
71
|
+
/** A box in the frame's viewport; null unless every number is finite and sizes aren't negative. */
|
|
72
|
+
export function parseRect(value) {
|
|
73
|
+
if (!isRecord(value)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const { x, y, width, height } = value;
|
|
77
|
+
if (!coordinate(x) || !coordinate(y) || !coordinate(width) || !coordinate(height)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (width < 0 || height < 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return { x, y, width, height };
|
|
84
|
+
}
|
|
85
|
+
const markId = (value) => text(value, LIMITS.id, 1);
|
|
86
|
+
function results(value) {
|
|
87
|
+
if (!Array.isArray(value) || value.length > LIMITS.marks) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const each of value) {
|
|
92
|
+
if (!isRecord(each) || !markId(each.id)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (each.state === "orphaned") {
|
|
96
|
+
out.push({ id: each.id, state: "orphaned" });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (each.state !== "attached") {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const rect = each.rect === undefined ? undefined : parseRect(each.rect);
|
|
103
|
+
if (rect === null) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
out.push({ id: each.id, state: "attached", ...(rect === undefined ? {} : { rect }) });
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** A report from the frame, checked; null if malformed (it is then dropped). */
|
|
111
|
+
export function parseReport(value) {
|
|
112
|
+
if (!isRecord(value)) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
switch (value.type) {
|
|
116
|
+
case "selected": {
|
|
117
|
+
const anchor = parseAnchor(value.anchor);
|
|
118
|
+
const rect = parseRect(value.rect);
|
|
119
|
+
return anchor === null || rect === null ? null : { type: "selected", anchor, rect };
|
|
120
|
+
}
|
|
121
|
+
case "unselected":
|
|
122
|
+
return { type: "unselected" };
|
|
123
|
+
case "cancelled":
|
|
124
|
+
return { type: "cancelled" };
|
|
125
|
+
case "anchored": {
|
|
126
|
+
const checked = results(value.results);
|
|
127
|
+
return checked === null ? null : { type: "anchored", results: checked };
|
|
128
|
+
}
|
|
129
|
+
default:
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** What the host may start with: elements, text, or both (default both). */
|
|
134
|
+
export function annotateMode(mode) {
|
|
135
|
+
const elements = mode?.elements ?? true;
|
|
136
|
+
const words = mode?.text ?? true;
|
|
137
|
+
if (typeof elements !== "boolean" || typeof words !== "boolean" || (!elements && !words)) {
|
|
138
|
+
throw new TypeError("annotations.start: elements, text, or both");
|
|
139
|
+
}
|
|
140
|
+
return { elements, text: words };
|
|
141
|
+
}
|
|
142
|
+
/** The host's marks, checked before they go to the frame; throws on the first bad one. */
|
|
143
|
+
export function checkMarks(marks) {
|
|
144
|
+
if (!Array.isArray(marks) || marks.length > LIMITS.marks) {
|
|
145
|
+
throw new TypeError(`annotations.show: at most ${LIMITS.marks} marks`);
|
|
146
|
+
}
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
return marks.map((mark, index) => {
|
|
149
|
+
const anchor = parseAnchor(mark?.anchor);
|
|
150
|
+
if (!markId(mark?.id) || seen.has(mark.id) || anchor === null) {
|
|
151
|
+
throw new TypeError(`annotations.show: mark ${index} needs a unique id (1 to ${LIMITS.id} characters) and a valid anchor`);
|
|
152
|
+
}
|
|
153
|
+
seen.add(mark.id);
|
|
154
|
+
return { id: mark.id, anchor };
|
|
155
|
+
});
|
|
156
|
+
}
|
package/dist/bridge/bridge.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type CapabilityName } from "../config/capabilities.ts";
|
|
2
|
+
import type { AnnotationReport } from "../annotate/types.ts";
|
|
2
3
|
import type { PortholeConfig } from "../config/schema.ts";
|
|
3
4
|
import type { CapabilityHandlers, Confirm, HostFunctionBridge, Refusal } from "./types.ts";
|
|
4
5
|
/**
|
|
@@ -13,6 +14,9 @@ export type BridgeEvent = {
|
|
|
13
14
|
} | {
|
|
14
15
|
readonly type: "error";
|
|
15
16
|
readonly message: string;
|
|
17
|
+
} | {
|
|
18
|
+
readonly type: "annotation";
|
|
19
|
+
readonly report: AnnotationReport;
|
|
16
20
|
} | {
|
|
17
21
|
readonly type: "refused";
|
|
18
22
|
readonly capability: CapabilityName;
|
package/dist/bridge/bridge.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { capabilitySpec, } from "../config/capabilities.js";
|
|
1
|
+
import { MAX_COPY_CHARS, capabilitySpec, } from "../config/capabilities.js";
|
|
2
|
+
import { parseReport } from "../annotate/validate.js";
|
|
2
3
|
import { effectiveGates } from "../compile/policy.js";
|
|
3
4
|
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
4
5
|
const MAX_URL = 4_096;
|
|
@@ -74,6 +75,13 @@ const VALIDATORS = {
|
|
|
74
75
|
? { message: message.slice(0, 2_000) }
|
|
75
76
|
: null;
|
|
76
77
|
},
|
|
78
|
+
copyText: (args) => {
|
|
79
|
+
const text = isRecord(args) ? args.text : undefined;
|
|
80
|
+
return typeof text === "string" && text !== "" && text.length <= MAX_COPY_CHARS
|
|
81
|
+
? { text }
|
|
82
|
+
: null;
|
|
83
|
+
},
|
|
84
|
+
annotate: (args) => parseReport(args),
|
|
77
85
|
resize: (args) => {
|
|
78
86
|
if (!isRecord(args) ||
|
|
79
87
|
typeof args.height !== "number" ||
|
|
@@ -173,6 +181,9 @@ export function createBridge(options) {
|
|
|
173
181
|
const size = args;
|
|
174
182
|
onEvent({ type: "resize", ...size });
|
|
175
183
|
}
|
|
184
|
+
else if (capability === "annotate") {
|
|
185
|
+
onEvent({ type: "annotation", report: args });
|
|
186
|
+
}
|
|
176
187
|
else {
|
|
177
188
|
onEvent({ type: "error", message: args.message });
|
|
178
189
|
}
|
package/dist/compile/policy.d.ts
CHANGED
|
@@ -33,9 +33,10 @@ export declare function serializeCsp(directives: readonly CspDirective[]): strin
|
|
|
33
33
|
*/
|
|
34
34
|
export declare function sandboxTokens(config: PortholeConfig): string[];
|
|
35
35
|
/**
|
|
36
|
-
* The frame's `allow` attribute (Permissions Policy).
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* The frame's `allow` attribute (Permissions Policy). No camera, microphone, geolocation,
|
|
37
|
+
* fullscreen or other powerful feature is delegated to the frame, and the clipboard is denied by
|
|
38
|
+
* name (P051): content never writes it, the host does (`copyText`). A url-mode config may
|
|
39
|
+
* delegate clipboard writes and full screen to the app's own origin (P049).
|
|
39
40
|
*/
|
|
40
41
|
export declare function allowAttribute(config: PortholeConfig): string;
|
|
41
42
|
/**
|
|
@@ -54,3 +55,9 @@ export declare function embedderFrameSources(config: PortholeConfig): string[];
|
|
|
54
55
|
* engine Porthole supports reads frames from `frame-src`, P038).
|
|
55
56
|
*/
|
|
56
57
|
export declare function embedderCsp(configs: readonly PortholeConfig[]): string;
|
|
58
|
+
/**
|
|
59
|
+
* The `frame-src` sources the embedding page must allow for every kind of display it shows, as a
|
|
60
|
+
* list, for a host that writes its own policy around them (empty: it frames nothing of
|
|
61
|
+
* Porthole's but `srcdoc`, which needs none).
|
|
62
|
+
*/
|
|
63
|
+
export declare function embedderFrameSourcesOf(configs: readonly PortholeConfig[]): string[];
|
package/dist/compile/policy.js
CHANGED
|
@@ -74,12 +74,15 @@ export function sandboxTokens(config) {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
/**
|
|
77
|
-
* The frame's `allow` attribute (Permissions Policy).
|
|
78
|
-
*
|
|
79
|
-
*
|
|
77
|
+
* The frame's `allow` attribute (Permissions Policy). No camera, microphone, geolocation,
|
|
78
|
+
* fullscreen or other powerful feature is delegated to the frame, and the clipboard is denied by
|
|
79
|
+
* name (P051): content never writes it, the host does (`copyText`). A url-mode config may
|
|
80
|
+
* delegate clipboard writes and full screen to the app's own origin (P049).
|
|
80
81
|
*/
|
|
81
82
|
export function allowAttribute(config) {
|
|
82
|
-
return config.mode === "url"
|
|
83
|
+
return config.mode === "url"
|
|
84
|
+
? (config.permissions ?? []).join("; ")
|
|
85
|
+
: "clipboard-read 'none'; clipboard-write 'none'";
|
|
83
86
|
}
|
|
84
87
|
/**
|
|
85
88
|
* The `frame-src` sources the EMBEDDING page must allow, and nothing else: a frame's own CSP can't
|
|
@@ -99,6 +102,14 @@ export function embedderFrameSources(config) {
|
|
|
99
102
|
* engine Porthole supports reads frames from `frame-src`, P038).
|
|
100
103
|
*/
|
|
101
104
|
export function embedderCsp(configs) {
|
|
102
|
-
const sources =
|
|
105
|
+
const sources = embedderFrameSourcesOf(configs);
|
|
103
106
|
return serializeCsp([["frame-src", ...(sources.length === 0 ? ["'none'"] : sources)]]);
|
|
104
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* The `frame-src` sources the embedding page must allow for every kind of display it shows, as a
|
|
110
|
+
* list, for a host that writes its own policy around them (empty: it frames nothing of
|
|
111
|
+
* Porthole's but `srcdoc`, which needs none).
|
|
112
|
+
*/
|
|
113
|
+
export function embedderFrameSourcesOf(configs) {
|
|
114
|
+
return unique(configs.flatMap(embedderFrameSources));
|
|
115
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AnnotationReport } from "../annotate/types.ts";
|
|
1
2
|
/**
|
|
2
3
|
* The capability vocabulary (PRINCIPLE 4): every power content has beyond drawing is one of these
|
|
3
4
|
* host functions, named in the config. A capability the config doesn't declare is not wired into
|
|
@@ -7,7 +8,7 @@
|
|
|
7
8
|
* are on by default), and is served either by a handler the host passes or as an event the
|
|
8
9
|
* framework emits.
|
|
9
10
|
*/
|
|
10
|
-
export declare const CAPABILITY_NAMES: readonly ["sendPrompt", "openLink", "downloadFile", "callTool", "readData", "setModelContext", "reportError", "resize"];
|
|
11
|
+
export declare const CAPABILITY_NAMES: readonly ["sendPrompt", "openLink", "downloadFile", "callTool", "readData", "setModelContext", "reportError", "resize", "copyText", "annotate"];
|
|
11
12
|
export type CapabilityName = (typeof CAPABILITY_NAMES)[number];
|
|
12
13
|
/**
|
|
13
14
|
* What must hold before a call reaches the host's handler. A capability's gates all apply.
|
|
@@ -32,14 +33,17 @@ export interface CapabilitySpec {
|
|
|
32
33
|
/** The global the shell defines for it (sugar over the protocol message), if any. */
|
|
33
34
|
readonly global?: string;
|
|
34
35
|
/**
|
|
35
|
-
* How content reaches it without the shell's global: the MCP Apps request
|
|
36
|
-
*
|
|
36
|
+
* How content reaches it without the shell's global: the MCP Apps request (or Porthole's own,
|
|
37
|
+
* `porthole/…`, where the standard has none), and the official App SDK's method for it, if any.
|
|
38
|
+
* Absent for what the shell does by itself (sizes, errors, annotations).
|
|
37
39
|
*/
|
|
38
40
|
readonly request?: {
|
|
39
41
|
readonly method: string;
|
|
40
|
-
readonly sdk
|
|
42
|
+
readonly sdk?: string;
|
|
41
43
|
};
|
|
42
44
|
}
|
|
45
|
+
/** The most text one `copyText` call carries. */
|
|
46
|
+
export declare const MAX_COPY_CHARS = 20000;
|
|
43
47
|
export declare const CAPABILITIES: {
|
|
44
48
|
readonly sendPrompt: {
|
|
45
49
|
readonly defaultGates: readonly ["user-activation", "confirm"];
|
|
@@ -99,6 +103,27 @@ export declare const CAPABILITIES: {
|
|
|
99
103
|
readonly defaultGates: readonly [];
|
|
100
104
|
readonly served: "event";
|
|
101
105
|
};
|
|
106
|
+
/**
|
|
107
|
+
* Copies text to the person's clipboard, done by the host, which shows them what was copied
|
|
108
|
+
* (P052). Content can't write the clipboard any other way (P051).
|
|
109
|
+
*/
|
|
110
|
+
readonly copyText: {
|
|
111
|
+
readonly defaultGates: readonly ["user-activation", Gate];
|
|
112
|
+
readonly served: "handler";
|
|
113
|
+
readonly global: "copyText";
|
|
114
|
+
readonly request: {
|
|
115
|
+
readonly method: "porthole/copy-text";
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Annotations (P053): the host asks the shell to let the person point at an element or select
|
|
120
|
+
* text, and to mark anchors already made. What the frame reports back is the content's word
|
|
121
|
+
* (it shares the realm): UI hints for the host, never permissions.
|
|
122
|
+
*/
|
|
123
|
+
readonly annotate: {
|
|
124
|
+
readonly defaultGates: readonly [Gate];
|
|
125
|
+
readonly served: "event";
|
|
126
|
+
};
|
|
102
127
|
};
|
|
103
128
|
/** The arguments a call carries, once the adapter has validated them. */
|
|
104
129
|
export interface CapabilityArgs {
|
|
@@ -130,6 +155,11 @@ export interface CapabilityArgs {
|
|
|
130
155
|
readonly height: number;
|
|
131
156
|
readonly width?: number;
|
|
132
157
|
};
|
|
158
|
+
readonly copyText: {
|
|
159
|
+
readonly text: string;
|
|
160
|
+
};
|
|
161
|
+
/** What the frame reported, once checked (`AnnotationReport`). */
|
|
162
|
+
readonly annotate: AnnotationReport;
|
|
133
163
|
}
|
|
134
164
|
/** What a call resolves to. Protocol-shaped results (a tool's result) stay opaque to the core. */
|
|
135
165
|
export interface CapabilityResults {
|
|
@@ -144,6 +174,9 @@ export interface CapabilityResults {
|
|
|
144
174
|
readonly setModelContext: undefined;
|
|
145
175
|
readonly reportError: undefined;
|
|
146
176
|
readonly resize: undefined;
|
|
177
|
+
/** Whether the text was copied. */
|
|
178
|
+
readonly copyText: boolean;
|
|
179
|
+
readonly annotate: undefined;
|
|
147
180
|
}
|
|
148
181
|
/** The capabilities a host serves with a handler. */
|
|
149
182
|
export type HandledCapability = {
|
|
@@ -16,8 +16,12 @@ export const CAPABILITY_NAMES = [
|
|
|
16
16
|
"setModelContext",
|
|
17
17
|
"reportError",
|
|
18
18
|
"resize",
|
|
19
|
+
"copyText",
|
|
20
|
+
"annotate",
|
|
19
21
|
];
|
|
20
22
|
const rate = (perMinute) => ({ rateLimit: { perMinute } });
|
|
23
|
+
/** The most text one `copyText` call carries. */
|
|
24
|
+
export const MAX_COPY_CHARS = 20_000;
|
|
21
25
|
export const CAPABILITIES = {
|
|
22
26
|
sendPrompt: {
|
|
23
27
|
defaultGates: ["user-activation", "confirm"],
|
|
@@ -53,6 +57,22 @@ export const CAPABILITIES = {
|
|
|
53
57
|
},
|
|
54
58
|
reportError: { defaultGates: [rate(10)], served: "event" },
|
|
55
59
|
resize: { defaultGates: [], served: "event" },
|
|
60
|
+
/**
|
|
61
|
+
* Copies text to the person's clipboard, done by the host, which shows them what was copied
|
|
62
|
+
* (P052). Content can't write the clipboard any other way (P051).
|
|
63
|
+
*/
|
|
64
|
+
copyText: {
|
|
65
|
+
defaultGates: ["user-activation", rate(30)],
|
|
66
|
+
served: "handler",
|
|
67
|
+
global: "copyText",
|
|
68
|
+
request: { method: "porthole/copy-text" },
|
|
69
|
+
},
|
|
70
|
+
/**
|
|
71
|
+
* Annotations (P053): the host asks the shell to let the person point at an element or select
|
|
72
|
+
* text, and to mark anchors already made. What the frame reports back is the content's word
|
|
73
|
+
* (it shares the realm): UI hints for the host, never permissions.
|
|
74
|
+
*/
|
|
75
|
+
annotate: { defaultGates: [rate(120)], served: "event" },
|
|
56
76
|
};
|
|
57
77
|
/** A capability's spec, as the general shape (the table above keeps literal types). */
|
|
58
78
|
export function capabilitySpec(name) {
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -157,4 +157,15 @@ export interface PortholeConfig {
|
|
|
157
157
|
readonly sandbox?: readonly UrlSandboxExtra[];
|
|
158
158
|
/** Url mode only: features delegated to the frame (`URL_PERMISSIONS`); none by default. */
|
|
159
159
|
readonly permissions?: readonly UrlPermission[];
|
|
160
|
+
/**
|
|
161
|
+
* Domain isolation: how far a display's storage reaches, for the manual. Storage is per origin,
|
|
162
|
+
* and the host picks each display's origin (`InstanceRef.scope`); a host that gives several
|
|
163
|
+
* displays one scope (all of a conversation's widgets) names it here (`{ shared:
|
|
164
|
+
* "conversation" }`), so the manual says storage is shared across it and keys need a prefix.
|
|
165
|
+
*/
|
|
166
|
+
readonly storage?: StorageConfig;
|
|
167
|
+
}
|
|
168
|
+
export interface StorageConfig {
|
|
169
|
+
/** What the displays sharing one origin have in common, as a noun ("conversation"). */
|
|
170
|
+
readonly shared?: string;
|
|
160
171
|
}
|
package/dist/config/validate.js
CHANGED
|
@@ -111,6 +111,7 @@ export function validateConfig(input) {
|
|
|
111
111
|
"embedder",
|
|
112
112
|
"sandbox",
|
|
113
113
|
"permissions",
|
|
114
|
+
"storage",
|
|
114
115
|
], "config");
|
|
115
116
|
if (!MODES.includes(config.mode)) {
|
|
116
117
|
fail("config.mode", `must be one of ${MODES.join(", ")}`);
|
|
@@ -258,6 +259,17 @@ export function validateConfig(input) {
|
|
|
258
259
|
}
|
|
259
260
|
});
|
|
260
261
|
}
|
|
262
|
+
if (config.storage !== undefined) {
|
|
263
|
+
const storage = object(config.storage, "config.storage");
|
|
264
|
+
only(storage, ["shared"], "config.storage");
|
|
265
|
+
if (config.isolation !== "domain") {
|
|
266
|
+
fail("config.storage", "only domain isolation keeps storage across displays");
|
|
267
|
+
}
|
|
268
|
+
if (storage.shared !== undefined &&
|
|
269
|
+
(typeof storage.shared !== "string" || !/^[a-z][a-z -]{0,39}$/u.test(storage.shared))) {
|
|
270
|
+
fail("config.storage.shared", 'must be a short lowercase noun ("conversation")');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
261
273
|
if (config.permissions !== undefined) {
|
|
262
274
|
if (config.mode !== "url") {
|
|
263
275
|
fail("config.permissions", "only url mode may be delegated features");
|