/** * React entry point (`@guuey/mcp-apps-host/react`). * * `` is the one React-coupled surface — the root subpath stays * React-free (narrowing, rehydration, the relay, `attachViewHost` itself), * so server-side consumers never import React at all. The component is a * CONVENIENCE composition of the framework-agnostic primitive: iframe * creation + the sandbox invariant + the handshake + lifecycle. A host * that needs a different composition (a transcript renderer, a non-React * surface) uses `attachViewHost` directly. * * ## Sandbox posture (invariant, not preference) * * `sandbox="allow-scripts"` WITHOUT `allow-same-origin`: a `srcdoc` frame * inherits its embedder's origin, so granting both would run * agent-generated HTML AS the embedding page — reach into its DOM and its * signed-in session, an XSS by construction. Dropping `allow-same-origin` * puts the document in an opaque origin instead. Extra flags ride ON TOP * via {@link GuueyViewProps.dangerouslyAddSandboxFlags} — named the way it * is because every flag it adds widens what agent-generated HTML can do. * `allow="clipboard-write"` is delegated by default: generated views own * copy buttons, and without the delegation every one of them silently * no-ops inside the opaque origin. * * ## Who paints which state * * While `"negotiating"`, the component shows a small non-blocking status * line — never a bare blank frame (guuey#186 audit). On `"connected"` the * view owns its pixels (and its own failures) and the line disappears. On * `"no-handshake"` the meaning is channel-aware: a `"ggui"` shell always * negotiates, so silence is a boot failure and is labeled as one; an * `"inline"` card is arbitrary tenant HTML with no handshake obligation, * so the status line simply retires and the document stands as rendered. */ import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { attachViewHost, viewDocumentHtml, type AttachViewHostConfig } from "./view-host.js"; import { attachSandboxPageDelivery } from "./sandbox-page.js"; import { decideAutoResize, initialAutoResizeState, type AutoResizeState } from "./auto-resize.js"; import type { ViewCspDiagnosis, ViewHostPhase } from "./view-host-protocol.js"; import type { ResolvedViewMount } from "./card-mount.js"; import { defaultViewHostStrings, type ViewHostStrings } from "./view-strings.js"; export { attachViewHost, viewDocumentHtml, MESSAGE_ANSWER_CAP_MS } from "./view-host.js"; export type { AttachViewHostConfig, UserMessageDelivery, ViewCspEvents, ViewFrameLike, ViewHostEvents } from "./view-host.js"; export { attachSandboxPageDelivery, isSandboxProxyReady, SANDBOX_PROXY_READY_METHOD, SANDBOX_RESOURCE_READY_METHOD, type SandboxPageDeliveryConfig, } from "./sandbox-page.js"; export type { ViewCspDiagnosis, ViewCspOrigins, ViewHostPhase } from "./view-host-protocol.js"; export type { ResolvedViewMount, ViewMount, ViewMountChannel } from "./card-mount.js"; export { defaultViewHostStrings } from "./view-strings.js"; export type { ViewHostStrings } from "./view-strings.js"; /** Accessible name for a mounted view when the caller has nothing better. */ const DEFAULT_TITLE = "Generated view"; export interface GuueyViewProps extends Pick< AttachViewHostConfig, | "hostCapabilities" | "hostInfo" | "hostContext" | "onCallTool" | "onUpdateModelContext" | "onUserMessage" | "onOpenLink" | "onReadResource" | "onSizeChanged" | "onDismiss" | "negotiationTimeoutMs" | "cspOrigins" | "onCspDiagnosis" > { /** The resolved card to mount (see `toolResultViewMount`/`resolveViewMount`). */ mount: ResolvedViewMount; /** * Opt into the TWO-ORIGIN mount: instead of `srcdoc`, the frame loads * this host-served sandbox page (guuey's `/mcp-app-sandbox` pattern — the * caller builds the full URL, channel/app query included) and the * document is delivered over the page's relay protocol * (`attachSandboxPageDelivery`). Why: a `srcdoc` frame INHERITS the * embedder's CSP, so its egress confinement is whatever the page happens * to carry; the sandbox page is served WITH the per-request CSP that * confines the mount — and the untrusted document lands in the page's * own inner opaque frame, never in this one. In this mode the frame's * `sandbox` gains `allow-same-origin` — REQUIRED and safe: the frame * holds the cross-origin RELAY PAGE (which must run as its real origin * for its CSP + referrer checks to mean anything), never agent HTML. * The page must be a genuinely different origin; a same-origin URL is * refused with a labeled state, never mounted. * * `null` (as opposed to absent) means the two-origin mount is REQUIRED * by the embedder's posture but no page is configured — the mount is * refused with the same labeled state, and srcdoc is NEVER fallen back * to (falling back would silently trade the caller's egress confinement * for the page's CSP; the widget/Studio convergence posture). */ sandboxPageUrl?: string | null; /** * Apply the view's own size reports (`ui/notifications/size-changed` — * spec surface) to the frame: a reported HEIGHT becomes the frame's * height; width stays the container's (a transcript column owns its * width). Default OFF — the primitive changes nothing for existing * hosts; a caller's {@link AttachViewHostConfig.onSizeChanged} observer * fires either way. */ autoResize?: boolean; /** * The tallest frame `autoResize` may apply, in px (guuey#992). Default: * the host window's `innerHeight` at the time of the report — a card * taller than the viewport scrolls inside its frame. Reports are also * judged by the echo detector in `auto-resize.ts`: a view whose body is * viewport-bound measures the frame's own height back to the host, and a * host that applied every report verbatim grew a frame to 86,816 px. */ maxHeight?: number; /** * Sandbox flags appended to the safe default (`allow-scripts`). Every * entry widens what agent-generated HTML may do — `allow-same-origin` * in particular hands it the embedder's origin. Prefer leaving unset. * In `sandboxPageUrl` mode these forward to the INNER frame via the * page's relay (which strips `allow-same-origin` regardless). */ dangerouslyAddSandboxFlags?: string[]; /** Permissions-Policy delegation for the frame. Default `clipboard-write`. */ allow?: string; /** Accessible frame title. Default "Generated view". */ title?: string; className?: string; style?: CSSProperties; /** Observe the negotiation phase (the same states the default UI labels). */ onPhaseChange?: (phase: ViewHostPhase) => void; /** * Replace the default status/failure line for a phase. Return `null` for * "render nothing". The default: a quiet loading line while * `"negotiating"`; a labeled failure for `"no-handshake"` on the * `"ggui"` channel; nothing once `"connected"` (the view owns its * pixels) and nothing for a silent `"inline"` card. The second argument * is the CSP diagnosis when the tripwire caught one (see * {@link AttachViewHostConfig.cspOrigins}) — the default label folds it * in; a custom renderer decides how to show it. * * To change only the WORDS, prefer {@link GuueyViewProps.strings}: this * hook makes a host reimplement the CSP/channel branching to do it. */ renderStatus?: (phase: ViewHostPhase, diagnosis?: ViewCspDiagnosis) => ReactNode; /** * Override the default status line's copy (guuey#1325). Merged over * {@link defaultViewHostStrings}, so a partial override is enough. */ strings?: Partial; } const statusLineStyle: CSSProperties = { position: "absolute", insetInlineStart: 8, insetBlockEnd: 8, margin: 0, padding: "2px 8px", fontSize: 12, lineHeight: "18px", opacity: 0.65, pointerEvents: "none", }; function defaultStatus( phase: ViewHostPhase, channel: ResolvedViewMount["channel"], diagnosis: ViewCspDiagnosis | undefined, strings: ViewHostStrings, ): ReactNode { if (phase === "negotiating") { // guuey#1279/#1325: the copy says what the PERSON is waiting for. The // phase name is machinery and never reaches the frame. return

{strings.viewLoading}

; } // A CSP diagnosis (guuey#235) is the WHY behind a silent frame — on any // channel: a blocked runtime bundle never gets to negotiate, so the // tripwire's verdict outranks the channel heuristic below. Actionable // over accurate-but-mute: name the blocked URI and the allowance. if (phase === "no-handshake" && diagnosis !== undefined) { return (

{diagnosis.message}

); } if (phase === "no-handshake" && channel === "ggui") { // A ggui shell negotiates unconditionally before painting, so silence // here is a boot failure with no other author — label it (role=alert // so it is announced, not just drawn). The label states the OUTCOME the // person sees; the handshake that did not happen is machinery. return (

{strings.viewBootFailure}

); } return null; } /** * Mount a resolved view and play the MCP Apps Host for it. See the module * docblock for the sandbox and state contracts. */ export function GuueyView(props: GuueyViewProps): ReactNode { const { mount, sandboxPageUrl, autoResize, maxHeight, dangerouslyAddSandboxFlags, allow, title, className, style, onPhaseChange, renderStatus, strings: stringOverrides, ...hostConfig } = props; // Read only during render (no effect depends on it), so a plain spread is // correct — no identity churn to guard against. const viewStrings: ViewHostStrings = { ...defaultViewHostStrings, ...stringOverrides }; const frameRef = useRef(null); const [phase, setPhase] = useState("negotiating"); // The CSP tripwire's verdict for THIS document, if any (guuey#235). const [diagnosis, setDiagnosis] = useState(undefined); // The view's own size report, applied only under `autoResize` — and only // as the sizing policy allows (ceiling + echo detector, guuey#992). const [reportedHeight, setReportedHeight] = useState(undefined); const sizing = useRef(initialAutoResizeState()); const html = viewDocumentHtml(mount.resource); // Vet the sandbox page once per URL. Same-origin is REFUSED (the widget's // ResourceMount precedent, generalized): the whole point of the page is // being a different origin — same-origin would hand the relay page (and // through `allow-same-origin`, everything it can reach) the embedder's // own origin. `null` — page mode required but unconfigured — refuses the // same way: srcdoc is never a silent fallback for a confinement posture. const sandboxPage: URL | "refused" | undefined = useMemo(() => { if (sandboxPageUrl === undefined) return undefined; if (sandboxPageUrl === null) return "refused"; let url: URL; try { url = new URL(sandboxPageUrl); } catch { return "refused"; } if (typeof window !== "undefined" && url.origin === window.location.origin) return "refused"; return url; }, [sandboxPageUrl]); const page = sandboxPage instanceof URL ? sandboxPage : undefined; // The attachment is keyed to the mounted DOCUMENT, not to every render's // fresh callback identities — host config rides a ref so the effect's // dependency list is honestly just the document identity. const latest = useRef({ hostConfig, onPhaseChange, dangerouslyAddSandboxFlags, autoResize, maxHeight }); latest.current = { hostConfig, onPhaseChange, dangerouslyAddSandboxFlags, autoResize, maxHeight }; useEffect(() => { // Keyed to the same identity the frame is (the resource uri): a new // document boots fresh, and the previous negotiation's phase must not // paper over it — nor must the previous document's reported size. setPhase("negotiating"); setDiagnosis(undefined); setReportedHeight(undefined); sizing.current = initialAutoResizeState(); const frame = frameRef.current; if (frame === null || html === undefined) return; if (sandboxPageUrl !== undefined && page === undefined) return; // refused config — nothing mounts const resourceUri = mount.resource.uri; // guuey#312: the resolved mount's DECLARED per-resource CSP // (`_meta.ui.csp`, spec-schema-validated at the reader) is the CSP // tripwire's default filter — the declaration finally doing host-side // work. An explicit `cspOrigins` prop always wins; both absent keeps // the tripwire inert exactly as before. const cspOrigins = latest.current.hostConfig.cspOrigins ?? mount.csp; const detachHost = attachViewHost(frame, { ...latest.current.hostConfig, ...(cspOrigins !== undefined ? { cspOrigins } : {}), resourceUri, onPhaseChange: (next) => { setPhase(next); latest.current.onPhaseChange?.(next); }, onCspDiagnosis: (found) => { setDiagnosis(found); latest.current.hostConfig.onCspDiagnosis?.(found); }, onSizeChanged: (size) => { if (latest.current.autoResize === true && size.height !== undefined) { const ceiling = latest.current.maxHeight ?? (typeof window !== "undefined" ? window.innerHeight : undefined); const decision = decideAutoResize(sizing.current, size.height, Date.now(), ceiling); sizing.current = decision.state; if (decision.apply !== undefined) setReportedHeight(decision.apply); } latest.current.hostConfig.onSizeChanged?.(size); }, }); if (page === undefined) return detachHost; // Two-origin mode: the page announces readiness, the document is // delivered over its relay (re-delivered on a reload's re-announce), // and the view-host handshake crosses the same relay transparently. const flags = latest.current.dangerouslyAddSandboxFlags; const detachDelivery = attachSandboxPageDelivery(frame, { pageOrigin: page.origin, html, ...(flags !== undefined && flags.length > 0 ? { sandbox: ["allow-scripts", ...flags].join(" ") } : {}), }); return () => { detachDelivery(); detachHost(); }; }, [mount.resource.uri, html, sandboxPageUrl, page]); if (html === undefined) { // A resolved mount with no document is producer-side breakage; an // empty frame would be a lie. Label it, in the same voice as the // no-handshake state. return (

This view could not be displayed — its resource carries no document.

); } if (sandboxPageUrl !== undefined && page === undefined) { // A missing (null), malformed, or SAME-ORIGIN sandbox page is a // configuration state, not a property of the card — refused, labeled, // never mounted, and never silently downgraded to srcdoc. The copy // names the configuration cause (an operator can act on it) without // ever printing the offending URL. return (

{sandboxPageUrl === null ? "Interactive view unavailable — no sandbox page is configured." : "Interactive view unavailable — the sandbox page is not usable from this origin."}

); } return (