title: "Engine seam and browser shell" description: "The interfaces a browser engine must implement, and the tab/window shell built on top of them." nodes: 42 progress: 79% generated: ohm spec editor#
Engine seam and browser shell#
The interfaces a browser engine must implement, and the tab/window shell built on top of them.
Contents#
- Overview
- Engine contract
- Tabs
- Schemes and content security
- UI ↔ core protocol
- Automation and the script bridge
- Review findings
- The UI page has no navigation lock
- Listener leak in whenLoaded
- Emitter dispatches over a live set
- Symlinks escape a served root
- Script tabs share the user’s cookie jar
- reload() on a discarded tab steals focus
- A popup from a hidden agent tab surfaces
- Dead state in the wire contract
- Smaller observations
☑ Overview#
☑ Scope#
note
The replaceable engine boundary (packages/engine-api), its Electron implementation
(packages/engine-electron), the tab state machine (packages/core/src/tab-manager.ts),
the UI↔core protocol, and the browser chrome in apps/ui/src/App.tsx.
☑ Electron now, a Chromium fork later#
decision
Nothing outside engine-electron and apps/desktop may import electron.
Swapping the engine means writing one new implementation of the engine-api
interfaces; core, the UI and the language keep working unchanged.
The proof that the seam is real is that the tab tests run a complete second
implementation of EngineTab/EngineWindow with no Electron at all
(packages/core/test/tab-manager.test.ts:14).
☑ The boundary is enforced, not documented#
requirement
scripts/check-engine-imports.mjs walks apps/ and packages/ and fails on any
import/import()/require of electron or electron/* outside
packages/engine-electron/ and apps/desktop/. npm run check runs it first.
Known limits: it is a text scan, so it would miss a re-export through an
intermediate package or a dynamically built specifier, and it skips dist/.
- node:
engine-seam
☑ Engine contract#
☑ BrowserEngine#
api
interface BrowserEngine {
readonly name: string;
readonly secrets: SecretStore;
createWindow(options?: CreateWindowOptions): Promise<EngineWindow>;
}
CreateWindowOptions { width?, height?, title? }.
☑ EngineWindow#
api
interface EngineWindow {
readonly id: WindowId;
readonly events: Emitter<{ resized: Rect; closed: void }>;
createTab(options: CreateTabOptions): Promise<EngineTab>;
pickFolder(o?: PickFolderOptions): Promise<string | null>;
pickFile(o?: FileDialogOptions): Promise<string | null>;
saveFile(o?: FileDialogOptions): Promise<string | null>;
focus(): void;
close(): void;
}
The three dialog calls resolve null when the user cancels. CreateTabOptions
is { id, url?, partition? } — the id is assigned by core, not the engine.
☑ EngineTab#
api
interface EngineTab {
readonly id: TabId;
readonly events: Emitter<{ state: PageState; openRequest: { url: string }; destroyed: void }>;
readonly automation: Automation;
getState(): PageState;
navigate(url: string): Promise<void>;
goBack(): void; goForward(): void; reload(): void; stop(): void;
attach(bounds: Rect): void;
detach(): void;
readonly attached: boolean;
setBackgroundThrottling(enabled: boolean): void;
destroy(): void;
}
PageState { url, title, loading, canGoBack, canGoForward, favicon? }.
Required semantics: detach() must keep the page running (that is what hidden
tabs rely on); attach(bounds) both positions the view and makes it visible;
navigate() must not reject on a failed load; destroy() must be idempotent.
☑ Automation is raw CDP#
api
interface Automation {
attach(): Promise<void>;
send<T>(method: string, params?: Record<string, unknown>): Promise<T>;
onEvent(listener: (method: string, params: unknown) => void): Disposable;
detach(): void;
}
Deliberately the Chrome DevTools Protocol and nothing else: a Chromium fork speaks
it natively, so every automation feature written against this interface survives the
engine swap. The Electron implementation is a thin wrapper over
webContents.debugger.
- node:
engine-seam
☑ SecretStore#
api
interface SecretStore {
readonly secure: boolean; // false ⇒ writes are refused
get(key): Promise<string | null>;
set(key, value): Promise<void>;
delete(key): Promise<void>;
keys(): Promise<string[]>;
}
Values never leave core. MemorySecretStore is the reference implementation used
in tests and as the default.
- spec:
04-models-and-settings
☑ Electron implementation#
component
- One
BaseWindowper window with aWebContentsViewfor the UI (preload,sandbox,contextIsolation, no node integration) and oneWebContentsViewper tab (same hardening, no preload). - Page views are added to the content view after the UI view, so pages draw on top of the UI — which is why a zero-sized rect is how the shell hides a page.
PageStateis re-emitted ondid-start-loading,did-stop-loading,did-navigate,did-navigate-in-page,page-title-updatedandpage-favicon-updated.setWindowOpenHandleralways denies and re-emits the URL asopenRequest.- Bounds are rounded and clamped to ≥ 0 before reaching the view.
☑ Tabs#
☑ Tab record and snapshot#
data-model
Core keeps TabRecord { id, engine: EngineTab | null, lifecycle, hidden, agent, url, title, loading, canGoBack, canGoForward, load: Promise<void> }. The cached
page fields are what let a discarded tab still render in the strip.
The wire type is TabInfo (the same minus engine/load), always delivered as a
whole TabsSnapshot { tabs: TabInfo[]; activeId: TabId | null }.
TabLifecycle = 'active' | 'background' | 'frozen' | 'discarded'.
☑ Core owns all tab state#
requirement
The engine reports (state, openRequest) and obeys (navigate, attach); the
UI renders snapshots and issues commands. Every mutation funnels through one private
changed() that emits a complete tabs.changed snapshot — there are no
incremental tab events and the UI never mutates its own copy.
- node:
tab-model
☑ Lifecycle#
flow
- create — allocate id, default
about:blank, startbackground, append to order, spawn the engine tab, then activate unless hidden. - activate — detach the previous tab and demote it to
background, respawn the engine if discarded, clearhidden, attach at the stored content rect. - hide — mark hidden; if it was active, hand focus to a neighbour; disable background throttling so the page keeps working.
- show — clear hidden, re-enable throttling, activate.
- discard — destroy the engine tab, keep url/title, lifecycle
discarded. - close — remove from order and map, destroy the engine tab, pick a new active.
- navigate — record the url first, then spawn-or-navigate.
whenLoaded(id, timeout = 30 s) awaits the recorded load promise and then a
loading === false state; it resolves on timeout rather than rejecting.
☑ Focus follows Chrome’s rule#
requirement #regression
pickNeighbour: among tabs that are not hidden, take the first at or to the
right of the index, otherwise the nearest to the left.
The two callers differ deliberately: close splices the tab out first, so the index
already points at the right-hand neighbour and nothing needs excluding; deactivate
(hide/discard) leaves the record in place and must exclude it.
This was a real bug once: an earlier version always moved focus left. The test that
pins it uses [a, b, c(hidden), d] — closing active b must activate d, not
a or the hidden c.
- node:
tab-lifecycle
☑ Hidden tabs keep running#
requirement
A hidden tab is out of the strip and into the drawer (Ctrl+Shift+H), but its page
keeps executing with background throttling off. This is the mechanism scripts use
for work the user should not have to watch — browser.open(url, { hidden = true })
— and the whole deep-research pipeline depends on it.
activate and show both clear hidden: a tab can never be focused and hidden at
once.
- spec:
02-ohm-language - spec:
08-projects-and-research
☑ Layout: bounds flow UI → core → engine#
flow
The UI renders an empty <main> placeholder and a ResizeObserver reports its
client rect through layout.setContentRect; core stores it and re-attaches the
active tab. When the editor is maximised or settings are open, the UI reports a zero
rect, which is how the page is hidden.
Consequence to preserve: the page view is positioned by a renderer round-trip, so a native window resize does not move it until React reacts.
☑ Popups become core-created tabs#
requirement
The engine denies every window.open/target=_blank and re-emits it as
openRequest; core creates a normal tab for it. No page ever gets a window the
shell does not know about.
☑ Omnibox input resolution#
requirement
resolveInput: empty → about:blank; anything with a scheme is left alone; bare
localhost and IPv4 get http://; other host-shaped text gets https://;
everything else becomes a Google search.
☑ Schemes and content security#
☑ Three privileged schemes#
data-model
Registered before app.ready, each { standard, secure, supportFetchAPI, corsEnabled, stream } (ohm-app additionally codeCache):
| Scheme | Serves | Root |
|---|---|---|
ohm-app:// |
the built renderer, with index.html as SPA fallback |
the UI dist folder |
ohm-models:// |
local model weights | the models folder (re-read per request) |
ohm-file:// |
project documents, read-only | the scripts folder |
Giving the UI a real origin is what makes workers, wasm and code caching work.
- spec:
11-documents - spec:
04-models-and-settings
☑ Every handler is rooted#
requirement
A request is decoded, resolved against the root, and refused with 403 unless the
result still starts with root + separator; a missing file is 404 unless the scheme
declares a fallback. This defeats .. traversal, including percent-encoded forms,
because decoding happens before resolution.
- node:
schemes - node:
risk-symlink
☑ Production CSP#
requirement #gotcha
Injected as a meta tag at build time (dev needs inline scripts for React refresh):
default-src 'self'; script-src 'self' 'wasm-unsafe-eval' blob:;
style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: ohm-file: https: http:;
font-src 'self' data:; worker-src 'self' blob:;
connect-src 'self' blob: data: ohm-models: ohm-file:
https://huggingface.co https://*.huggingface.co https://*.hf.co
wasm-unsafe-eval is for the Lua VM and the ONNX runtime; blob: for Monaco's and
transformers.js's workers. If something works under npm run dev and not under
npm start, suspect this first.
- spec:
04-models-and-settings
☑ UI ↔ core protocol#
☑ Two channels, one method table#
api
ohm:call carries requests, ohm:event carries pushes. CoreMethods maps each
method name to [params, result], and CoreHandlers is typed to be total over
that map — adding a method to the protocol is a compile error until it is
implemented.
☑ The preload bridge is two functions#
api
window.ohmHost = {
call(method: string, params?: unknown): Promise<unknown>;
on(event: string, listener: (payload: unknown) => void): () => void;
};
No Node, no ipcRenderer, no engine types. apps/ui/src/host.ts wraps it in the
typed HostBridge and yields null when the global is absent, so the UI can be
opened in a plain browser and says so instead of crashing.
☑ Only the UI may call core#
requirement #safety
A single ipcMain.handle looks the sender's webContents.id up in a table
registered when the window's UI view is created, and throws Unauthorized caller
otherwise. Page webContents have different ids, so nothing a browsed page does can
reach CoreMethods.
☑ Automation and the script bridge#
☑ How a script drives a page#
flow
ScriptHost.cdp(tab, method, params): resolve the tab id (accepting either a string
or a tab handle), respawn the engine tab if it was discarded, wait up to 15 s for it
to finish loading, then automation.send.
Only four CDP methods are used: Runtime.evaluate (with awaitPromise,
returnByValue, userGesture), Input.insertText, Input.dispatchKeyEvent and
Page.captureScreenshot. Everything the script API offers is built from those.
- spec:
02-ohm-language
☑ Limits on automation#
requirement
sleepclamped to 0…600 s.queryAllcaps at 1000 elements; element descriptions truncate text at 4000 chars.waitForpolls every 200 ms, defaults to 10 s and throws on timeout (unlikewhenLoaded, which resolves).screenshotraces a 10 s timer and writes to.ohm/shots/<timestamp>.png.Selectors are injected into evaluated JS with
JSON.stringify.Scripts may create model profiles but can never read or write credentials.
spec:
04-models-and-settings
☑ A runaway script can always be stopped#
requirement #safety
Every run owns a worker thread; stop() detaches the pending run and calls
worker.terminate(), which kills even a CPU-bound infinite loop that never yields
to the host. One run at a time per runner; runs are pruned to 150 finished entries
once 200 accumulate; closing the window disposes every runner.
- spec:
02-ohm-language
☑ Review findings#
☐ The UI page has no navigation lock#
risk urgent #security
The UI WebContentsView carries the preload that exposes window.ohmHost, and
nothing handles will-navigate/will-redirect. If that view is ever navigated
top-level to a remote origin — a stray same-window link, a redirect from the dev
server — the new origin inherits the entire CoreMethods surface, including script
writing and filesystem-backed handlers.
Fix: cancel any top-level navigation in the UI view whose URL is not the configured UI URL.
- node:
ipc-auth
☐ Listener leak in whenLoaded#
risk high
When the outer timeout wins, the inner state subscription is never disposed, so
every timed-out wait leaves a listener on that tab's emitter. A script polling a slow
page accumulates them.
Fix: hold the subscription outside the promise and dispose it in a finally.
- node:
tab-lifecycle
☐ Emitter dispatches over a live set#
risk
Emitter.emit iterates the listener Set directly while whenLoaded disposes
from inside a listener. Set iteration tolerates deleting the current element, so
it works — but it is load-bearing behaviour that is neither documented nor tested.
Copying before dispatch would also make additions during emit safe.
☐ Symlinks escape a served root#
risk high #security
The scheme handlers check the resolved path prefix but never realpath, so a
symlink inside the models or scripts folder pointing outside it is followed. The
ohm-file root is also the whole scripts folder, .ohm/ internals included,
rather than just each project's files/.
☐ Script tabs share the user’s cookie jar#
risk high #security #privacy
CreateTabOptions.partition exists and is implemented, but core never passes it, so
every tab — including agent: true tabs opened by scripts — shares one session.
The type suggests an isolation that does not exist.
☐ reload() on a discarded tab steals focus#
risk low
It calls activate, which attaches the tab and moves the user's view — a focus
change nobody asked for. It should respawn and reload in place.
☐ A popup from a hidden agent tab surfaces#
risk
openRequest always produces a visible, activated, non-agent tab, so a background
script that triggers a popup pulls the user's view away mid-task. Hidden agent tabs
should propagate hidden/agent to the tabs they spawn.
☐ Dead state in the wire contract#
risk low
TabLifecycle includes 'frozen', which nothing ever sets although the UI renders
it. Either implement freezing or drop the state.
☐ Smaller observations#
note low
- The global keydown effect in
App.tsxhas no dependency array, so it is re-registered on every render, and it swallowsCtrl+W/Ctrl+Teven while focus is in the omnibox or the editor. EngineWindow.events.resizedis emitted but never consumed, andfocus()is never called.- Nothing calls
Automation.detach()except tab destruction, so a tab stays CDP-attached for life once a script touches it;debugger.attachalso throws if DevTools is already open on that tab, surfacing as a script error. - A page's favicon is never cleared on navigation, so a faviconless site keeps the previous icon.
Links between nodes#
graph LR
the_boundary_is_enforced_not_documented["The boundary is enforced, not documented"] -.-> engine_seam["Electron now, a Chromium fork later"]
automation_is_raw_cdp["Automation is raw CDP"] -.-> engine_seam["Electron now, a Chromium fork later"]
core_owns_all_tab_state["Core owns all tab state"] -.-> tab_model["Tab record and snapshot"]
focus_follows_chrome_s_rule["Focus follows Chrome’s rule"] -.-> tab_lifecycle["Lifecycle"]
every_handler_is_rooted["Every handler is rooted"] -.-> schemes["Three privileged schemes"]
every_handler_is_rooted["Every handler is rooted"] -.-> risk_symlink["Symlinks escape a served root"]
risk_ui_nav["The UI page has no navigation lock"] -.-> ipc_auth["Only the UI may call core"]
risk_listener_leak["Listener leak in whenLoaded"] -.-> tab_lifecycle["Lifecycle"]