@botiverse/porthole 0.0.1 → 0.1.0
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 +184 -0
- package/dist/bridge/types.d.ts +67 -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 +106 -0
- package/dist/config/capabilities.d.ts +154 -0
- package/dist/config/capabilities.js +63 -0
- package/dist/config/schema.d.ts +131 -0
- package/dist/config/schema.js +1 -0
- package/dist/config/validate.d.ts +20 -0
- package/dist/config/validate.js +230 -0
- package/dist/conformance/behavior.d.ts +2 -0
- package/dist/conformance/behavior.js +376 -0
- package/dist/conformance/index.d.ts +95 -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 +9 -0
- package/dist/conformance/probes.js +865 -0
- package/dist/conformance/served.d.ts +68 -0
- package/dist/conformance/served.js +311 -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 +261 -0
- package/dist/host/create-host.d.ts +8 -0
- package/dist/host/create-host.js +248 -0
- package/dist/host/locate.d.ts +8 -0
- package/dist/host/locate.js +11 -0
- package/dist/host/types.d.ts +95 -0
- package/dist/host/types.js +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +19 -0
- package/dist/isolation/served.d.ts +32 -0
- package/dist/isolation/served.js +97 -0
- package/dist/isolation/types.d.ts +87 -0
- package/dist/isolation/types.js +1 -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 +44 -0
- package/dist/platform/types.js +1 -0
- package/dist/platform/web.d.ts +8 -0
- package/dist/platform/web.js +72 -0
- package/dist/presets/presets.d.ts +65 -0
- package/dist/presets/presets.js +125 -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 +75 -0
- package/dist/react/frame.js +221 -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 +6 -0
- package/dist/server/pages.js +20 -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 +518 -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 +28 -0
- package/dist/shell/document.js +84 -0
- package/dist/shell/plan.d.ts +35 -0
- package/dist/shell/plan.js +34 -0
- package/dist/shell/runtime.d.ts +28 -0
- package/dist/shell/runtime.js +832 -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 +71 -1
- package/src/bridge/bridge.ts +256 -0
- package/src/bridge/types.ts +77 -0
- package/src/compile/compile.ts +68 -0
- package/src/compile/policy.ts +133 -0
- package/src/config/capabilities.ts +138 -0
- package/src/config/schema.ts +140 -0
- package/src/config/validate.ts +284 -0
- package/src/conformance/behavior.ts +430 -0
- package/src/conformance/index.ts +102 -0
- package/src/conformance/kit.ts +63 -0
- package/src/conformance/oracles.ts +126 -0
- package/src/conformance/probes.ts +1093 -0
- package/src/conformance/served.ts +472 -0
- package/src/guide/examples.ts +74 -0
- package/src/guide/guide.ts +424 -0
- package/src/host/create-host.ts +290 -0
- package/src/host/locate.ts +20 -0
- package/src/host/types.ts +95 -0
- package/src/index.ts +118 -0
- package/src/isolation/served.ts +127 -0
- package/src/isolation/types.ts +77 -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 +48 -0
- package/src/platform/web.ts +78 -0
- package/src/presets/presets.ts +193 -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 +422 -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 +31 -0
- package/src/server/proxy.ts +131 -0
- package/src/server/server.ts +617 -0
- package/src/server/tickets.ts +30 -0
- package/src/server/types.ts +104 -0
- package/src/shell/document.ts +120 -0
- package/src/shell/plan.ts +66 -0
- package/src/shell/runtime.ts +832 -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,77 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CapabilityArgs,
|
|
3
|
+
CapabilityName,
|
|
4
|
+
CapabilityResults,
|
|
5
|
+
HandledCapability,
|
|
6
|
+
} from "../config/capabilities.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The host-function bridge (ARCHITECTURE § Framework layer): carries a capability call from the
|
|
10
|
+
* frame to the host's handler and its result back, checking at the boundary that the capability
|
|
11
|
+
* is declared, that its arguments are well formed, and that every gate passes, in that order.
|
|
12
|
+
* Adapters decode protocol messages into calls; the bridge alone decides whether they run.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Which display a call came from. */
|
|
16
|
+
export interface CallContext {
|
|
17
|
+
readonly displayId: string;
|
|
18
|
+
/** The display's title, as the host gave it (never the content's words). */
|
|
19
|
+
readonly title: string;
|
|
20
|
+
readonly capability: CapabilityName;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type CapabilityHandler<N extends CapabilityName> = (
|
|
24
|
+
args: CapabilityArgs[N],
|
|
25
|
+
context: CallContext,
|
|
26
|
+
) => CapabilityResults[N] | Promise<CapabilityResults[N]>;
|
|
27
|
+
|
|
28
|
+
/** The host's handlers: one for each declared capability served by a handler. */
|
|
29
|
+
export type CapabilityHandlers = { readonly [N in HandledCapability]?: CapabilityHandler<N> };
|
|
30
|
+
|
|
31
|
+
/** What a person is asked to confirm: the call's whole effect, not a summary the content chose. */
|
|
32
|
+
export type ConfirmRequest = {
|
|
33
|
+
[N in CapabilityName]: {
|
|
34
|
+
readonly capability: N;
|
|
35
|
+
readonly args: CapabilityArgs[N];
|
|
36
|
+
readonly display: { readonly id: string; readonly title: string };
|
|
37
|
+
};
|
|
38
|
+
}[CapabilityName];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Asks a person. Resolves true only if they accept. The host's UI, or the React binding's default
|
|
42
|
+
* dialog; drawn outside the frame, never inside it.
|
|
43
|
+
*/
|
|
44
|
+
export type Confirm = (request: ConfirmRequest) => Promise<boolean>;
|
|
45
|
+
|
|
46
|
+
/** Why a call didn't run. */
|
|
47
|
+
export type Refusal =
|
|
48
|
+
/** The config doesn't declare it. */
|
|
49
|
+
| "not-declared"
|
|
50
|
+
/** Its arguments were malformed (an `openLink` that isn't http(s), an empty prompt). */
|
|
51
|
+
| "invalid"
|
|
52
|
+
/** It needed a user gesture and had none. */
|
|
53
|
+
| "no-user-activation"
|
|
54
|
+
/** It needed a confirmation and there is no `confirm` function. */
|
|
55
|
+
| "no-confirm"
|
|
56
|
+
/** The person declined. */
|
|
57
|
+
| "declined"
|
|
58
|
+
/** The display was muted after too many declines (until it runs again). */
|
|
59
|
+
| "muted"
|
|
60
|
+
| "rate-limited"
|
|
61
|
+
/** The host's handler threw or rejected. */
|
|
62
|
+
| "failed";
|
|
63
|
+
|
|
64
|
+
export type CallOutcome<T> =
|
|
65
|
+
| { readonly ok: true; readonly value: T }
|
|
66
|
+
| { readonly ok: false; readonly refusal: Refusal; readonly message?: string };
|
|
67
|
+
|
|
68
|
+
/** What adapters call; one per display. */
|
|
69
|
+
export interface HostFunctionBridge {
|
|
70
|
+
/** Validates `args` for `capability`, applies its gates, then runs the host's handler. */
|
|
71
|
+
call<N extends CapabilityName>(
|
|
72
|
+
capability: N,
|
|
73
|
+
args: unknown,
|
|
74
|
+
options?: { readonly userActivated?: boolean },
|
|
75
|
+
): Promise<CallOutcome<CapabilityResults[N]>>;
|
|
76
|
+
declared(capability: CapabilityName): boolean;
|
|
77
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { PortholeConfig } from "../config/schema.ts";
|
|
2
|
+
import { validateConfig } from "../config/validate.ts";
|
|
3
|
+
import { generateGuide, generateToolDescription, type GuideTopic } from "../guide/guide.ts";
|
|
4
|
+
import { shellDocument, type ShellDocumentOptions } from "../shell/document.ts";
|
|
5
|
+
import { shellPlan, type ShellPlan } from "../shell/plan.ts";
|
|
6
|
+
import {
|
|
7
|
+
allowAttribute,
|
|
8
|
+
embedderFrameSources,
|
|
9
|
+
frameCspDirectives,
|
|
10
|
+
sandboxTokens,
|
|
11
|
+
serializeCsp,
|
|
12
|
+
type CspDirective,
|
|
13
|
+
} from "./policy.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Everything derived from one config (PRINCIPLE 3): the frame's policies, the shell's wiring and
|
|
17
|
+
* document, and the agent manual and tool description. Compiled together from a validated config,
|
|
18
|
+
* so they cannot disagree; the agreement test checks that they don't.
|
|
19
|
+
*/
|
|
20
|
+
export interface CompiledConfig {
|
|
21
|
+
readonly config: PortholeConfig;
|
|
22
|
+
/** The frame's CSP, directive by directive; empty in url mode (the app sends its own). */
|
|
23
|
+
readonly cspDirectives: readonly CspDirective[];
|
|
24
|
+
/** The frame's CSP: in the shell's `<meta>`, or a served document's header. */
|
|
25
|
+
readonly csp: string;
|
|
26
|
+
/** The frame's `sandbox` attribute. */
|
|
27
|
+
readonly sandbox: string;
|
|
28
|
+
/** The frame's `allow` attribute (Permissions Policy). */
|
|
29
|
+
readonly allow: string;
|
|
30
|
+
/** The frame's `referrerpolicy` attribute. */
|
|
31
|
+
readonly referrerPolicy: "no-referrer";
|
|
32
|
+
/** The `frame-src` sources the embedding page must allow (see `embedderCsp`). */
|
|
33
|
+
readonly embedderFrameSources: readonly string[];
|
|
34
|
+
/** What the shell wires; null in url mode, which has no shell. */
|
|
35
|
+
readonly shell: ShellPlan | null;
|
|
36
|
+
/** The frame's document: the policy, the base styles and the shell's runtime. */
|
|
37
|
+
shellDocument(options: ShellDocumentOptions): string;
|
|
38
|
+
/** The agent manual for `topics` (every built-in topic by default). */
|
|
39
|
+
guide(topics?: readonly GuideTopic[]): string;
|
|
40
|
+
/** The description of the host's show tool. */
|
|
41
|
+
toolDescription(): string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Validates `config` and derives everything from it. Throws a `ConfigError` on a bad config. */
|
|
45
|
+
export function compile(config: PortholeConfig): CompiledConfig {
|
|
46
|
+
validateConfig(config);
|
|
47
|
+
const cspDirectives = frameCspDirectives(config);
|
|
48
|
+
const csp = serializeCsp(cspDirectives);
|
|
49
|
+
const shell = shellPlan(config);
|
|
50
|
+
return {
|
|
51
|
+
config,
|
|
52
|
+
cspDirectives,
|
|
53
|
+
csp,
|
|
54
|
+
sandbox: sandboxTokens(config).join(" "),
|
|
55
|
+
allow: allowAttribute(config),
|
|
56
|
+
referrerPolicy: "no-referrer",
|
|
57
|
+
embedderFrameSources: embedderFrameSources(config),
|
|
58
|
+
shell,
|
|
59
|
+
shellDocument: (options) => {
|
|
60
|
+
if (shell === null) {
|
|
61
|
+
throw new TypeError("url mode has no shell: the frame loads the app's own document");
|
|
62
|
+
}
|
|
63
|
+
return shellDocument(shell, csp, options);
|
|
64
|
+
},
|
|
65
|
+
guide: (topics) => generateGuide(config, topics),
|
|
66
|
+
toolDescription: () => generateToolDescription(config),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { CAPABILITIES, type CapabilityName, type Gate } from "../config/capabilities.ts";
|
|
2
|
+
import type { EgressConfig, PortholeConfig } from "../config/schema.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The frame's policies, derived from the config: its CSP (in the shell's `<meta>` for opaque
|
|
6
|
+
* frames, in headers for served ones), its `sandbox` and `allow` attributes, and the `frame-src`
|
|
7
|
+
* the EMBEDDING page must send. Pure, so a test can tie each source back to the config.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A CSP directive: its name, then its sources. */
|
|
11
|
+
export type CspDirective = readonly [name: string, ...sources: string[]];
|
|
12
|
+
|
|
13
|
+
export type EgressKind = keyof EgressConfig;
|
|
14
|
+
|
|
15
|
+
/** The capabilities a config declares, in vocabulary order. */
|
|
16
|
+
export function declaredCapabilities(config: PortholeConfig): CapabilityName[] {
|
|
17
|
+
return (Object.keys(CAPABILITIES) as CapabilityName[]).filter(
|
|
18
|
+
(name) => config.capabilities[name] !== undefined,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The gates a declared capability runs under: its own, else the vocabulary's defaults. */
|
|
23
|
+
export function effectiveGates(config: PortholeConfig, name: CapabilityName): readonly Gate[] {
|
|
24
|
+
return config.capabilities[name]?.gates ?? CAPABILITIES[name].defaultGates;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const unique = (values: readonly string[]): string[] => [...new Set(values)];
|
|
28
|
+
|
|
29
|
+
/** The origins content may reach for `kind`: the base allowlist plus every declared capability's. */
|
|
30
|
+
export function egressOrigins(config: PortholeConfig, kind: EgressKind): string[] {
|
|
31
|
+
const fromCapabilities = declaredCapabilities(config).flatMap((name) => {
|
|
32
|
+
const capability = config.capabilities[name];
|
|
33
|
+
return (kind === "connect" ? capability?.connect : capability?.resource) ?? [];
|
|
34
|
+
});
|
|
35
|
+
return unique([...(config.egress[kind] ?? []), ...fromCapabilities]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Every origin content may reach, of any kind. */
|
|
39
|
+
export function allEgressOrigins(config: PortholeConfig): string[] {
|
|
40
|
+
const kinds: EgressKind[] = ["connect", "script", "style", "img", "font", "media"];
|
|
41
|
+
return unique(kinds.flatMap((kind) => egressOrigins(config, kind)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const list = (sources: readonly string[]): string[] =>
|
|
45
|
+
sources.length === 0 ? ["'none'"] : [...sources];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The frame's CSP directives. Closed by default: `default-src 'none'`, and each kind opened only
|
|
49
|
+
* to the config's origins. Inline code runs (agent content is inline), so the walls are origin
|
|
50
|
+
* isolation and egress, not script provenance. No `'self'` anywhere: an opaque frame has nothing
|
|
51
|
+
* of its own to load, and its `'self'` may be the embedding page's origin. Empty for url mode,
|
|
52
|
+
* whose app serves its own policy.
|
|
53
|
+
*/
|
|
54
|
+
export function frameCspDirectives(config: PortholeConfig): CspDirective[] {
|
|
55
|
+
if (config.mode === "url") {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
const origins = (kind: EgressKind) => egressOrigins(config, kind);
|
|
59
|
+
return [
|
|
60
|
+
["default-src", "'none'"],
|
|
61
|
+
["script-src", "'unsafe-inline'", "'unsafe-eval'", "blob:", ...origins("script")],
|
|
62
|
+
["style-src", "'unsafe-inline'", ...origins("style")],
|
|
63
|
+
["font-src", "data:", ...origins("font")],
|
|
64
|
+
["img-src", "data:", "blob:", ...origins("img")],
|
|
65
|
+
["media-src", "data:", "blob:", ...origins("media")],
|
|
66
|
+
["connect-src", ...list(origins("connect"))],
|
|
67
|
+
["worker-src", "blob:"],
|
|
68
|
+
["child-src", "blob:"],
|
|
69
|
+
["frame-src", "'none'"],
|
|
70
|
+
["object-src", "'none'"],
|
|
71
|
+
["base-uri", "'none'"],
|
|
72
|
+
["form-action", "'none'"],
|
|
73
|
+
// WebRTC reaches the network outside connect-src (ICE gathering against a STUN/TURN server).
|
|
74
|
+
// Engines that honor this token block it; the shell removes the RTC constructors for the rest.
|
|
75
|
+
["webrtc", "'block'"],
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function serializeCsp(directives: readonly CspDirective[]): string {
|
|
80
|
+
return directives.map((directive) => directive.join(" ")).join("; ");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The frame's `sandbox` tokens. Opaque: scripts only, so the frame is an opaque origin and gets
|
|
85
|
+
* no forms, popups, downloads, modals or top navigation. Domain: its own origin as well, which is
|
|
86
|
+
* safe only because that origin is a separate site from the host's (the mount refuses one that
|
|
87
|
+
* isn't). External (url mode): what a running web app needs to work on its own origin.
|
|
88
|
+
*/
|
|
89
|
+
export function sandboxTokens(config: PortholeConfig): string[] {
|
|
90
|
+
switch (config.isolation) {
|
|
91
|
+
case "opaque":
|
|
92
|
+
return ["allow-scripts"];
|
|
93
|
+
case "domain":
|
|
94
|
+
return ["allow-scripts", "allow-same-origin"];
|
|
95
|
+
case "external":
|
|
96
|
+
return ["allow-scripts", "allow-same-origin", "allow-forms"];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The frame's `allow` attribute (Permissions Policy). Empty: no camera, microphone, geolocation,
|
|
102
|
+
* clipboard, fullscreen or other powerful feature is delegated to the frame.
|
|
103
|
+
*/
|
|
104
|
+
export function allowAttribute(_config: PortholeConfig): string {
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The `frame-src` sources the EMBEDDING page must allow, and nothing else: a frame's own CSP can't
|
|
110
|
+
* stop it navigating itself to an external URL with data in the query, but the embedding page's
|
|
111
|
+
* `frame-src` governs every navigation of its frames, including self-initiated ones. `srcdoc`
|
|
112
|
+
* frames need no source (measured: they load under `frame-src 'none'`), so opaque content gets
|
|
113
|
+
* none, and can't navigate its frame even to the host's own origin (P033); served and external
|
|
114
|
+
* content adds its own origins, and a host its own frames (`'self'` only if it names it).
|
|
115
|
+
*/
|
|
116
|
+
export function embedderFrameSources(config: PortholeConfig): string[] {
|
|
117
|
+
return unique([...config.contentOrigins, ...(config.embedder.frameSrc ?? [])]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The CSP the embedding page sends (a header, or the host's own document's policy), for every kind
|
|
122
|
+
* of display it shows. It sets no `default-src`: it restricts frames only, leaving the host's own
|
|
123
|
+
* scripts, styles and fetches to the host's policy. `child-src` repeats `frame-src` for engines
|
|
124
|
+
* that read frames from it.
|
|
125
|
+
*/
|
|
126
|
+
export function embedderCsp(configs: readonly PortholeConfig[]): string {
|
|
127
|
+
const sources = unique(configs.flatMap(embedderFrameSources));
|
|
128
|
+
const all = sources.length === 0 ? ["'none'"] : sources;
|
|
129
|
+
return serializeCsp([
|
|
130
|
+
["frame-src", ...all],
|
|
131
|
+
["child-src", ...all],
|
|
132
|
+
]);
|
|
133
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capability vocabulary (PRINCIPLE 4): every power content has beyond drawing is one of these
|
|
3
|
+
* host functions, named in the config. A capability the config doesn't declare is not wired into
|
|
4
|
+
* the shell, not carried by the bridge, adds nothing to the CSP, and is absent from the manual.
|
|
5
|
+
*
|
|
6
|
+
* Each has default gates, applied when a config declares it without its own (PRINCIPLE 1: gates
|
|
7
|
+
* are on by default), and is served either by a handler the host passes or as an event the
|
|
8
|
+
* framework emits.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const CAPABILITY_NAMES = [
|
|
12
|
+
"sendPrompt",
|
|
13
|
+
"openLink",
|
|
14
|
+
"downloadFile",
|
|
15
|
+
"callTool",
|
|
16
|
+
"readData",
|
|
17
|
+
"setModelContext",
|
|
18
|
+
"reportError",
|
|
19
|
+
"resize",
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export type CapabilityName = (typeof CAPABILITY_NAMES)[number];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What must hold before a call reaches the host's handler. A capability's gates all apply.
|
|
26
|
+
*
|
|
27
|
+
* - `user-activation`: the call follows a real user gesture in the frame. The shell refuses a call
|
|
28
|
+
* without one, and the host checks its own page's activation (which a gesture in a child frame
|
|
29
|
+
* propagates to). It stops calls made on load or on a timer; it is not proof of intent.
|
|
30
|
+
* - `confirm`: the host's `confirm` function shows a person the call's whole effect, and the call
|
|
31
|
+
* goes only if they accept. No `confirm` function means the call is refused (fail closed).
|
|
32
|
+
* - `rateLimit`: at most `perMinute` calls per display per minute; the rest are refused.
|
|
33
|
+
*/
|
|
34
|
+
export type Gate =
|
|
35
|
+
| "user-activation"
|
|
36
|
+
| "confirm"
|
|
37
|
+
| { readonly rateLimit: { readonly perMinute: number } };
|
|
38
|
+
|
|
39
|
+
export interface CapabilitySpec {
|
|
40
|
+
/** The gates a declared capability gets when its config names none. */
|
|
41
|
+
readonly defaultGates: readonly Gate[];
|
|
42
|
+
/** `handler`: the host passes a function that performs it. `event`: the framework emits it. */
|
|
43
|
+
readonly served: "handler" | "event";
|
|
44
|
+
/** The global the shell defines for it (sugar over the protocol message), if any. */
|
|
45
|
+
readonly global?: string;
|
|
46
|
+
/**
|
|
47
|
+
* How content reaches it without the shell's global: the MCP Apps request, and the official
|
|
48
|
+
* App SDK's method for it. Absent for what the shell does by itself (sizes, errors).
|
|
49
|
+
*/
|
|
50
|
+
readonly request?: { readonly method: string; readonly sdk: string };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const rate = (perMinute: number): Gate => ({ rateLimit: { perMinute } });
|
|
54
|
+
|
|
55
|
+
export const CAPABILITIES = {
|
|
56
|
+
sendPrompt: {
|
|
57
|
+
defaultGates: ["user-activation", "confirm"],
|
|
58
|
+
served: "handler",
|
|
59
|
+
global: "sendPrompt",
|
|
60
|
+
request: { method: "ui/message", sdk: "app.sendMessage" },
|
|
61
|
+
},
|
|
62
|
+
openLink: {
|
|
63
|
+
defaultGates: ["user-activation", "confirm"],
|
|
64
|
+
served: "handler",
|
|
65
|
+
global: "openLink",
|
|
66
|
+
request: { method: "ui/open-link", sdk: "app.openLink" },
|
|
67
|
+
},
|
|
68
|
+
downloadFile: {
|
|
69
|
+
defaultGates: ["user-activation", "confirm"],
|
|
70
|
+
served: "handler",
|
|
71
|
+
request: { method: "ui/download-file", sdk: "app.downloadFile" },
|
|
72
|
+
},
|
|
73
|
+
callTool: {
|
|
74
|
+
defaultGates: ["confirm"],
|
|
75
|
+
served: "handler",
|
|
76
|
+
request: { method: "tools/call", sdk: "app.callServerTool" },
|
|
77
|
+
},
|
|
78
|
+
readData: {
|
|
79
|
+
defaultGates: [rate(120)],
|
|
80
|
+
served: "handler",
|
|
81
|
+
request: { method: "resources/read", sdk: "app.readServerResource" },
|
|
82
|
+
},
|
|
83
|
+
setModelContext: {
|
|
84
|
+
defaultGates: [rate(30)],
|
|
85
|
+
served: "handler",
|
|
86
|
+
request: { method: "ui/update-model-context", sdk: "app.updateModelContext" },
|
|
87
|
+
},
|
|
88
|
+
reportError: { defaultGates: [rate(10)], served: "event" },
|
|
89
|
+
resize: { defaultGates: [], served: "event" },
|
|
90
|
+
} as const satisfies { readonly [N in CapabilityName]: CapabilitySpec };
|
|
91
|
+
|
|
92
|
+
/** The arguments a call carries, once the adapter has validated them. */
|
|
93
|
+
export interface CapabilityArgs {
|
|
94
|
+
readonly sendPrompt: { readonly text: string };
|
|
95
|
+
readonly openLink: { readonly url: string };
|
|
96
|
+
/** MCP resource contents (embedded or linked), as the protocol carries them. */
|
|
97
|
+
readonly downloadFile: { readonly contents: readonly unknown[] };
|
|
98
|
+
readonly callTool: {
|
|
99
|
+
readonly name: string;
|
|
100
|
+
readonly arguments: Readonly<Record<string, unknown>>;
|
|
101
|
+
};
|
|
102
|
+
readonly readData: { readonly uri: string };
|
|
103
|
+
readonly setModelContext: {
|
|
104
|
+
readonly content?: readonly unknown[];
|
|
105
|
+
readonly structuredContent?: Readonly<Record<string, unknown>>;
|
|
106
|
+
};
|
|
107
|
+
readonly reportError: { readonly message: string };
|
|
108
|
+
readonly resize: { readonly height: number; readonly width?: number };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What a call resolves to. Protocol-shaped results (a tool's result) stay opaque to the core. */
|
|
112
|
+
export interface CapabilityResults {
|
|
113
|
+
/** Whether the prompt was sent. */
|
|
114
|
+
readonly sendPrompt: boolean;
|
|
115
|
+
/** Whether the link was opened. */
|
|
116
|
+
readonly openLink: boolean;
|
|
117
|
+
/** Whether the download started. */
|
|
118
|
+
readonly downloadFile: boolean;
|
|
119
|
+
readonly callTool: unknown;
|
|
120
|
+
readonly readData: unknown;
|
|
121
|
+
readonly setModelContext: undefined;
|
|
122
|
+
readonly reportError: undefined;
|
|
123
|
+
readonly resize: undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The capabilities a host serves with a handler. */
|
|
127
|
+
export type HandledCapability = {
|
|
128
|
+
[N in CapabilityName]: (typeof CAPABILITIES)[N]["served"] extends "handler" ? N : never;
|
|
129
|
+
}[CapabilityName];
|
|
130
|
+
|
|
131
|
+
/** A capability's spec, as the general shape (the table above keeps literal types). */
|
|
132
|
+
export function capabilitySpec(name: CapabilityName): CapabilitySpec {
|
|
133
|
+
return CAPABILITIES[name];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function isCapabilityName(value: unknown): value is CapabilityName {
|
|
137
|
+
return typeof value === "string" && (CAPABILITY_NAMES as readonly string[]).includes(value);
|
|
138
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { CapabilityName, Gate } from "./capabilities.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One config per kind of display (PRINCIPLE 3). The compiler derives the frame's CSP, its sandbox
|
|
5
|
+
* and `allow` attributes, the embedding page's frame policy, the shell's wiring, and the agent
|
|
6
|
+
* manual and tool description from it, together. A preset is a config with safe defaults filled
|
|
7
|
+
* in; hosts adopt one and override theme values and callbacks.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** What is shown. */
|
|
11
|
+
export type DisplayMode =
|
|
12
|
+
/** An HTML fragment or SVG the host has (an agent wrote it), drawn by the shell. */
|
|
13
|
+
| "inline"
|
|
14
|
+
/** A whole HTML document: a standalone page. */
|
|
15
|
+
| "artifact"
|
|
16
|
+
/** A standard MCP Apps view: a `ui://` template, its tool input and result. */
|
|
17
|
+
| "mcp-app"
|
|
18
|
+
/** A running app by URL (a dev server's preview): frame, isolation and access auth only. */
|
|
19
|
+
| "url";
|
|
20
|
+
|
|
21
|
+
/** How the content is kept away from the host. */
|
|
22
|
+
export type IsolationKind =
|
|
23
|
+
/** A sandboxed `srcdoc` frame without `allow-same-origin`: an opaque origin, no storage. */
|
|
24
|
+
| "opaque"
|
|
25
|
+
/**
|
|
26
|
+
* A document served on its own subdomain of a separate registrable domain (`/server`): a real,
|
|
27
|
+
* stable origin per storage scope, its CSP in headers, private content behind a ticket.
|
|
28
|
+
*/
|
|
29
|
+
| "domain"
|
|
30
|
+
/** Someone else's origin (url mode): the frame loads it as is; there is no shell. */
|
|
31
|
+
| "external";
|
|
32
|
+
|
|
33
|
+
export type ColorScheme = "light" | "dark";
|
|
34
|
+
|
|
35
|
+
/** A theme variable's value: one for both schemes, or one per scheme. */
|
|
36
|
+
export type ThemeValue = string | { readonly light: string; readonly dark: string };
|
|
37
|
+
|
|
38
|
+
export interface CapabilityConfig {
|
|
39
|
+
/** Every gate must pass. Omitted: the capability's default gates. `[]`: ungated. */
|
|
40
|
+
readonly gates?: readonly Gate[];
|
|
41
|
+
/** Extra `connect-src` origins this capability needs. */
|
|
42
|
+
readonly connect?: readonly string[];
|
|
43
|
+
/** Extra script/style/img/font/media origins this capability needs. */
|
|
44
|
+
readonly resource?: readonly string[];
|
|
45
|
+
/** Replaces the generated manual entry for this capability. */
|
|
46
|
+
readonly guide?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The declared capabilities. Absent: not available, not wired, not in the CSP, not in the manual. */
|
|
50
|
+
export type CapabilityMap = { readonly [N in CapabilityName]?: CapabilityConfig };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Where content may load from and talk to, as exact origins (`https://cdn.example.com`, or
|
|
54
|
+
* `https://*.example.com` for every subdomain). Empty everywhere is the default: egress closed.
|
|
55
|
+
* Capabilities add their own needs to these.
|
|
56
|
+
*/
|
|
57
|
+
export interface EgressConfig {
|
|
58
|
+
readonly connect?: readonly string[];
|
|
59
|
+
readonly script?: readonly string[];
|
|
60
|
+
readonly style?: readonly string[];
|
|
61
|
+
readonly img?: readonly string[];
|
|
62
|
+
readonly font?: readonly string[];
|
|
63
|
+
readonly media?: readonly string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ThemeConfig {
|
|
67
|
+
/**
|
|
68
|
+
* Every CSS variable the content may read, with its default per scheme. The shell injects
|
|
69
|
+
* exactly these names (the host supplies live values for them) and the manual teaches them.
|
|
70
|
+
*/
|
|
71
|
+
readonly variables: Readonly<Record<string, ThemeValue>>;
|
|
72
|
+
/** The window event the shell fires after the theme changed, for code that must redraw. */
|
|
73
|
+
readonly event: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface Limits {
|
|
77
|
+
/** The tallest the frame grows before its content scrolls inside it. */
|
|
78
|
+
readonly maxHeightPx: number;
|
|
79
|
+
/** The shortest the frame is once its content reported a size. */
|
|
80
|
+
readonly minHeightPx: number;
|
|
81
|
+
/** The largest content the manual allows (the host enforces it where it takes content in). */
|
|
82
|
+
readonly maxBytes: number;
|
|
83
|
+
/** The most frames of this kind a page runs at once (the React binding's live-frame cap). */
|
|
84
|
+
readonly maxLiveFrames: number;
|
|
85
|
+
/** Declined confirmations after which a display stops asking until it runs again. */
|
|
86
|
+
readonly declinesBeforeMute: number;
|
|
87
|
+
/** The longest prompt `sendPrompt` carries. */
|
|
88
|
+
readonly maxPromptChars: number;
|
|
89
|
+
/** The most errors one run of a display reports. */
|
|
90
|
+
readonly maxErrorReports: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Where the host shows the content, for the manual's size rules. */
|
|
94
|
+
export interface LayoutConfig {
|
|
95
|
+
/** The narrowest the frame ever is (a small phone). */
|
|
96
|
+
readonly minWidthPx: number;
|
|
97
|
+
/** A typical phone's width: what the content is designed for first. */
|
|
98
|
+
readonly narrowWidthPx: number;
|
|
99
|
+
/** The widest the frame is (a desktop column). */
|
|
100
|
+
readonly wideWidthPx: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface GuideConfig {
|
|
104
|
+
/** The host's tool that shows this content, as the manual names it (`show_widget`). */
|
|
105
|
+
readonly toolName?: string;
|
|
106
|
+
/** The host's tool that answers this manual, as the tool description names it. */
|
|
107
|
+
readonly guideToolName?: string;
|
|
108
|
+
/**
|
|
109
|
+
* The host's own topics, by name, appended to the manual when asked for (a host's file tool,
|
|
110
|
+
* say). They may not name a built-in topic. Write them about the host's tools, not the sandbox:
|
|
111
|
+
* the sandbox's rules are generated, so they always match it.
|
|
112
|
+
*/
|
|
113
|
+
readonly topics?: Readonly<Record<string, string>>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface EmbedderConfig {
|
|
117
|
+
/**
|
|
118
|
+
* Extra `frame-src` sources the embedding page allows, for frames it shows besides these:
|
|
119
|
+
* origins, or `'self'` if it frames its own pages (which lets content navigate there too).
|
|
120
|
+
*/
|
|
121
|
+
readonly frameSrc?: readonly string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface PortholeConfig {
|
|
125
|
+
readonly mode: DisplayMode;
|
|
126
|
+
readonly isolation: IsolationKind;
|
|
127
|
+
readonly capabilities: CapabilityMap;
|
|
128
|
+
readonly egress: EgressConfig;
|
|
129
|
+
readonly theme: ThemeConfig;
|
|
130
|
+
readonly limits: Limits;
|
|
131
|
+
readonly layout: LayoutConfig;
|
|
132
|
+
readonly guide: GuideConfig;
|
|
133
|
+
/**
|
|
134
|
+
* Where `domain` and `external` content is served from, as frame-src sources
|
|
135
|
+
* (`https://*.example-usercontent.com`; dev `http://*.localhost:8790`): the embedding page frames
|
|
136
|
+
* them, and the host takes messages only from a frame whose origin matches. Empty for `opaque`.
|
|
137
|
+
*/
|
|
138
|
+
readonly contentOrigins: readonly string[];
|
|
139
|
+
readonly embedder: EmbedderConfig;
|
|
140
|
+
}
|