@botiverse/porthole 0.0.1 → 0.1.1
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/LICENSE +217 -0
- package/dist/bridge/bridge.d.ts +39 -0
- package/dist/bridge/bridge.js +198 -0
- package/dist/bridge/types.d.ts +83 -0
- package/dist/bridge/types.js +1 -0
- package/dist/compile/compile.d.ts +35 -0
- package/dist/compile/compile.js +30 -0
- package/dist/compile/policy.d.ts +55 -0
- package/dist/compile/policy.js +103 -0
- package/dist/config/capabilities.d.ts +154 -0
- package/dist/config/capabilities.js +63 -0
- package/dist/config/schema.d.ts +152 -0
- package/dist/config/schema.js +11 -0
- package/dist/config/validate.d.ts +22 -0
- package/dist/config/validate.js +261 -0
- package/dist/conformance/behavior.d.ts +2 -0
- package/dist/conformance/behavior.js +426 -0
- package/dist/conformance/index.d.ts +101 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/kit.d.ts +42 -0
- package/dist/conformance/kit.js +24 -0
- package/dist/conformance/oracles.d.ts +35 -0
- package/dist/conformance/oracles.js +77 -0
- package/dist/conformance/probes.d.ts +33 -0
- package/dist/conformance/probes.js +876 -0
- package/dist/conformance/served.d.ts +75 -0
- package/dist/conformance/served.js +335 -0
- package/dist/guide/examples.d.ts +70 -0
- package/dist/guide/examples.js +69 -0
- package/dist/guide/guide.d.ts +16 -0
- package/dist/guide/guide.js +265 -0
- package/dist/host/create-host.d.ts +8 -0
- package/dist/host/create-host.js +292 -0
- package/dist/host/locate.d.ts +8 -0
- package/dist/host/locate.js +16 -0
- package/dist/host/outcome.d.ts +24 -0
- package/dist/host/outcome.js +134 -0
- package/dist/host/types.d.ts +127 -0
- package/dist/host/types.js +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +21 -0
- package/dist/isolation/served.d.ts +35 -0
- package/dist/isolation/served.js +117 -0
- package/dist/isolation/types.d.ts +96 -0
- package/dist/isolation/types.js +2 -0
- package/dist/mcp/adapter.d.ts +43 -0
- package/dist/mcp/adapter.js +327 -0
- package/dist/mcp/index.d.ts +27 -0
- package/dist/mcp/index.js +25 -0
- package/dist/mcp/proxy.d.ts +25 -0
- package/dist/mcp/proxy.js +106 -0
- package/dist/mcp/view.d.ts +31 -0
- package/dist/mcp/view.js +72 -0
- package/dist/platform/types.d.ts +51 -0
- package/dist/platform/types.js +1 -0
- package/dist/platform/web.d.ts +8 -0
- package/dist/platform/web.js +85 -0
- package/dist/presets/presets.d.ts +71 -0
- package/dist/presets/presets.js +140 -0
- package/dist/protocol/adapter.d.ts +73 -0
- package/dist/protocol/adapter.js +1 -0
- package/dist/protocol/jsonrpc.d.ts +43 -0
- package/dist/protocol/jsonrpc.js +50 -0
- package/dist/protocol/mcp-apps.d.ts +79 -0
- package/dist/protocol/mcp-apps.js +99 -0
- package/dist/protocol/shell-adapter.d.ts +5 -0
- package/dist/protocol/shell-adapter.js +228 -0
- package/dist/react/dialog.d.ts +20 -0
- package/dist/react/dialog.js +98 -0
- package/dist/react/frame.d.ts +91 -0
- package/dist/react/frame.js +255 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.js +13 -0
- package/dist/react/live.d.ts +24 -0
- package/dist/react/live.js +57 -0
- package/dist/server/index.d.ts +27 -0
- package/dist/server/index.js +26 -0
- package/dist/server/keys.d.ts +19 -0
- package/dist/server/keys.js +78 -0
- package/dist/server/pages.d.ts +11 -0
- package/dist/server/pages.js +86 -0
- package/dist/server/proxy.d.ts +21 -0
- package/dist/server/proxy.js +107 -0
- package/dist/server/server.d.ts +7 -0
- package/dist/server/server.js +542 -0
- package/dist/server/tickets.d.ts +8 -0
- package/dist/server/tickets.js +28 -0
- package/dist/server/types.d.ts +97 -0
- package/dist/server/types.js +1 -0
- package/dist/shell/document.d.ts +29 -0
- package/dist/shell/document.js +85 -0
- package/dist/shell/plan.d.ts +37 -0
- package/dist/shell/plan.js +35 -0
- package/dist/shell/runtime.d.ts +28 -0
- package/dist/shell/runtime.js +848 -0
- package/dist/theme/variables.d.ts +73 -0
- package/dist/theme/variables.js +171 -0
- package/dist/transport/post-message.d.ts +9 -0
- package/dist/transport/post-message.js +38 -0
- package/dist/transport/types.d.ts +15 -0
- package/dist/transport/types.js +1 -0
- package/package.json +63 -1
- package/src/bridge/bridge.ts +278 -0
- package/src/bridge/types.ts +95 -0
- package/src/compile/compile.ts +68 -0
- package/src/compile/policy.ts +130 -0
- package/src/config/capabilities.ts +138 -0
- package/src/config/schema.ts +168 -0
- package/src/config/validate.ts +318 -0
- package/src/conformance/behavior.ts +485 -0
- package/src/conformance/index.ts +116 -0
- package/src/conformance/kit.ts +63 -0
- package/src/conformance/oracles.ts +126 -0
- package/src/conformance/probes.ts +1129 -0
- package/src/conformance/served.ts +501 -0
- package/src/guide/examples.ts +74 -0
- package/src/guide/guide.ts +430 -0
- package/src/host/create-host.ts +335 -0
- package/src/host/locate.ts +25 -0
- package/src/host/outcome.ts +159 -0
- package/src/host/types.ts +126 -0
- package/src/index.ts +128 -0
- package/src/isolation/served.ts +151 -0
- package/src/isolation/types.ts +91 -0
- package/src/mcp/adapter.ts +441 -0
- package/src/mcp/index.ts +50 -0
- package/src/mcp/proxy.ts +117 -0
- package/src/mcp/view.ts +97 -0
- package/src/platform/types.ts +55 -0
- package/src/platform/web.ts +91 -0
- package/src/presets/presets.ts +217 -0
- package/src/protocol/adapter.ts +67 -0
- package/src/protocol/jsonrpc.ts +83 -0
- package/src/protocol/mcp-apps.ts +138 -0
- package/src/protocol/shell-adapter.ts +261 -0
- package/src/react/dialog.tsx +158 -0
- package/src/react/frame.tsx +486 -0
- package/src/react/index.ts +20 -0
- package/src/react/live.ts +76 -0
- package/src/server/index.ts +37 -0
- package/src/server/keys.ts +100 -0
- package/src/server/pages.ts +117 -0
- package/src/server/proxy.ts +131 -0
- package/src/server/server.ts +657 -0
- package/src/server/tickets.ts +30 -0
- package/src/server/types.ts +104 -0
- package/src/shell/document.ts +121 -0
- package/src/shell/plan.ts +69 -0
- package/src/shell/runtime.ts +848 -0
- package/src/theme/variables.ts +190 -0
- package/src/transport/post-message.ts +45 -0
- package/src/transport/types.ts +16 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { FRAME_STATUS_METHOD } from "../isolation/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* How a display's first document went (P040). A frame's content is another origin's, so the host
|
|
4
|
+
* can't look inside; it goes by what it can see:
|
|
5
|
+
*
|
|
6
|
+
* - a shell or view that speaks for itself is loaded when it finishes its handshake;
|
|
7
|
+
* - a page of Porthole's `/server` says how it went (`porthole/notifications/frame-status`): it is
|
|
8
|
+
* entering an app, or blocked (its ticket used, no cookie kept in a frame, the app down); a url
|
|
9
|
+
* app is loaded when its document loads after the entry page;
|
|
10
|
+
* - a load from which nothing ever spoke, where something should have, is a page that never ran:
|
|
11
|
+
* framing refused;
|
|
12
|
+
* - an app from a host's own gateway is loaded on its first load, unless its page says it is
|
|
13
|
+
* blocked (the gateway may send the same message);
|
|
14
|
+
* - nothing settled in `timeoutMs`: a timeout.
|
|
15
|
+
*/
|
|
16
|
+
/** How long after a load the frame's word may still arrive. */
|
|
17
|
+
const SETTLE_MS = 400;
|
|
18
|
+
const BLOCKS = [
|
|
19
|
+
"framing",
|
|
20
|
+
"cookies",
|
|
21
|
+
"session",
|
|
22
|
+
"upstream",
|
|
23
|
+
"not-found",
|
|
24
|
+
"error",
|
|
25
|
+
];
|
|
26
|
+
/** A frame-status message, or null for anything else. */
|
|
27
|
+
export function parseFrameStatus(data) {
|
|
28
|
+
if (data === null || typeof data !== "object") {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const message = data;
|
|
32
|
+
if (message.jsonrpc !== "2.0" || message.method !== FRAME_STATUS_METHOD) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const params = message.params;
|
|
36
|
+
if (params === null || typeof params !== "object") {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (params.state === "entering") {
|
|
40
|
+
return { state: "entering" };
|
|
41
|
+
}
|
|
42
|
+
if (params.state !== "blocked") {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const reason = BLOCKS.includes(params.reason)
|
|
46
|
+
? params.reason
|
|
47
|
+
: "error";
|
|
48
|
+
return {
|
|
49
|
+
state: "blocked",
|
|
50
|
+
reason,
|
|
51
|
+
...(typeof params.status === "number" && Number.isInteger(params.status)
|
|
52
|
+
? { status: params.status }
|
|
53
|
+
: {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function watchOutcome(options) {
|
|
57
|
+
const { surface, source, speaks } = options;
|
|
58
|
+
const reports = source.kind === "url" && source.reports === true;
|
|
59
|
+
let decided = false;
|
|
60
|
+
let heard = false;
|
|
61
|
+
let entering = false;
|
|
62
|
+
let loads = 0;
|
|
63
|
+
const timers = new Set();
|
|
64
|
+
const later = (ms, run) => {
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
timers.delete(timer);
|
|
67
|
+
run();
|
|
68
|
+
}, ms);
|
|
69
|
+
timers.add(timer);
|
|
70
|
+
};
|
|
71
|
+
const stop = () => {
|
|
72
|
+
for (const timer of timers) {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
}
|
|
75
|
+
timers.clear();
|
|
76
|
+
unlisten();
|
|
77
|
+
unload();
|
|
78
|
+
};
|
|
79
|
+
const decide = (outcome) => {
|
|
80
|
+
if (!decided) {
|
|
81
|
+
decided = true;
|
|
82
|
+
stop();
|
|
83
|
+
options.onOutcome(outcome);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const unlisten = surface.transport.listen((data) => {
|
|
87
|
+
heard = true;
|
|
88
|
+
const status = parseFrameStatus(data);
|
|
89
|
+
if (status?.state === "entering") {
|
|
90
|
+
entering = true;
|
|
91
|
+
}
|
|
92
|
+
else if (status?.state === "blocked") {
|
|
93
|
+
decide({
|
|
94
|
+
state: "blocked",
|
|
95
|
+
reason: status.reason ?? "error",
|
|
96
|
+
...(status.status === undefined ? {} : { status: status.status }),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
const neverRan = () => later(SETTLE_MS, () => {
|
|
101
|
+
if (!heard) {
|
|
102
|
+
decide({ state: "blocked", reason: "framing" });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
const unload = surface.onLoad(() => {
|
|
106
|
+
loads += 1;
|
|
107
|
+
if (decided) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (speaks) {
|
|
111
|
+
// A served shell or view says something before its first load ends; one that never did
|
|
112
|
+
// never ran. (An opaque srcdoc frame always runs: its handshake decides.)
|
|
113
|
+
if (source.kind === "url") {
|
|
114
|
+
neverRan();
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!reports) {
|
|
119
|
+
later(SETTLE_MS, () => decide({ state: "loaded" }));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (entering && loads >= 2) {
|
|
123
|
+
decide({ state: "loaded" });
|
|
124
|
+
}
|
|
125
|
+
else if (!entering) {
|
|
126
|
+
neverRan();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
later(options.timeoutMs, () => decide({ state: "timeout" }));
|
|
130
|
+
return {
|
|
131
|
+
initialized: () => decide({ state: "loaded" }),
|
|
132
|
+
stop,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { Confirm, CapabilityHandlers, Refusal } from "../bridge/types.ts";
|
|
2
|
+
import type { CompiledConfig } from "../compile/compile.ts";
|
|
3
|
+
import type { CapabilityName } from "../config/capabilities.ts";
|
|
4
|
+
import type { ColorScheme } from "../config/schema.ts";
|
|
5
|
+
import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
|
|
6
|
+
import type { PlatformHost } from "../platform/types.ts";
|
|
7
|
+
import type { AdapterConnection, ProtocolAdapter, ShellConnection } from "../protocol/adapter.ts";
|
|
8
|
+
import type { HostContext } from "../protocol/mcp-apps.ts";
|
|
9
|
+
/**
|
|
10
|
+
* The host-side API (framework-agnostic; the web platform by default). A host compiles a config
|
|
11
|
+
* once (`createHost`), then mounts a display per piece of content. Everything here is mechanism;
|
|
12
|
+
* the host passes policy: its handlers, who confirms, its theme values.
|
|
13
|
+
*/
|
|
14
|
+
export interface HostOptions<C extends AdapterConnection = ShellConnection> {
|
|
15
|
+
/** Handlers for the declared capabilities; a mount may pass its own over these. */
|
|
16
|
+
readonly handlers?: CapabilityHandlers;
|
|
17
|
+
/** Who confirms `confirm`-gated calls. Absent: those calls are refused. */
|
|
18
|
+
readonly confirm?: Confirm;
|
|
19
|
+
/**
|
|
20
|
+
* The host's live values for the config's theme variables, for `scheme` (read from its own
|
|
21
|
+
* design tokens). A name it leaves out, or answers empty, gets the config's default.
|
|
22
|
+
*/
|
|
23
|
+
readonly theme?: (scheme: ColorScheme) => Readonly<Record<string, string | null | undefined>>;
|
|
24
|
+
/** The host's own context fields (locale, time zone, platform): merged into the host context. */
|
|
25
|
+
readonly hostContext?: () => Partial<HostContext>;
|
|
26
|
+
/** How the host names itself in the handshake. */
|
|
27
|
+
readonly hostInfo?: {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly version: string;
|
|
30
|
+
};
|
|
31
|
+
/** The protocol adapter. Default: the core's shell adapter (inline and artifact modes). */
|
|
32
|
+
readonly adapter?: ProtocolAdapter<C>;
|
|
33
|
+
/** The platform host. Default: the web's iframe host. */
|
|
34
|
+
readonly platform?: PlatformHost;
|
|
35
|
+
/** Required for domain and external isolation: where an instance's frame loads from. */
|
|
36
|
+
readonly locator?: InstanceLocator;
|
|
37
|
+
}
|
|
38
|
+
export interface MountOptions {
|
|
39
|
+
/** The host's id for this display: what its height is remembered by, and calls name. */
|
|
40
|
+
readonly displayId: string;
|
|
41
|
+
/** The display's accessible name and the title its confirmations show (the host's words). */
|
|
42
|
+
readonly title: string;
|
|
43
|
+
readonly scheme: ColorScheme;
|
|
44
|
+
/** Handlers for this display only, over the host's. */
|
|
45
|
+
readonly handlers?: CapabilityHandlers;
|
|
46
|
+
/** Domain and external isolation: the instance to locate. */
|
|
47
|
+
readonly instance?: InstanceRef;
|
|
48
|
+
/** A whole document the frame starts with (an MCP Apps view), hardened by the shell. */
|
|
49
|
+
readonly document?: string;
|
|
50
|
+
/** The height to start at, before the content reports its own. */
|
|
51
|
+
readonly initialHeightPx?: number;
|
|
52
|
+
}
|
|
53
|
+
/** What a display reports, by event name. */
|
|
54
|
+
/** Why a display's frame showed nothing of its content (`FrameOutcome`). */
|
|
55
|
+
export type FrameBlock =
|
|
56
|
+
/** The page that frames it isn't one its server allows (frame-ancestors), or it never ran. */
|
|
57
|
+
"framing"
|
|
58
|
+
/** The browser keeps no cookie for it inside a frame (WebKit, P025): open it on a tab. */
|
|
59
|
+
| "cookies"
|
|
60
|
+
/** Its ticket was used or its session ended. */
|
|
61
|
+
| "session"
|
|
62
|
+
/** Its app didn't answer. */
|
|
63
|
+
| "upstream" | "not-found" | "error";
|
|
64
|
+
/**
|
|
65
|
+
* How a display's first document went, once per run: it loaded (a shell or view finished its
|
|
66
|
+
* handshake; a url-mode app's document loaded), it was blocked (and why), or nothing settled in
|
|
67
|
+
* `limits.loadTimeoutMs`. A host shows its own fallback ("Open in a new tab") on anything but
|
|
68
|
+
* loaded.
|
|
69
|
+
*/
|
|
70
|
+
export type FrameOutcome = {
|
|
71
|
+
readonly state: "loaded";
|
|
72
|
+
} | {
|
|
73
|
+
readonly state: "blocked";
|
|
74
|
+
readonly reason: FrameBlock;
|
|
75
|
+
readonly status?: number;
|
|
76
|
+
} | {
|
|
77
|
+
readonly state: "timeout";
|
|
78
|
+
};
|
|
79
|
+
export interface DisplayEvents {
|
|
80
|
+
/** The frame finished its handshake (url mode, which has none: its first document loaded). */
|
|
81
|
+
readonly initialized: undefined;
|
|
82
|
+
/** How the frame's first document went (`FrameOutcome`). */
|
|
83
|
+
readonly outcome: FrameOutcome;
|
|
84
|
+
/** The content's size, after the config's limits. */
|
|
85
|
+
readonly resize: {
|
|
86
|
+
readonly height: number;
|
|
87
|
+
readonly width?: number;
|
|
88
|
+
};
|
|
89
|
+
/** What the content threw, or what the sandbox blocked (its own words: untrusted). */
|
|
90
|
+
readonly error: {
|
|
91
|
+
readonly message: string;
|
|
92
|
+
};
|
|
93
|
+
/** The content navigated its frame away; the frame was started again from its content. */
|
|
94
|
+
readonly navigated: undefined;
|
|
95
|
+
/** A call didn't run. */
|
|
96
|
+
readonly refused: {
|
|
97
|
+
readonly capability: CapabilityName;
|
|
98
|
+
readonly refusal: Refusal;
|
|
99
|
+
};
|
|
100
|
+
/** Too many declines: the display stops asking until it runs again. */
|
|
101
|
+
readonly muted: undefined;
|
|
102
|
+
}
|
|
103
|
+
export interface DisplayControls {
|
|
104
|
+
readonly displayId: string;
|
|
105
|
+
/**
|
|
106
|
+
* The theme flipped: the frame is re-themed in place, without a reload. The host's values are
|
|
107
|
+
* read two animation frames later, once a host that restyles its page asynchronously has.
|
|
108
|
+
*/
|
|
109
|
+
setTheme(scheme: ColorScheme): void;
|
|
110
|
+
/** The host's theme values changed (same scheme): read them now and re-theme the frame. */
|
|
111
|
+
refreshTheme(): void;
|
|
112
|
+
on<E extends keyof DisplayEvents>(event: E, listener: (detail: DisplayEvents[E]) => void): () => void;
|
|
113
|
+
/** Runs the content again from the start, in a new frame. */
|
|
114
|
+
reload(): void;
|
|
115
|
+
/** Tears the view down and removes the frame. */
|
|
116
|
+
destroy(): void;
|
|
117
|
+
}
|
|
118
|
+
/** A mounted display: the framework's controls plus the adapter's own operations. */
|
|
119
|
+
export type DisplayHandle<C extends AdapterConnection = ShellConnection> = DisplayControls & Omit<C, keyof AdapterConnection>;
|
|
120
|
+
export interface PortholeHost<C extends AdapterConnection = ShellConnection> {
|
|
121
|
+
readonly compiled: CompiledConfig;
|
|
122
|
+
/**
|
|
123
|
+
* Creates a display in `container`. Throws if a declared capability served by a handler has
|
|
124
|
+
* none (here or in the host's options), so a manual never teaches a function nobody serves.
|
|
125
|
+
*/
|
|
126
|
+
mount(container: HTMLElement, options: MountOptions): DisplayHandle<C>;
|
|
127
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@botiverse/porthole`: the framework-agnostic core. One config (a preset's) compiles into the
|
|
3
|
+
* frame's policies, the shell, and the agent manual; the host mounts displays under it.
|
|
4
|
+
*/
|
|
5
|
+
export { CAPABILITIES, CAPABILITY_NAMES, capabilitySpec, isCapabilityName, type CapabilityArgs, type CapabilityName, type CapabilityResults, type CapabilitySpec, type Gate, type HandledCapability, } from "./config/capabilities.ts";
|
|
6
|
+
export type { CapabilityConfig, CapabilityMap, ColorScheme, DisplayMode, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeConfig, ThemeValue, } from "./config/schema.ts";
|
|
7
|
+
export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.ts";
|
|
8
|
+
export { URL_SANDBOX_EXTRAS, type UrlSandboxExtra } from "./config/schema.ts";
|
|
9
|
+
export { hostOriginProblem, isHostOrigin } from "./isolation/served.ts";
|
|
10
|
+
export { LIBRARY_CDNS, presets, type PresetName, type PresetOptions, type UrlPresetOptions, } from "./presets/presets.ts";
|
|
11
|
+
export { compile, type CompiledConfig } from "./compile/compile.ts";
|
|
12
|
+
export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, type CspDirective, type EgressKind, } from "./compile/policy.ts";
|
|
13
|
+
export { shellPlan, type ShellPlan } from "./shell/plan.ts";
|
|
14
|
+
export type { ShellDocumentOptions } from "./shell/document.ts";
|
|
15
|
+
export { generateGuide, generateToolDescription, type BuiltInTopic, type GuideTopic, } from "./guide/guide.ts";
|
|
16
|
+
export { GUIDE_EXAMPLES, type GuideExample } from "./guide/examples.ts";
|
|
17
|
+
export { CHART_PALETTE, DEFAULT_THEME, DEFAULT_THEME_EVENT, DEFAULT_THEME_VARIABLES, resolveTheme, themeDefault, } from "./theme/variables.ts";
|
|
18
|
+
export type { FrameLocation, FrameSource, InstanceLocator, InstanceRef, ServedDocument, } from "./isolation/types.ts";
|
|
19
|
+
export type { PlatformHost, Surface, SurfaceSpec } from "./platform/types.ts";
|
|
20
|
+
export type { Transport } from "./transport/types.ts";
|
|
21
|
+
export type { AdapterConnection, AdapterEvent, AdapterSession, ProtocolAdapter, ShellConnection, } from "./protocol/adapter.ts";
|
|
22
|
+
export { MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, type HostContext, } from "./protocol/mcp-apps.ts";
|
|
23
|
+
export type { CallContext, CallOutcome, CapabilityHandler, CapabilityHandlers, Confirm, ConfirmAnswer, ConfirmRequest, Proceed, HostFunctionBridge, Refusal, } from "./bridge/types.ts";
|
|
24
|
+
export { createHost } from "./host/create-host.ts";
|
|
25
|
+
export type { DisplayControls, DisplayEvents, DisplayHandle, HostOptions, MountOptions, PortholeHost, } from "./host/types.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@botiverse/porthole`: the framework-agnostic core. One config (a preset's) compiles into the
|
|
3
|
+
* frame's policies, the shell, and the agent manual; the host mounts displays under it.
|
|
4
|
+
*/
|
|
5
|
+
// The config and its vocabulary.
|
|
6
|
+
export { CAPABILITIES, CAPABILITY_NAMES, capabilitySpec, isCapabilityName, } from "./config/capabilities.js";
|
|
7
|
+
export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.js";
|
|
8
|
+
export { URL_SANDBOX_EXTRAS } from "./config/schema.js";
|
|
9
|
+
export { hostOriginProblem, isHostOrigin } from "./isolation/served.js";
|
|
10
|
+
// Presets.
|
|
11
|
+
export { LIBRARY_CDNS, presets, } from "./presets/presets.js";
|
|
12
|
+
// The compiler.
|
|
13
|
+
export { compile } from "./compile/compile.js";
|
|
14
|
+
export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, } from "./compile/policy.js";
|
|
15
|
+
export { shellPlan } from "./shell/plan.js";
|
|
16
|
+
export { generateGuide, generateToolDescription, } from "./guide/guide.js";
|
|
17
|
+
export { GUIDE_EXAMPLES } from "./guide/examples.js";
|
|
18
|
+
// Theme.
|
|
19
|
+
export { CHART_PALETTE, DEFAULT_THEME, DEFAULT_THEME_EVENT, DEFAULT_THEME_VARIABLES, resolveTheme, themeDefault, } from "./theme/variables.js";
|
|
20
|
+
export { MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, } from "./protocol/mcp-apps.js";
|
|
21
|
+
export { createHost } from "./host/create-host.js";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { FrameLocation } from "./types.ts";
|
|
2
|
+
interface ParsedOrigin {
|
|
3
|
+
readonly scheme: string;
|
|
4
|
+
readonly host: string;
|
|
5
|
+
readonly port: string;
|
|
6
|
+
}
|
|
7
|
+
/** `scheme://host[:port]`, lowercase, nothing else; null for anything that isn't exactly that. */
|
|
8
|
+
export declare function parseOrigin(origin: string): ParsedOrigin | null;
|
|
9
|
+
/** Whether `origin` is an exact origin (`https://host[:port]`, lowercase, no path). */
|
|
10
|
+
export declare function isExactOrigin(origin: unknown): origin is string;
|
|
11
|
+
export declare function isHostOrigin(origin: unknown): origin is string;
|
|
12
|
+
/** Why `origin` can't be a host origin, in words a host's developer can act on; null if it can. */
|
|
13
|
+
export declare function hostOriginProblem(origin: unknown): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* Whether two hosts may be the same site. Without the public-suffix list this errs one way only:
|
|
16
|
+
* it says "same site" whenever the hosts are equal or share their last two labels, so a pair it
|
|
17
|
+
* calls cross-site always is (a refused pair, like `a.github.io` and `b.github.io`, may not be:
|
|
18
|
+
* a safe failure). An IP address is a site of its own; `localhost`'s subdomains are each one, as
|
|
19
|
+
* browsers treat an unlisted top-level name as a public suffix.
|
|
20
|
+
*/
|
|
21
|
+
export declare function maybeSameSite(hostA: string, hostB: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Whether `origin` matches a frame source as the config writes them (`https://*.example.com`,
|
|
24
|
+
* `http://*.localhost:8790`, or an exact origin): same scheme and port; `*.` matches one or more
|
|
25
|
+
* labels below the name, never the name itself.
|
|
26
|
+
*/
|
|
27
|
+
export declare function originMatchesSource(origin: string, source: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Throws unless `location` is a frame a host on `hostOrigin` may load for a config whose content
|
|
30
|
+
* is served from `contentOrigins`: its URL is on its stated origin, that origin is one the config
|
|
31
|
+
* names, and it is not of the host's own site (a same-site frame with `allow-same-origin` could
|
|
32
|
+
* reach cookies and storage the host shares with its site).
|
|
33
|
+
*/
|
|
34
|
+
export declare function checkFrameLocation(location: FrameLocation, contentOrigins: readonly string[], hostOrigin: string): void;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks for served frames (domain and external isolation), shared by the host's mount and by
|
|
3
|
+
* `/server`: a served origin must be one the config names, and never of the host's own site
|
|
4
|
+
* (P016, P020). Pure, no DOM and no Node, so both sides run the same code.
|
|
5
|
+
*/
|
|
6
|
+
const EXACT_ORIGIN = /^(https?):\/\/([a-z0-9.-]+|\[[0-9a-f:.]+\])(?::(\d{1,5}))?$/u;
|
|
7
|
+
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/u;
|
|
8
|
+
const DEFAULT_PORTS = { http: "80", https: "443" };
|
|
9
|
+
/** `scheme://host[:port]`, lowercase, nothing else; null for anything that isn't exactly that. */
|
|
10
|
+
export function parseOrigin(origin) {
|
|
11
|
+
const match = EXACT_ORIGIN.exec(origin);
|
|
12
|
+
if (match === null) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const [, scheme = "", host = "", port] = match;
|
|
16
|
+
return { scheme, host, port: port ?? DEFAULT_PORTS[scheme] ?? "" };
|
|
17
|
+
}
|
|
18
|
+
/** Whether `origin` is an exact origin (`https://host[:port]`, lowercase, no path). */
|
|
19
|
+
export function isExactOrigin(origin) {
|
|
20
|
+
return typeof origin === "string" && parseOrigin(origin) !== null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A host page's origin, exactly as `location.origin` and a message's `event.origin` give it:
|
|
24
|
+
* `scheme://host[:port]`, lowercase, any scheme (an Electron or Tauri app's own registered scheme,
|
|
25
|
+
* `ferry://app`, `app://local`), never `"null"` (an opaque origin isn't one origin: every opaque
|
|
26
|
+
* page says "null") and never a path. The shell takes messages only from these, exactly.
|
|
27
|
+
*/
|
|
28
|
+
const HOST_ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*|\[[0-9a-f:.]+\])(:\d{1,5})?$/u;
|
|
29
|
+
export function isHostOrigin(origin) {
|
|
30
|
+
return typeof origin === "string" && origin.length <= 300 && HOST_ORIGIN.test(origin);
|
|
31
|
+
}
|
|
32
|
+
/** Why `origin` can't be a host origin, in words a host's developer can act on; null if it can. */
|
|
33
|
+
export function hostOriginProblem(origin) {
|
|
34
|
+
if (isHostOrigin(origin)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (origin === "null") {
|
|
38
|
+
return 'the host page\'s origin is opaque ("null"): serve it from a real origin (an Electron custom protocol must be registered as standard, and file: pages have none)';
|
|
39
|
+
}
|
|
40
|
+
return `${JSON.stringify(origin)} is not an exact origin (scheme://host[:port], lowercase, no path)`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether two hosts may be the same site. Without the public-suffix list this errs one way only:
|
|
44
|
+
* it says "same site" whenever the hosts are equal or share their last two labels, so a pair it
|
|
45
|
+
* calls cross-site always is (a refused pair, like `a.github.io` and `b.github.io`, may not be:
|
|
46
|
+
* a safe failure). An IP address is a site of its own; `localhost`'s subdomains are each one, as
|
|
47
|
+
* browsers treat an unlisted top-level name as a public suffix.
|
|
48
|
+
*/
|
|
49
|
+
export function maybeSameSite(hostA, hostB) {
|
|
50
|
+
const a = hostA.toLowerCase().replace(/\.$/u, "");
|
|
51
|
+
const b = hostB.toLowerCase().replace(/\.$/u, "");
|
|
52
|
+
if (a === b) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
if (IPV4.test(a) || IPV4.test(b) || a.startsWith("[") || b.startsWith("[")) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const labelsA = a.split(".").toReversed();
|
|
59
|
+
const labelsB = b.split(".").toReversed();
|
|
60
|
+
let shared = 0;
|
|
61
|
+
while (shared < labelsA.length &&
|
|
62
|
+
shared < labelsB.length &&
|
|
63
|
+
labelsA[shared] === labelsB[shared]) {
|
|
64
|
+
shared += 1;
|
|
65
|
+
}
|
|
66
|
+
return shared >= 2;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Whether `origin` matches a frame source as the config writes them (`https://*.example.com`,
|
|
70
|
+
* `http://*.localhost:8790`, or an exact origin): same scheme and port; `*.` matches one or more
|
|
71
|
+
* labels below the name, never the name itself.
|
|
72
|
+
*/
|
|
73
|
+
export function originMatchesSource(origin, source) {
|
|
74
|
+
const parsed = parseOrigin(origin);
|
|
75
|
+
const wildcard = /^(https?):\/\/\*\.([a-z0-9.-]+)(?::(\d{1,5}))?$/u.exec(source);
|
|
76
|
+
if (parsed === null) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (wildcard === null) {
|
|
80
|
+
const exact = parseOrigin(source);
|
|
81
|
+
return (exact !== null &&
|
|
82
|
+
exact.scheme === parsed.scheme &&
|
|
83
|
+
exact.host === parsed.host &&
|
|
84
|
+
exact.port === parsed.port);
|
|
85
|
+
}
|
|
86
|
+
const [, scheme = "", suffix = "", port] = wildcard;
|
|
87
|
+
return (scheme === parsed.scheme &&
|
|
88
|
+
(port ?? DEFAULT_PORTS[scheme]) === parsed.port &&
|
|
89
|
+
parsed.host.endsWith(`.${suffix}`) &&
|
|
90
|
+
parsed.host.length > suffix.length + 1);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Throws unless `location` is a frame a host on `hostOrigin` may load for a config whose content
|
|
94
|
+
* is served from `contentOrigins`: its URL is on its stated origin, that origin is one the config
|
|
95
|
+
* names, and it is not of the host's own site (a same-site frame with `allow-same-origin` could
|
|
96
|
+
* reach cookies and storage the host shares with its site).
|
|
97
|
+
*/
|
|
98
|
+
export function checkFrameLocation(location, contentOrigins, hostOrigin) {
|
|
99
|
+
const origin = parseOrigin(location.origin);
|
|
100
|
+
let url;
|
|
101
|
+
try {
|
|
102
|
+
url = new URL(location.url);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new TypeError(`The located URL ${JSON.stringify(location.url)} is not a URL`);
|
|
106
|
+
}
|
|
107
|
+
if (origin === null || url.origin !== location.origin) {
|
|
108
|
+
throw new TypeError(`The located URL ${JSON.stringify(location.url)} is not on its origin ${JSON.stringify(location.origin)}`);
|
|
109
|
+
}
|
|
110
|
+
if (!contentOrigins.some((source) => originMatchesSource(location.origin, source))) {
|
|
111
|
+
throw new TypeError(`The located origin ${location.origin} is not one of the config's content origins`);
|
|
112
|
+
}
|
|
113
|
+
const host = parseOrigin(hostOrigin);
|
|
114
|
+
if (host !== null && maybeSameSite(origin.host, host.host)) {
|
|
115
|
+
throw new TypeError(`The located origin ${location.origin} may be the same site as the host (${hostOrigin}): content must be served from a registrable domain the host uses for nothing else`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The isolation-mode seam. Opaque frames need nothing from outside: the host builds the shell
|
|
3
|
+
* document and the frame is `srcdoc`. Served modes (domain, and url mode's external app) load a
|
|
4
|
+
* URL, so the host asks a locator for one; the locator is the host's own code, typically a call to
|
|
5
|
+
* the host's backend, which asks `/server` for a ticketed URL after deciding the viewer may see it.
|
|
6
|
+
*
|
|
7
|
+
* Storage is keyed by origin. In domain mode each storage scope gets its own subdomain of the
|
|
8
|
+
* content domain (`<label>.example-usercontent.com`, the label derived from the scope by `/server`,
|
|
9
|
+
* not guessable from it), so content with the same scope shares an origin and its storage and
|
|
10
|
+
* nothing else does. The host chooses the scope (PRINCIPLE 8): per display, per conversation, per
|
|
11
|
+
* app. Browsers partition a framed origin's storage by the top-level site, so it is stable within
|
|
12
|
+
* one host app.
|
|
13
|
+
*/
|
|
14
|
+
import type { EgressConfig } from "../config/schema.ts";
|
|
15
|
+
/**
|
|
16
|
+
* Which document a served frame loads. `preset` names the config it is served under (the server's
|
|
17
|
+
* `configs`); the artifact and the proxy default to their presets' names.
|
|
18
|
+
*/
|
|
19
|
+
export type ServedDocument =
|
|
20
|
+
/** The shell, served with its CSP in headers; content arrives over the transport as for opaque. */
|
|
21
|
+
{
|
|
22
|
+
readonly kind: "shell";
|
|
23
|
+
readonly preset: string;
|
|
24
|
+
}
|
|
25
|
+
/** A stored page (artifact mode), by id and version: versions are immutable. */
|
|
26
|
+
| {
|
|
27
|
+
readonly kind: "artifact";
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly version: string;
|
|
30
|
+
/** Default `artifact`. */
|
|
31
|
+
readonly preset?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The MCP Apps sandbox proxy: a second origin the host hands the view's HTML to, which becomes
|
|
35
|
+
* the view. `egress` is what the view declares (`_meta.ui.csp`, mapped by `/mcp`'s
|
|
36
|
+
* `viewEgress`), opened on top of the config's: its CSP goes out in the proxy's headers, before
|
|
37
|
+
* the HTML arrives.
|
|
38
|
+
*/
|
|
39
|
+
| {
|
|
40
|
+
readonly kind: "mcp-proxy";
|
|
41
|
+
readonly preset?: string;
|
|
42
|
+
readonly egress?: EgressConfig;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Someone else's running app (url mode), e.g. a dev server behind the host's preview gateway:
|
|
46
|
+
* `url` is where the host's upstream finds it; the frame lands on its path and query.
|
|
47
|
+
*/
|
|
48
|
+
| {
|
|
49
|
+
readonly kind: "external";
|
|
50
|
+
readonly url: string;
|
|
51
|
+
};
|
|
52
|
+
/** One served instance: where its storage lives, and what it shows. */
|
|
53
|
+
export interface InstanceRef {
|
|
54
|
+
/**
|
|
55
|
+
* What the content's origin, and so its storage, is keyed by. Instances with the same scope
|
|
56
|
+
* share an origin. Opaque to Porthole: the host picks it (a display id, an app id).
|
|
57
|
+
*/
|
|
58
|
+
readonly scope: string;
|
|
59
|
+
readonly document: ServedDocument;
|
|
60
|
+
}
|
|
61
|
+
/** Where a served frame loads from. */
|
|
62
|
+
export interface FrameLocation {
|
|
63
|
+
/** The URL the frame loads, carrying a one-time ticket where the content is private. */
|
|
64
|
+
readonly url: string;
|
|
65
|
+
/** Its origin: the host takes messages only from this origin, from this frame. */
|
|
66
|
+
readonly origin: string;
|
|
67
|
+
/**
|
|
68
|
+
* Whether the pages there report how loading went (`porthole/notifications/frame-status`):
|
|
69
|
+
* Porthole's `/server` does, so a page that never ran (refused framing) can be told apart from
|
|
70
|
+
* one that loaded. A host's own gateway may send the same message without this.
|
|
71
|
+
*/
|
|
72
|
+
readonly reports?: boolean;
|
|
73
|
+
}
|
|
74
|
+
/** The message a served page sends its host about loading (own-prefixed, P040). */
|
|
75
|
+
export declare const FRAME_STATUS_METHOD = "porthole/notifications/frame-status";
|
|
76
|
+
/**
|
|
77
|
+
* Turns an instance into the URL its frame loads. Implemented by the host, usually by asking its
|
|
78
|
+
* own backend (which authenticates the viewer and calls `PortholeServer.issue`). Called on every
|
|
79
|
+
* mount: a ticket is good once.
|
|
80
|
+
*/
|
|
81
|
+
export interface InstanceLocator {
|
|
82
|
+
locate(instance: InstanceRef, signal?: AbortSignal): Promise<FrameLocation>;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Where a frame's document comes from: built by the host (`srcdoc`, opaque isolation), or loaded
|
|
86
|
+
* from a URL (domain and external isolation).
|
|
87
|
+
*/
|
|
88
|
+
export type FrameSource = {
|
|
89
|
+
readonly kind: "srcdoc";
|
|
90
|
+
readonly html: string;
|
|
91
|
+
} | {
|
|
92
|
+
readonly kind: "url";
|
|
93
|
+
readonly url: string;
|
|
94
|
+
readonly origin: string;
|
|
95
|
+
readonly reports?: boolean;
|
|
96
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AdapterConnection, ProtocolAdapter } from "../protocol/adapter.ts";
|
|
2
|
+
import { type HostContext } from "../protocol/mcp-apps.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The MCP Apps adapter (P004, P007): the host side of the protocol for full MCP Apps views, written
|
|
5
|
+
* against the core's seams and no SDK. It answers the view's handshake with the host context and
|
|
6
|
+
* the capabilities the config declares, carries the view's requests into the bridge (which alone
|
|
7
|
+
* decides whether they run), reports sizes, errors and teardown requests, and sends the view its
|
|
8
|
+
* tool input, result and cancellation, never before the view said it was initialized (spec §
|
|
9
|
+
* Sandbox proxy, 6). Behind the sandbox proxy it hands the proxy the view's HTML first.
|
|
10
|
+
*/
|
|
11
|
+
/** The tool call a view was made for: its host context's `toolInfo`. */
|
|
12
|
+
export interface ToolInfo {
|
|
13
|
+
/** The JSON-RPC id of the `tools/call`. */
|
|
14
|
+
readonly id?: string | number;
|
|
15
|
+
/** The tool's definition (name, input schema, …) as the server listed it. */
|
|
16
|
+
readonly tool: Readonly<Record<string, unknown>>;
|
|
17
|
+
}
|
|
18
|
+
export interface McpAppsAdapterOptions {
|
|
19
|
+
/** The tool call each display shows, by display id. */
|
|
20
|
+
readonly toolInfo?: (displayId: string) => ToolInfo | undefined;
|
|
21
|
+
/** How long `teardown` waits for the view's answer, ms (default 3000). */
|
|
22
|
+
readonly teardownTimeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
/** A tool's result as MCP carries it (`CallToolResult`): content, structured content, `_meta`. */
|
|
25
|
+
export type ToolResult = Readonly<Record<string, unknown>>;
|
|
26
|
+
/** What a host drives on an MCP Apps view, besides the framework's own controls. */
|
|
27
|
+
export interface McpAppConnection extends AdapterConnection {
|
|
28
|
+
/** The tool's arguments so far, while the agent writes them (dropped once the whole went). */
|
|
29
|
+
sendToolInputPartial(args: Readonly<Record<string, unknown>>): void;
|
|
30
|
+
/** The tool's whole arguments, once. */
|
|
31
|
+
sendToolInput(args: Readonly<Record<string, unknown>>): void;
|
|
32
|
+
/** The tool's result. */
|
|
33
|
+
sendToolResult(result: ToolResult): void;
|
|
34
|
+
/** The tool call was cancelled (by the person, an error, a policy). */
|
|
35
|
+
sendToolCancelled(reason?: string): void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The host context as a view may receive it: Porthole's fields as they are, its theme variables
|
|
39
|
+
* cut to the names the standard defines (the official SDK refuses any other).
|
|
40
|
+
*/
|
|
41
|
+
export declare function viewHostContext(context: HostContext, toolInfo?: ToolInfo): Readonly<Record<string, unknown>>;
|
|
42
|
+
/** The MCP Apps adapter: pass it as `HostOptions.adapter` with the `mcpApp` preset. */
|
|
43
|
+
export declare function mcpAppsAdapter(options?: McpAppsAdapterOptions): ProtocolAdapter<McpAppConnection>;
|