@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
188 lines • 8.87 kB
JavaScript
import { jsonByteLength, sandboxAction, validateUiActionContext, } from '../../genui/actions.js';
import { SANDBOX_DEFAULTS, SANDBOX_MESSAGE } from '../../genui/sandbox.js';
import { hostThemeCss, tailwindThemeCss } from '../../genui/sandbox-theme.js';
/**
* The only URLs the WebView may be at: the inline document (`source={{ html }}` loads as
* `about:blank`; `about:srcdoc` for engines that name it so), and in-page `#fragment` jumps.
*/
const INLINE_DOCUMENT = /^about:(blank|srcdoc)(#.*)?$/i;
/** Whether a URL is the inline sandbox document itself — everything else is a navigation away. */
export function isInlineDocumentUrl(url) {
return typeof url === 'string' && INLINE_DOCUMENT.test(url);
}
/**
* `onShouldStartLoadWithRequest`: the inline document loads, nothing else does — no link, no
* `location = …`, no `window.open`, no `tel:` / `mailto:` / app scheme. (Used with an
* `originWhitelist` that lets every URL through to it: `react-native-webview` hands a URL the
* whitelist refuses to `Linking.openURL` — the system browser or another app — instead of dropping it.)
*/
export function shouldStartSandboxLoad(request) {
return isInlineDocumentUrl(request.url);
}
/** Room for the envelope (`type`, `token`, keys) around a payload in one bridge message. */
export const NATIVE_SANDBOX_ENVELOPE_CHARS = 1024;
/** The frame → host message types a host acts on. */
const FRAME_TYPES = new Set([
SANDBOX_MESSAGE.ready,
SANDBOX_MESSAGE.resize,
SANDBOX_MESSAGE.send,
SANDBOX_MESSAGE.error,
]);
/**
* Read one `onMessage` string from the WebView. Its length is capped BEFORE it is parsed (the cap
* is the payload limit plus the envelope: a payload within the limit always fits, as its JSON has at
* most as many UTF-16 units as UTF-8 bytes); only a JSON object carrying `token` and a frame message
* type gets through. A send too long to parse is recognised by its envelope's exact prefix — which
* the bridge writes first — so it is refused as `too-large` rather than dropped unseen.
*/
export function parseFrameMessage(raw, token, maxPayloadBytes) {
if (typeof raw !== 'string')
return { kind: 'drop' };
if (raw.length > maxPayloadBytes + NATIVE_SANDBOX_ENVELOPE_CHARS) {
const prefix = `{"type":${JSON.stringify(SANDBOX_MESSAGE.send)},"token":${JSON.stringify(token)},"payload":`;
return raw.startsWith(prefix)
? { kind: 'too-large', bytes: raw.length - prefix.length - 1 }
: { kind: 'drop' };
}
let data;
try {
data = JSON.parse(raw);
}
catch {
return { kind: 'drop' };
}
if (typeof data !== 'object' || data === null || Array.isArray(data))
return { kind: 'drop' };
const record = data;
if (typeof record.token !== 'string' || record.token !== token)
return { kind: 'drop' };
if (typeof record.type !== 'string' || !FRAME_TYPES.has(record.type))
return { kind: 'drop' };
return { kind: 'message', data: record };
}
/**
* The script `injectJavaScript` runs to hand the frame one host message: a `message` event whose
* `source` is the frame's own window — its `parent` (a top-level document is its own parent), the
* only source the bridge and the JSX runtime listen to. The message is JSON (as a JS literal; the
* line separators JSON leaves raw are escaped), never code.
*/
export function hostMessageScript(message) {
const literal = JSON.stringify(message)
.split(String.fromCharCode(0x2028))
.join('\\u2028')
.split(String.fromCharCode(0x2029))
.join('\\u2029');
return `(function(){try{window.dispatchEvent(new MessageEvent('message',{data:${literal},source:window}))}catch(e){}})();true;`;
}
/** The frame's height: what it measured, within 24 px and the policy's `maxHeight`. */
export function clampSandboxHeight(value, policy) {
const max = policy.maxHeight ?? SANDBOX_DEFAULTS.maxHeight;
return Math.max(24, Math.min(max, Math.ceil(value)));
}
const MODE_ATTRIBUTE = /^data-([\w-]*-)?(theme|mode|scheme)$/;
const VAR_NAME = /^--[A-Za-z0-9_-]+$/;
/** `vars` keyed `--name` (a bare `name` gets its dashes), invalid names and non-strings dropped. */
export function normalizeThemeVars(vars) {
const out = {};
for (const [key, value] of Object.entries(vars)) {
const name = key.startsWith('--') ? key : `--${key}`;
if (!VAR_NAME.test(name))
continue;
if (typeof value === 'number')
out[name] = String(value);
else if (typeof value === 'string' && value.trim() !== '')
out[name] = value.trim();
}
return out;
}
export function nativeThemeCss(theme) {
const vars = normalizeThemeVars(theme.vars ?? {});
return {
css: hostThemeCss(vars, theme.colorScheme !== undefined ? { colorScheme: theme.colorScheme } : {}),
dark: theme.dark === true,
attributes: Object.fromEntries(Object.entries(theme.attributes ?? {}).filter(([name, value]) => MODE_ATTRIBUTE.test(name) && typeof value === 'string')),
tailwind: tailwindThemeCss(vars),
};
}
/**
* A url the config names, made absolute against `baseUrl` (React Native has no page origin to
* resolve it against). Only `http:` / `https:` come out; anything else, or a relative url without
* an absolute `baseUrl`, is `undefined`.
*/
export function resolveSandboxUrl(url, baseUrl) {
const absolute = /^https?:\/\/[^/?#]+/i;
if (absolute.test(url))
return url;
if (baseUrl === undefined)
return undefined;
const base = absolute.exec(baseUrl);
if (base === null)
return undefined;
const origin = base[0];
if (url.startsWith('//')) {
const out = `${origin.slice(0, origin.indexOf(':'))}:${url}`;
return absolute.test(out) ? out : undefined;
}
if (url.startsWith('/'))
return `${origin}${url}`;
if (/^[a-z][a-z0-9+.-]*:/i.test(url))
return undefined;
const path = baseUrl.slice(origin.length).replace(/[?#].*$/, '');
const directory = path.endsWith('/') ? path : path.replace(/[^/]*$/, '') || '/';
return `${origin}${directory.startsWith('/') ? '' : '/'}${directory}${url}`;
}
/**
* Everything the web host checks an `agent.send` for, in its order: a preview's send declined, the
* count, the interval, JSON, the size, `schema`, `confirm`, a handler. The interval clock starts at
* a send that passed the size check; the count grows only for a delivered action.
*/
export async function checkSandboxSend(check) {
const { state, policy } = check;
if (check.incomplete)
return { ok: false, refusal: { reason: 'declined' } };
const maxSends = policy.maxSends ?? SANDBOX_DEFAULTS.maxSends;
if (state.count >= maxSends)
return { ok: false, refusal: { reason: 'too-many', limit: maxSends } };
const now = check.now ?? Date.now();
const interval = policy.minSendIntervalMs ?? SANDBOX_DEFAULTS.minSendIntervalMs;
if (state.last > 0 && now - state.last < interval) {
return { ok: false, refusal: { reason: 'too-soon', waitMs: interval - (now - state.last) } };
}
const bytes = jsonByteLength(check.payload);
if (bytes === undefined)
return { ok: false, refusal: { reason: 'not-json' } };
const limit = policy.maxPayloadBytes ?? SANDBOX_DEFAULTS.maxPayloadBytes;
if (bytes > limit)
return { ok: false, refusal: { reason: 'too-large', bytes, limit } };
state.last = now;
let action = sandboxAction(check.payload, {
...(check.componentId !== undefined ? { componentId: check.componentId } : {}),
...(check.title !== undefined ? { title: check.title } : {}),
});
if (check.schema !== undefined) {
const checked = await validateUiActionContext(action, check.schema);
if (!checked.ok)
return { ok: false, refusal: { reason: 'invalid', issues: checked.issues } };
action = { ...action, context: checked.context };
}
if (check.confirm !== undefined && !(await check.confirm(action))) {
return { ok: false, refusal: { reason: 'declined' } };
}
if (!check.hasHandler)
return { ok: false, refusal: { reason: 'no-handler' } };
state.count += 1;
return { ok: true, action };
}
/** A per-frame token. Not a secret from the frame's own code — it tells this frame's messages apart. */
export function sandboxToken() {
const bytes = new Uint8Array(16);
const crypto = globalThis
.crypto;
if (typeof crypto?.getRandomValues === 'function')
crypto.getRandomValues(bytes);
else
for (let index = 0; index < bytes.length; index++)
bytes[index] = Math.floor(Math.random() * 256);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
//# sourceMappingURL=sandbox-host.js.map