import { IconDots, IconExternalLink, IconLayoutSidebarRightCollapse, IconTrash, } from "@tabler/icons-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router"; import { extensionPath } from "../../extensions/path.js"; import { THEME_VAR_NAMES } from "../../extensions/theme.js"; import { SESSION_REPLAY_IFRAME_ATTRIBUTE } from "../../session-replay-iframe-protocol.js"; import { sendToAgentChat } from "../agent-chat.js"; import { agentNativePath } from "../api-path.js"; import { useAppearance } from "../appearance.js"; import { Popover, PopoverContent, PopoverTrigger, } from "../components/ui/popover.js"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../components/ui/tooltip.js"; import { useT } from "../i18n.js"; import { deleteOrHideExtension, invalidateExtensionRemoval, } from "./delete-extension.js"; import { extensionLoadError, extensionLoadErrorStatus, shouldRetryExtensionLoad, } from "./extension-load-error.js"; import { isAllowedExtensionPath, sanitizeExtensionRequestOptions, checkBridgePolicy, type BridgePolicyContext, type ExtensionBridgeRole, } from "./iframe-bridge.js"; import { normalizeAgentNativeExtensionSandbox } from "./portable-extension.js"; interface Extension { id: string; name: string; description?: string; content?: string; updatedAt?: string; canDelete?: boolean; source?: { mode?: "database" | "local-files"; permissions?: BridgePolicyContext["permissions"]; }; } const EXTENSION_IFRAME_SANDBOX = normalizeAgentNativeExtensionSandbox(undefined); // Read the host app's *actual* computed theme values for the shared token set // (THEME_VAR_NAMES). The iframe ships with a generic baked palette // (getThemeVars); syncing the live values keeps embedded extensions visually // identical to the surrounding app even when a template overrides the default // palette (e.g. analytics uses a neutral-gray dark theme, not near-black). function readHostThemeVars(): Record { if (typeof document === "undefined") return {}; const computed = getComputedStyle(document.documentElement); const vars: Record = {}; for (const name of THEME_VAR_NAMES) { const value = computed.getPropertyValue(name).trim(); if (value) vars[name] = value; } return vars; } function serializeChatValue(value: unknown): string | undefined { if (value === undefined || value === null) return undefined; if (typeof value === "string") return value; try { return JSON.stringify(value); } catch { return String(value); } } /** * Slot contexts (e.g. Design's `DesignExtensionSlotContext`) commonly carry * live callback functions alongside plain data — the host component uses * those callbacks itself, but `window.postMessage` uses the structured clone * algorithm, which throws a `DataCloneError` on any function-valued property * (see MDN's postMessage docs). Round-tripping through JSON drops functions * (and other non-cloneable values like symbols) the same way * `JSON.stringify` already silently omits them, producing a payload that's * safe to post. Exported so a test can verify functions never reach the * iframe instead of only reading the code. */ export function sanitizeSlotContextForPostMessage( context: Record | null | undefined, ): Record { try { return JSON.parse(JSON.stringify(context ?? {})); } catch { // Circular reference or other non-serializable shape — fail safe to an // empty context rather than letting postMessage throw and skip every // other message this handler sends in the same tick (theme update, // ready signal). return {}; } } export interface EmbeddedExtensionProps { extensionId: string; /** Slot identifier passed via the iframe URL so the extension runtime knows it's * embedded and enables auto-resize. */ slotId: string; /** Object pushed into the extension as `window.slotContext`. Re-posted whenever * the host re-renders with a new context. */ context?: Record | null; /** Optional className applied to the iframe container. */ className?: string; /** Initial iframe height before content reports a real height. */ initialHeight?: number; /** Fires once when the embedded iframe first signals content readiness — its * first height report, or iframe load as a fallback. Hosts that gate on * content paint (e.g. dashboard report screenshots) use this. */ onReady?: () => void; /** Fires when the extension can't be loaded for this viewer (e.g. 403/404 — * the extension isn't shared with them or no longer exists). Hosts can use * this to render an explanatory fallback instead of a blank panel. By default * the component renders nothing on failure (slot-style silent skip). */ onUnavailable?: (status?: number) => void; } /** * Renders a extension inline as a small auto-sized iframe — for use inside an * ``. Different from `` (which is full-page with a * toolbar): no header, sized to content, receives a `slotContext`. */ export function EmbeddedExtension({ extensionId, slotId, context, className, initialHeight = 80, onReady, onUnavailable, }: EmbeddedExtensionProps) { const iframeRef = useRef(null); // Latch the readiness signal so onReady fires at most once per iframe // instance. Reset when the iframe is recreated (extensionId/updatedAt change). const onReadyRef = useRef(onReady); onReadyRef.current = onReady; const readyFiredRef = useRef(false); const fireReady = () => { if (readyFiredRef.current) return; readyFiredRef.current = true; onReadyRef.current?.(); }; const [height, setHeight] = useState(initialHeight); const [isDark, setIsDark] = useState( () => typeof document !== "undefined" && document.documentElement.classList.contains("dark"), ); // (audit H4) Mirror ExtensionViewer's role-aware gating; deny-by-default until // the iframe's render binding announcement arrives. const bridgeContextRef = useRef({ role: "viewer", isAuthor: false, }); // (audit H4) Latch the render binding once per iframe instance. The shell // posts the server-resolved binding BEFORE user content runs; any later // agent-native-extension-binding message is attacker-controllable (it // originates inside the same sandboxed realm as user code) and must be // ignored so a viewer cannot self-escalate to owner. const bindingLatchedRef = useRef(false); const extensionRef = useRef(null as null | Extension); useEffect(() => { setIsDark(document.documentElement.classList.contains("dark")); const observer = new MutationObserver(() => { setIsDark(document.documentElement.classList.contains("dark")); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"], }); return () => observer.disconnect(); }, []); const { data: extension, isFetching, isLoading, isError, error, } = useQuery({ queryKey: ["extension", extensionId], queryFn: async () => { const res = await fetch( agentNativePath(`/_agent-native/extensions/${extensionId}`), ); if (res.status === 404) { throw extensionLoadError(404, "Extension not found"); } if (res.status === 403) { throw extensionLoadError(403, "Extension access denied"); } if (!res.ok) { throw extensionLoadError(res.status, "Failed to fetch extension"); } return res.json(); }, retry: shouldRetryExtensionLoad, retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 4000), }); extensionRef.current = extension ?? null; // Notify the host once when the extension can't be loaded for this viewer so // it can show a fallback instead of a blank panel. const onUnavailableRef = useRef(onUnavailable); onUnavailableRef.current = onUnavailable; const unavailableFiredRef = useRef(false); useEffect(() => { unavailableFiredRef.current = false; }, [extensionId]); useEffect(() => { if (isError && !isFetching && !unavailableFiredRef.current) { unavailableFiredRef.current = true; onUnavailableRef.current?.(extensionLoadErrorStatus(error)); } }, [isError, isFetching, error]); // Initial dark state is baked into the URL on first load only; subsequent // theme toggles update the iframe's via postMessage so // the user's interaction state inside the extension survives the toggle. const initialDarkRef = useRef(isDark); const iframeSrc = useMemo(() => { const v = encodeURIComponent(extension?.updatedAt ?? ""); return agentNativePath( `/_agent-native/extensions/${extensionId}/render?slot=${encodeURIComponent(slotId)}&dark=${initialDarkRef.current}&v=${v}`, ); }, [extensionId, slotId, extension?.updatedAt]); // Reset role + binding latch to deny-by-default whenever the iframe is // recreated (its key changes). The new render's first binding announcement // re-establishes the role. useEffect(() => { bridgeContextRef.current = { role: "viewer", isAuthor: false }; bindingLatchedRef.current = false; readyFiredRef.current = false; }, [extensionId, extension?.updatedAt]); const appearance = useAppearance(); useEffect(() => { const win = iframeRef.current?.contentWindow; if (!win) return; win.postMessage( { type: "agent-native-theme-update", isDark, vars: readHostThemeVars() }, "*", ); }, [isDark, appearance]); // Forward slot context whenever it changes. The iframe's own load handler // posts the initial value once it's ready; this effect handles updates. const contextJson = JSON.stringify(context ?? {}); useEffect(() => { const win = iframeRef.current?.contentWindow; if (!win) return; // Post the JSON-round-tripped context, not the raw `context` object — // slot contexts (e.g. Design's DesignExtensionSlotContext) carry live // callback functions the host uses internally, and postMessage's // structured clone throws a DataCloneError on any function-valued // property. See sanitizeSlotContextForPostMessage's docblock. win.postMessage( { type: "agent-native-slot-context", context: sanitizeSlotContextForPostMessage(context), }, "*", ); }, [contextJson]); // Bridge extension requests + height reports. useEffect(() => { const handleMessage = async (event: MessageEvent) => { if (event.source !== iframeRef.current?.contentWindow) return; const message = event.data; if (!message || typeof message !== "object") return; if (message.type === "agent-native-extension-binding") { // Only the FIRST announcement (sent by the shell before user content // runs) is trusted. Ignore re-announcements — a malicious extension // body could otherwise postMessage a forged owner binding to escalate. if (bindingLatchedRef.current) return; bindingLatchedRef.current = true; const binding = (message as any).binding ?? {}; const role: ExtensionBridgeRole = binding.role === "owner" || binding.role === "admin" || binding.role === "editor" || binding.role === "viewer" ? binding.role : "viewer"; bridgeContextRef.current = { role, isAuthor: !!binding.isAuthor, source: binding.source === "local-files" ? "local-files" : "database", permissions: binding && typeof binding.permissions === "object" ? binding.permissions : undefined, }; return; } if (message.type === "agent-native-extension-resize") { const h = Number(message.height); if (Number.isFinite(h) && h > 0) { setHeight(Math.ceil(h)); // First laid-out height means the content has painted. fireReady(); } return; } if (message.type === "agent-native-send-to-chat") { const text = serializeChatValue((message as any).message); if (!text?.trim()) return; sendToAgentChat({ message: text, context: serializeChatValue((message as any).context), submit: (message as any).submit !== false, openSidebar: (message as any).openSidebar !== false, }); return; } if (message.type === "agent-native-extension-error-fix") { const name = extensionRef.current?.name ?? extensionId; const errors: string[] = Array.isArray((message as any).errors) ? (message as any).errors : []; const errorDetails: Array<{ message: string; stack: string }> = Array.isArray((message as any).errorDetails) ? (message as any).errorDetails : []; const consoleLogs: Array<{ level: string; message: string }> = Array.isArray((message as any).consoleLogs) ? (message as any).consoleLogs : []; const networkLogs: Array<{ path: string; method: string; ok?: boolean; status?: number; error?: string; }> = Array.isArray((message as any).networkLogs) ? (message as any).networkLogs : []; const detailedTrace = errorDetails .filter((e) => e && typeof e === "object") .map((e) => { const msg = typeof e.message === "string" ? e.message : String(e.message); return typeof e.stack === "string" ? `${msg}\n${e.stack}` : msg; }) .join("\n\n"); // Force a fresh read from the server, same as ExtensionViewer's fix // flow — the query cache may hold the agent's previous (broken) turn. let freshContent: string | undefined; try { const res = await fetch( agentNativePath(`/_agent-native/extensions/${extensionId}`), { cache: "no-store" }, ); if (res.ok) { const fresh = (await res.json()) as Extension; freshContent = typeof fresh?.content === "string" ? fresh.content : undefined; } } catch { // Fall through with no snapshot — agent can still re-read via get-extension. } const contextParts = [ `The user is viewing a dashboard panel embedding extension "${name}" (id: ${extensionId}, slot: ${slotId}) and there are runtime errors that need fixing.`, `\nFull error details:\n${detailedTrace}`, ]; if (consoleLogs.length > 0) { const consoleStr = consoleLogs .map((l) => `[${l.level}] ${l.message}`) .join("\n"); contextParts.push(`\nRecent console output:\n${consoleStr}`); } if (networkLogs.length > 0) { const netStr = networkLogs .map( (l) => `${l.method} ${l.path} → ${l.ok ? l.status : "FAILED: " + (l.error || l.status)}`, ) .join("\n"); contextParts.push(`\nRecent network requests:\n${netStr}`); } if (freshContent) { contextParts.push( `\nCurrent extension content (just re-read from the database — this is the authoritative source, not anything you may have written in a previous turn):\n\`\`\`html\n${freshContent}\n\`\`\``, ); } sendToAgentChat({ message: `Fix runtime errors in extension "${name}" (id: ${extensionId}), embedded as a dashboard panel. The content snapshot below was just re-read from the database — treat it as authoritative and ignore any prior version you may have generated in this chat. If in doubt, call get-extension first.\n\nErrors:\n${errors.join("\n")}`, context: contextParts.join("\n"), submit: true, openSidebar: true, }); return; } if (message.type !== "agent-native-extension-request") return; const requestId = String(message.requestId ?? ""); const path = String(message.path ?? ""); const respond = (payload: Record) => { iframeRef.current?.contentWindow?.postMessage( { type: "agent-native-extension-response", requestId, ...payload }, "*", ); }; if (!requestId || !isAllowedExtensionPath(path, extensionId)) { respond({ error: "Extension request path is not allowed" }); return; } try { const options = sanitizeExtensionRequestOptions(message.options); // (audit H4) Role-aware gating: viewer-shared extensions can read but not // write. The bridge policy is decided here in the parent before the // request leaves; the server enforces a second layer. const policy = checkBridgePolicy(path, options.method ?? "GET", { ...bridgeContextRef.current, extensionId, }); if (!policy.ok) { respond({ response: { ok: false, status: 403, statusText: "Forbidden", body: { error: policy.error }, }, }); return; } // (audit H5) Same extension-bridge tagging as . action-routes // uses these headers to enforce per-action `toolCallable` opt-in. const finalHeaders = new Headers(options.headers ?? undefined); finalHeaders.set("X-Agent-Native-Extension-Bridge", "1"); finalHeaders.set("X-Agent-Native-Extension-Id", extensionId); finalHeaders.set("X-Agent-Native-Tool-Bridge", "1"); finalHeaders.set("X-Agent-Native-Tool-Id", extensionId); const res = await fetch(agentNativePath(path), { ...options, headers: finalHeaders, credentials: "same-origin", }); const text = await res.text(); let body: unknown = text; if (text) { try { body = JSON.parse(text); } catch { body = text; } } respond({ response: { ok: res.ok, status: res.status, statusText: res.statusText, body, }, }); } catch (err: any) { respond({ error: err?.message ?? "Extension host request failed" }); } }; window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); }, [extensionId, slotId]); if (!extension) { if (!isLoading && !isFetching) return null; return (
); } return (