@cef-ai/widget-runtime
v2.4.0
Published
Browser runtime for CEF widgets. It boots from `window.WidgetSandbox.manifest`, resolves the user's identity, exposes `window.WidgetRuntime` (`query` / `publish` / `subscribe` / `connect` / `connectAgent` / `agentStatus` / `identity`), and renders config-
Keywords
Readme
@cef-ai/widget-runtime
Browser runtime for CEF widgets. It boots from window.WidgetSandbox.manifest,
resolves the user's identity, exposes window.WidgetRuntime
(query / publish / subscribe / connect / connectAgent / agentStatus / identity),
and renders config-driven widget kinds (record / list / dashboard / submit /
conversation / composite) plus custom widgets. It also carries the audio
capture/upload path. The runtime reads a manifest; the @cef-ai/cli build
tooling builds one.
The esbuild IIFE build emits dist/widget-runtime.js (the browser bundle) and
dist/widget.html (the shell); tsc emits the library entry (dist/index.js)
for consumers that import the helpers directly.
Following a stream
subscribe polls one stream of the widget's scope (the scope publish writes
to) and hands each poll's new events to the callback as one ordered,
de-duplicated batch:
const { requestId } = …;
await WidgetRuntime.publish('workflow.start', input, requestId, { target });
const stop = WidgetRuntime.subscribe(
requestId,
{ types: ['agent.progress'], intervalMs: 2500 },
(events) => events.forEach(render),
);
// later: stop();It polls every 2500ms by default, backs off (doubling, up to maxBackoffMs,
default 30s) while polls fail and reports each failure to onError, and stops
when the returned function is called. Reading a stream needs ddc-read and a
read grant on the scope, which a widget's delegation carries.
Identity (multi-source)
The runtime resolves a signing identity from the best available source:
- Embedded (host): when the widget is framed, it asks the embedding page
for an identity over
postMessageand signs through it. The host does not have to be the Cere Vault — the contract is host-agnostic and exported (see Host contract below), so a browser-extension panel, a partner app, or any page that has already authenticated the user can supply it. - Standalone (interactive wallet) — currently NON-FUNCTIONAL. When the
widget is not framed — opened top-level via a direct link — it was meant
to connect an interactive Cere embed-wallet built from
manifest.wallet.{appId,env}and sign locally. That path terminates atbeta.openlogin.com, which no longer resolves, so it fails rather than opening a wallet. Tracked in CEF-AI/sdk#144 (dead endpoint) and #146 (replacement);standalone-wallet.tsis unchanged and still shipped. Until those land, treat path 1 as the only working one: frame the widget and mount a host.
Path 1 feeds the @cef-ai/vault-sdk client, so cubby reads, connect, publish,
and audio uploads work the same way whichever host supplies the identity.
A framed widget never falls back to (2) (behaviour change in 1.1). If the
host does not answer the handshake within 8 s, or answers malformed, the runtime
surfaces WidgetSignedOutError and the identity stays anon. It used to
silently open the standalone wallet instead, which looked like a hang and
produced an identity the host knew nothing about. The Cere Vault host already
implements the handshake, so it is unaffected; a page that frames a CEF widget
without implementing the host contract now gets WidgetSignedOutError where
it previously got the silent fallback. If you frame a widget in your own page,
mount createWidgetHost on it.
Host contract
Four postMessage messages, widget → host and back.
Gate the inbound side first. The host holds the user's signing key, so
createWidgetHost makes you name who may talk to it. allowedOrigins is
required and non-empty, and it throws at mount without it — an ungated
listener is a signing oracle for every other frame, opener, or popup on the
page.
A frame handle does not pin a document. widget is an optional, and
recommended, extra narrowing — never a replacement for allowedOrigins. A
WindowProxy keeps its identity across navigation, cross-origin included:
after the framed page follows an open redirect, or navigates itself, or has its
src set from a query param, event.source === iframe.contentWindow still
holds and only event.origin has moved. A host gated on the frame alone would
sign arbitrary bytes for whatever later occupies that slot. When both options
are given, both must pass.
Each allowedOrigins entry must be a canonical origin spelled exactly as
event.origin reports it — scheme + host + non-default port, lower-case, no
trailing slash, no path ('https://widget.example',
'http://localhost:5173'). A default port is not optional spelling: an origin
omits :443 on https and :80 on http, so 'https://widget.example:443'
throws too. Anything else throws at mount rather than failing
silently at runtime. In particular '*' is rejected: postMessage takes
* as a targetOrigin wildcard, but event.origin is never the string "*",
so an allowlist of ['*'] would mount clean and then answer nobody.
Sandboxed widgets. A sandboxed frame's event.origin is the literal string
"null", so pass allowedOrigins: ['null'] to frame one. That is an opaque
origin — every sandboxed frame reports it, so pair it with widget if any
other sandboxed frame can reach the page. Separately, a "null" origin relaxes
only the reply targetOrigin ('*', since "null" is not a valid
targetOrigin); it never relaxes the inbound decision.
Bytes on the wire are number[], not Uint8Array. Structured clone handles
typed arrays everywhere; the reason is the rest of the chain. A real host
usually forwards the payload over a JSON-serialising transport —
chrome.runtime.sendMessage to an extension service worker, a native-messaging
port — which turns a Uint8Array into {"0":1,"1":2,…} and hands the signer
something the widget never sent.
| Message | Direction | Fields |
| --- | --- | --- |
| cef-widget:identity-request | widget → host | requestId: string, v: number |
| cef-widget:identity-response | host → widget | requestId: string, v?: number, pubkey?: string, sigType?: 'ed25519' \| 'sr25519' (default ed25519), token?: string (a bearer delegation the widget uses instead of asking you to sign), context?: { runId?, recordId?, vaultId? }, error?: string, errorCode?: 'unsupported-version' |
| cef-widget:sign-request | widget → host | requestId: string, v: number, bytes: number[] |
| cef-widget:sign-response | host → widget | requestId: string, v?: number, signature: number[] \| null (null = user declined), error?: string, errorCode?: 'unsupported-version' |
Rules:
- Versioning (
v). Current version is1(PROTOCOL_VERSION). A message that carries novis treated as v1 — that is what keeps already-deployed widgets and the existing Vault host working untouched. An unrecognisedvis rejected with a namedWidgetProtocolVersionError; it is never silently ignored. A peer that rejects the other side'svanswers witherrorCode: 'unsupported-version'— its own reply is still a v1 message, sovitself cannot carry the rejection, and without the code the receiver would just see an opaque error string and keep retrying. - Which vault (
context.vaultId). By default a widget opens the caller's own vault (vault.ensure()), and atokendoes not change that — a delegation says who, not which vault. A workflow, meanwhile, runs in the vault of the org that owns its agent service, so a host showing somebody else's run must name that vault or the widget reads a different database and reports it as empty. Optional: a host that sends none gets exactly today's behaviour. A host that does send one and the widget cannot open it getsWidgetVaultUnreachableError— the runtime refuses rather than falling back, because a fallback is indistinguishable from a run that produced nothing. - Sign verbatim. The host must sign the exact bytes it receives with
ed25519_signRaw. Do not use the high-levelsignMessage/wallet_signMessage, which wrap the payload in Substrate's<Bytes>…</Bytes>envelope — a signature over the wrapped form verifies nowhere (not in GAR, not at the DDC gateway). - Validate what you sign. Every element of
bytesmust be an integer0–255, and the payload is capped at 64 KiB (MAX_SIGN_BYTES).Uint8Array.fromcoerces junk silently (300→44,'zz'→0, and a sparse array's holes →0), which would produce a perfectly valid signature over the wrong payload.createWidgetHostdoes this check for you and answersWidgetMalformedMessageError; a hand-rolled host must do it itself —isByteArrayandMAX_SIGN_BYTESare exported for exactly that, so the cap moves in one place. The widget applies the same check tosignature, and refuses to post a payload over the cap in the first place. - Timing. The widget retries
identity-requestevery 400 ms for up to 8 s before giving up (the host page and the iframe race to attach their listeners, so the first request is often sent before the host is ready — just answer whichever request you see). A reply slower than the retry interval still counts: every request the handshake issued stays open, which is what a cold extension service worker needs. Asign-requestwaits up to 60 s, the user's time to approve in the host's UI. A handshake that times out is not cached — a later action (the user connects after the widget loaded) starts a fresh one. - Replies are source-gated. The widget only accepts messages whose
event.source === window.parent. Always reply toevent.source, echoing the request'srequestId.
Mounting a host (non-Vault example)
createWidgetHost is the host side of the table above — one listener, no
dependencies. Mount it once on the page that frames the widget:
import { createWidgetHost } from '@cef-ai/widget-runtime';
// An extension panel that has ALREADY authenticated the user with SCP Wallet.
const dispose = createWidgetHost({
// REQUIRED. The gate between the page and the user's key: which DOCUMENT may
// ask. Canonical origins only — no trailing slash, no path, and no '*'.
// Use ['null'] for a sandboxed widget.
allowedOrigins: ['https://widget.example'],
// Optional but recommended: narrow to one frame as well. Both must pass.
// On its own this would NOT be safe — the handle survives navigation.
widget: document.querySelector<HTMLIFrameElement>('iframe#cef-widget')!,
// Return null/undefined while the user is not signed in yet — the widget
// retries for 8 s, and a later action retries again after that.
getIdentity: () => (scp.isConnected() ? { pubkey: scp.publicKey, sigType: 'ed25519' } : null),
// Verbatim raw-bytes signing. Return null if the user declines; throw to
// report a failure. Never route this to signMessage/wallet_signMessage.
//
// NOTE: called more than once per widget action — a first `query()` signs
// twice (delegation-token mint, then vault claim). If you put a user prompt
// behind this, the reader sees two prompts before the first cubby read.
// Cache or batch consent rather than prompting per call.
sign: async (bytes) => scp.request({ method: 'ed25519_signRaw', params: [bytes] }),
});
// On unmount:
dispose();The widget then resolves its identity through the host, exactly as it does inside the Cere Vault, and the standalone wallet is never opened.
Chrome extension hosts: an MV3 panel must also allow the widget's origin in its own CSP, or the frame never loads and the handshake has nothing to answer:
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'; frame-src https://widget.example"
}Verified end-to-end against a real extension: the widget resolves the host's
identity, signatures cross the frame as number[] and verify, and with no host
mounted the framed widget fails WidgetSignedOutError without reaching the
legacy wallet.
Build
pnpm --filter @cef-ai/widget-runtime build # esbuild bundle + tsc + fix-extensions
pnpm --filter @cef-ai/widget-runtime test