import { PANEL_DESIGN_TOKEN_ALLOWLIST } from './plugins/plugin-panel-shell' /** * The document wrapped around an agent-written chat visual before it enters its sandboxed frame. * Same placement rule as the plugin panel shell: the CSP meta parses before any visual markup, so * nothing later in the document can loosen it, and the visual's own / merge into ours. * * Network: scripts, styles, fonts and images may load from a pinned set of public CDNs; nothing may * fetch, open sockets, frame, or submit. A page can still encode data in a request to an allowed * CDN — an accepted, disclosed risk, not a guarantee that no data leaves. * * Electron-free string builder: desktop, the web client and mobile build the same document. */ export const NATIVE_CHAT_VISUAL_CDN_ORIGINS = [ 'https://cdn.jsdelivr.net', 'https://unpkg.com', 'https://cdnjs.cloudflare.com', 'https://esm.sh', 'https://fonts.googleapis.com', 'https://fonts.gstatic.com' ] as const const ASSET_SOURCES = `'unsafe-inline' data: blob: ${NATIVE_CHAT_VISUAL_CDN_ORIGINS.join(' ')}` const MEDIA_SOURCES = `data: blob: ${NATIVE_CHAT_VISUAL_CDN_ORIGINS.join(' ')}` export const NATIVE_CHAT_VISUAL_CSP = [ "default-src 'none'", `script-src ${ASSET_SOURCES}`, `style-src ${ASSET_SOURCES}`, `font-src ${MEDIA_SOURCES}`, `img-src ${MEDIA_SOURCES}`, "connect-src 'none'", "frame-src 'none'", "child-src 'none'", "worker-src 'none'", "object-src 'none'", "media-src 'none'", "manifest-src 'none'", "form-action 'none'", "base-uri 'none'" ].join('; ') /** Frame names carry this prefix so the main process registers them before content runs. */ export const NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX = 'orca-chat-visual:' export const NATIVE_CHAT_VISUAL_SIZE_TYPE = 'orca-visual-size' export const NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE = 'orca-visual-open-link' export const NATIVE_CHAT_VISUAL_THEME_TYPE = 'orca-visual-theme' export const NATIVE_CHAT_VISUAL_MIN_HEIGHT = 80 export const NATIVE_CHAT_VISUAL_MAX_HEIGHT = 2000 const MAX_LINK_LENGTH = 2048 /** Theme variables a visual may style against: the plugin panel set plus the chart series. */ export const NATIVE_CHAT_VISUAL_THEME_TOKENS = [ ...PANEL_DESIGN_TOKEN_ALLOWLIST, '--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5', '--font-sans', '--font-mono' ] as const export type NativeChatVisualTheme = { colorScheme: 'light' | 'dark' tokens: Readonly> } const TOKEN_NAME_PATTERN = /^--[a-z0-9-]+$/ /** Token values land inside a ${args.html}` } export type NativeChatVisualFrameMessage = | { kind: 'size'; height: number } | { kind: 'open-link'; url: string } /** A message from a visual's shell, validated; null for anything else. */ export function readNativeChatVisualFrameMessage( data: unknown, channel: string ): NativeChatVisualFrameMessage | null { if (typeof data !== 'object' || data === null) { return null } if (!('channel' in data) || data.channel !== channel || !('type' in data)) { return null } if (data.type === NATIVE_CHAT_VISUAL_SIZE_TYPE) { const height = 'height' in data ? data.height : undefined return typeof height === 'number' && Number.isFinite(height) && height > 0 ? { kind: 'size', height } : null } if (data.type === NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE) { const url = 'url' in data ? data.url : undefined if (typeof url !== 'string' || url.length > MAX_LINK_LENGTH) { return null } try { const parsed = new URL(url) return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? { kind: 'open-link', url: parsed.href } : null } catch { return null } } return null } export function clampNativeChatVisualHeight(height: number): number { return Math.min( NATIVE_CHAT_VISUAL_MAX_HEIGHT, Math.max(NATIVE_CHAT_VISUAL_MIN_HEIGHT, Math.round(height)) ) } export function nativeChatVisualThemeMessage( theme: NativeChatVisualTheme, channel: string ): { type: string; channel: string; css: string; colorScheme: 'light' | 'dark' } { return { type: NATIVE_CHAT_VISUAL_THEME_TYPE, channel, css: nativeChatVisualThemeCss(theme), colorScheme: theme.colorScheme } }