Files
orca/src/shared/native-chat-visual-shell.ts
T
Brennan Benson 7b91405547 Let in-page links switch sections inside a chat visual (#26729)
* Let in-page links switch sections inside a chat visual

* Make in-page links work in chat visuals on mobile and in WebKit

The first fix (an about:srcdoc base in the visual) only reached desktop
Chromium. On mobile, the page hosting the visual carried a policy with
base-uri 'none', and a srcdoc visual inherits its host's policy before
its own, so the base was refused and an in-page link still took the
visual off its page and showed it as failed. The mobile web shell sends
the same rule as a header. Separately, WebKit (iOS, and the web client
in Safari) fires the frame's load event for an in-page link inside a
sandboxed frame, and both hosts treated any second load as the visual
navigating away and retired it.

- The mobile host page and the mobile web shell header now allow only an
  about: base, so the shell's own base applies; the visual's own policy
  still refuses any base it declares.
- Instead of retiring a visual on its second load, the host now asks the
  visual's shell after every load. A later load must be answered by the
  same run of the shell within the plugin panel watchdog's 5 s deadline;
  no answer, or an answer from a new run (a reload), still retires it.
- Tests: unit tests for the policy, the answer reader and the frame's
  keep/retire rules, and a Chromium + WebKit test of both mobile surfaces
  that runs in the mobile web app CI job.

* Keep any answered later load, and say plainly what the check is for

The previous commit tagged each run of a visual's wrapper with a random
id so a reload would still retire it. The visual shares a window with
its wrapper, so it could read or set that id itself, and a reload only
reruns the same Orca-written document, which is not an escape. Drop it:
after any load past the first, the host asks once, and any answer on
this visual's channel within the wait keeps it; silence retires it.

- Comments now say the check is a tripwire for a frame gone blank or to
  another page; the frame guard (desktop), the host page's policy plus
  the app's load filter (mobile) and the sandbox are the boundaries.
- Visuals get their own 5 s wait instead of borrowing the plugin panel
  watchdog's, because a 1 s wait retired a busy visual in WebKit.
- Tests: a reload is now kept; going blank, or dropping the base and
  following a link, still retires. The CI routing for the shared wrapper
  is now pinned in the path-classification test.

* Load the visual links check's mobile modules only where they can build

The unit shards run every config/scripts test with only the root
dependencies installed. This check imported a mobile/ module at load,
and transforming it reads mobile/tsconfig.json, which extends a package
those shards do not have. The transform error could not be sent back to
the test worker under Bun, so the whole shard stopped with
"DataCloneError: The object can not be cloned".

Import the visual shell and the mobile host page inside the dependency
gate, as the session-dictation check already does, so the shards skip
the file without loading them. The mobile web app job, which installs
mobile dependencies, still runs it in full.
2026-10-09 10:07:59 -07:00

301 lines
12 KiB
TypeScript

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 <html>/<head> 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(' ')}`
// A srcdoc page otherwise resolves '#x' against Orca's own URL, so an in-page link would leave it.
// It precedes the CSP, whose `base-uri 'none'` then refuses any base the visual declares.
const INERT_BASE = '<base href="about:srcdoc">'
const POLICY_WITHOUT_BASE = [
"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'"
]
export const NATIVE_CHAT_VISUAL_CSP = [...POLICY_WITHOUT_BASE, "base-uri 'none'"].join('; ')
/**
* For a page that hosts the visual frame and has no URL of its own (mobile). The srcdoc child
* inherits this policy before its own meta parses, so it must admit the shell's `about:srcdoc` base
* (CSP cannot name that URL exactly); the child's own policy then refuses every other base.
*/
export const NATIVE_CHAT_VISUAL_HOST_CSP = [...POLICY_WITHOUT_BASE, 'base-uri about:'].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_PING_TYPE = 'orca-visual-ping'
export const NATIVE_CHAT_VISUAL_PONG_TYPE = 'orca-visual-pong'
/** Visuals' own wait for an answer: at 1 s, a busy visual in WebKit answered too late. */
export const NATIVE_CHAT_VISUAL_PONG_TIMEOUT_MS = 5_000
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<Record<string, string>>
}
const TOKEN_NAME_PATTERN = /^--[a-z0-9-]+$/
/** Token values land inside a <style> block, so structural characters are stripped. */
function sanitizeTokenValue(value: string): string {
return value.replaceAll(/[{}<>;\\]/g, '').trim()
}
export function nativeChatVisualThemeCss(theme: NativeChatVisualTheme): string {
const declarations = [`color-scheme:${theme.colorScheme === 'dark' ? 'dark' : 'light'}`]
for (const [name, value] of Object.entries(theme.tokens)) {
const clean = sanitizeTokenValue(value)
if (TOKEN_NAME_PATTERN.test(name) && clean.length > 0) {
declarations.push(`${name}:${clean}`)
}
}
return `:root{${declarations.join(';')}}`
}
// A visual sits in the reply on the thread's own background; its scrollbar stays hidden because the
// frame grows to fit it. The visual's own CSS comes later and wins.
const BASE_CSS =
'html{background:var(--background);color:var(--foreground);font-family:var(--font-sans);' +
'font-size:14px;line-height:1.5;scrollbar-width:none}html::-webkit-scrollbar{display:none}' +
'body{margin:0}code,pre,kbd,samp{font-family:var(--font-mono)}'
function bootstrapScript(channel: string): string {
const constants = JSON.stringify({
channel,
size: NATIVE_CHAT_VISUAL_SIZE_TYPE,
link: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE,
theme: NATIVE_CHAT_VISUAL_THEME_TYPE,
ping: NATIVE_CHAT_VISUAL_PING_TYPE,
pong: NATIVE_CHAT_VISUAL_PONG_TYPE
})
// Plain ES5 so it runs before, and independent of, anything the visual loads.
return `(function () {
'use strict'
var C = ${constants}
var host = window.parent
function send(message) {
message.channel = C.channel
try { host.postMessage(message, '*') } catch (_) {}
}
// Containment: a visual is a document, never a browsing context. The host also refuses navigation.
if (window.navigation && typeof window.navigation.addEventListener === 'function') {
window.navigation.addEventListener('navigate', function (event) {
if (!event.hashChange && event.cancelable) event.preventDefault()
})
}
try { Object.defineProperty(window, 'open', { value: function () { return null }, writable: false, configurable: false }) }
catch (_) { try { window.open = function () { return null } } catch (_) {} }
document.addEventListener('click', function (event) {
var node = event.target
while (node && node.nodeType !== 1) node = node.parentNode
var anchor = node && node.closest ? node.closest('a[href]') : null
if (!anchor) return
var href = anchor.getAttribute('href') || ''
if (href.charAt(0) === '#') return
event.preventDefault()
if (!event.isTrusted) return
var url
try { url = new URL(href, document.baseURI) } catch (_) { return }
if (url.protocol === 'http:' || url.protocol === 'https:') send({ type: C.link, url: url.href })
}, true)
document.addEventListener('submit', function (event) { event.preventDefault() }, true)
// Only the host window may restyle the visual, or ask whether this document still runs the shell.
var themeStyle = document.getElementById('orca-visual-theme')
window.addEventListener('message', function (event) {
var data = event.data
if (event.source !== host || !data || data.channel !== C.channel) return
if (data.type === C.ping) return send({ type: C.pong, id: data.id })
if (data.type !== C.theme || typeof data.css !== 'string' || !themeStyle) return
themeStyle.textContent = data.css
document.documentElement.classList.toggle('dark', data.colorScheme === 'dark')
})
// Height: report the document's own height whenever layout changes; the host fits the frame.
var reported = -1
var pending = false
function measure() {
pending = false
var height = document.documentElement.getBoundingClientRect().height
var body = document.body
// A page pinned to the frame (html/body height 100%) overflows its body instead of growing it;
// add only what overflows, so a padded full-height body does not grow the frame by itself.
if (body && body.scrollHeight > body.clientHeight) height += body.scrollHeight - body.clientHeight
height = Math.ceil(height)
if (height !== reported && height > 0) {
reported = height
send({ type: C.size, height: height })
}
}
function schedule() {
if (pending) return
pending = true
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(measure)
else setTimeout(measure, 16)
}
if (typeof ResizeObserver === 'function') {
var observer = new ResizeObserver(schedule)
observer.observe(document.documentElement)
document.addEventListener('DOMContentLoaded', function () { if (document.body) observer.observe(document.body) })
}
// Content that overflows a fixed-size body changes no observed box, so watch the content too.
if (typeof MutationObserver === 'function') {
document.addEventListener('DOMContentLoaded', function () {
if (document.body) new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true })
})
}
document.addEventListener('DOMContentLoaded', schedule)
window.addEventListener('load', schedule)
if (document.fonts && document.fonts.ready) document.fonts.ready.then(schedule)
})()`
}
/**
* The full srcdoc for one visual. `channel` is unique per built document and rides on every message
* the shell sends, so the host can tell this document's messages from anything else.
*/
export function buildNativeChatVisualDocument(args: {
html: string
channel: string
theme: NativeChatVisualTheme
}): string {
const scheme = args.theme.colorScheme === 'dark' ? 'dark' : 'light'
return `<!doctype html>
<html class="${scheme}">
<head>
<meta charset="utf-8">
${INERT_BASE}
<meta http-equiv="Content-Security-Policy" content="${NATIVE_CHAT_VISUAL_CSP}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style id="orca-visual-theme">${nativeChatVisualThemeCss(args.theme)}</style>
<style>${BASE_CSS}</style>
<script>${bootstrapScript(args.channel)}</script>
</head>
${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
}
/** The ping id a visual's shell answered on `channel`, or null for anything else. */
export function readNativeChatVisualPong(data: unknown, channel: string): number | null {
if (typeof data !== 'object' || data === null || !('channel' in data) || !('type' in data)) {
return null
}
if (data.channel !== channel || data.type !== NATIVE_CHAT_VISUAL_PONG_TYPE) {
return null
}
const id = 'id' in data ? data.id : undefined
return typeof id === 'number' && Number.isSafeInteger(id) ? id : 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 nativeChatVisualPingMessage(
id: number,
channel: string
): { type: string; channel: string; id: number } {
return { type: NATIVE_CHAT_VISUAL_PING_TYPE, channel, id }
}
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
}
}