@vendoai/stage
v0.1.0
Published
Sandboxed stage runtime and bridge for Vendo agent-generated UI.
Downloads
21
Maintainers
Readme
@vendoai/stage
Secure sandboxed-stage runtime and bridge for agent-generated UI. Provides the host-side sandbox lifecycle (createStage, connectStage) and the Vite build preset (vendoHostPreset) that host bundles use.
Security model
The stage runs in an opaque-origin <iframe sandbox="allow-scripts"> using srcdoc — no allow-same-origin, so the sandbox cannot access host storage, cookies, or the DOM outside the frame. A strict CSP is set inline:
default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline';
img-src data:; font-src data:; connect-src 'none'Key invariants:
- Code crosses host → sandbox as data. The bundle is fetched by the host and passed as text; the sandbox creates a
blob:URL andimport()s it. No network access from inside the sandbox (egress jail:connect-src 'none'). - Sandbox → host actions only through the audited chokepoint. The sandbox calls
window.__vendoDispatch(descriptor, originNodeId), which serializes to atools/callpostMessage. The host validates, audits, and returns anActionResult. - Bundle, theme tokens, and state projection are data. They cross the postMessage boundary as structured-clone-safe values, not code.
Threat model & v1 limitations
These are deliberate v1 boundaries, not bugs. The follow-up items are tracked under F2/F3.
- Capability tokens are host-validated provenance, not in-sandbox isolation. Each node carries a per-id token; the host rejects a
tools/callwhose token does not match the origin node. But all bundle code in the frame can read every token in its tree (they live in the rendered tree), so a token proves "this action came from a node the host placed", not "node A cannot speak for node B". v1 assumes host-supplied presentational bundles. Cross-node isolation and a real token policy (expiry/rotation, per-node scoping) are F2. The host map and the runtime map are rebuilt from scratch on everyinitialize/update, so a replaced or removed node id loses its token immediately — tokens never accumulate. $stateprop binding resolves top-level only. A prop whose direct value is{ "$state": "key" }is substituted from the state projection. Nested references (inside an object or array prop) are passed through unresolved — F3b's renderer owns deep resolution. See thebindPropsunit test for the pinned behavior.- Standalone teardown removes the iframe is the caller's job.
connectStage(...).dispose()tears down the RPC bridge and best-effort postsui/teardownto reject outstanding approvals; it does not remove the iframe created bycreateStage. TheVendoStageReact adapter removes the iframe for you; standalone callers must remove it themselves. - Theme token values are interpolated unsanitized. Theme keys/values are written verbatim into a
:root{}block. This is contained by the egress CSP today (values cannot exfiltrate). When theme tokens become agent-influenced, F2 should allowlist--[a-z-]+names and sanitize values. - Approvals have no wall-clock timeout. Approval-pending actions are intentionally long-lived. They are settled by
resolveAction/cancelAction, and any still-outstanding ondispose()are rejected viaui/teardown— there is no automatic expiry.
Public API
createStage(slot, opts?)
Mounts a sandboxed <iframe> into slot (a host DOM element) and returns { iframe, endpoints }. Optional opts.reactSource injects a React ESM shim so the sandbox shares a single React instance with the host bundle.
connectStage(endpoints, opts)
Returns a StageController with:
ready: Promise<void>— resolves when the sandbox runtime signals ready, or rejects with asandboxerror if it never signals withinreadyTimeoutMs(default 10s).initialize(params)— sendsui/initializeto the sandbox (theme, state, bundleSource, tree).update(params)— sendsui/update. A node patch travels as one unit:update({ replace: { nodeId, node } }).theme/statemay be patched on their own. Replacing an unknownnodeIdrejects (no silent success).resolveAction(actionId, result)— resolves a parked approval-pending action.cancelAction(actionId, reason?)— cancels a parked approval-pending action (settles the runtime promise with an error so it never leaks).dispose()— tears down the RPC bridge and postsui/teardown. Does not remove the iframe (see Threat model & v1 limitations).
connectStage(endpoints, { onAction, readyTimeoutMs? }).
StageController
The object returned by connectStage. Exposes ready, initialize, update, resolveAction, cancelAction, and dispose.
StageCapabilities
The interface F3b's renderer consumes (spec §7, frozen from the F3a spike):
interface StageCapabilities {
resolveComponent(name: string, source: "prewired" | "host"): ComponentImpl | undefined;
theme: ThemeTokens;
getState(): Readonly<StateProjection>;
subscribe(cb: () => void): () => void; // provisional — not exercised in F3a
dispatch(action: ActionRequest): Promise<ActionResult>;
}Host-side GenUI resolution + ui-delta
createGenUISession(payload) validates a Vendo GenUI v1 payload and resolves its flat,
id-addressed graph into the nested UINode tree the sandbox renders. On { ok: true } it
returns a session:
session.tree: the resolved tree, passed asStageInitPayload.treetocontroller.initialize.session.applyDataPatch(path, value?): applies a JSON-Pointer patch to the live data model and returns the minimal node replacements a prop-level update needs:Array<{ nodeId, node }>. Omitvalueto delete the pointer; pass it (evenundefined) to set. Each replacement is driven into the sandbox viacontroller.update({ replace: { nodeId, node } }).
Because structure is unchanged on a data patch, only bound nodes are replaced (no remount).
The VendoStage React adapter wires this automatically: a data-only payload change takes
the applyDataPatch path, while a structural change re-initializes.
Host build step
Host component bundles must be built with vendoHostPreset from @vendoai/stage/build:
// vite.config.ts (host bundle)
import { vendoHostPreset } from "@vendoai/stage/build";
export default vendoHostPreset({
entry: "src/entry.tsx",
version: "1.0.0",
outDir: "dist",
});The preset encodes two hard requirements from the F3a spike:
- React (
react,react-dom,react-dom/client,react/jsx-runtime) is externalized — the sandbox imports it from its import map, so there is exactly one React instance in the sandbox. process.env.NODE_ENVis defined at build time — the sandbox has noprocessglobal and React throws without this.
It also stamps __VENDO_BUNDLE_VERSION__ into the output for traceability.
Host component bundles must be presentational only: pure props in, action descriptors out. No network calls, no side effects that escape the sandbox.
vite and @vitejs/plugin-react are peer dependencies — the host project provides them.
Running tests
Unit tests (30 cases, no browser needed):
pnpm --filter @vendoai/stage testBrowser tests (15 gates, requires Chromium via Playwright):
pnpm --filter @vendoai/stage test:browser