mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
* 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.
301 lines
12 KiB
TypeScript
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
|
|
}
|
|
}
|