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#

☑ 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 BaseWindow per window with a WebContentsView for the UI (preload, sandbox, contextIsolation, no node integration) and one WebContentsView per 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.
  • PageState is re-emitted on did-start-loading, did-stop-loading, did-navigate, did-navigate-in-page, page-title-updated and page-favicon-updated.
  • setWindowOpenHandler always denies and re-emits the URL as openRequest.
  • 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, start background, 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, clear hidden, 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

  • sleep clamped to 0…600 s.

  • queryAll caps at 1000 elements; element descriptions truncate text at 4000 chars.

  • waitFor polls every 200 ms, defaults to 10 s and throws on timeout (unlike whenLoaded, which resolves).

  • screenshot races 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.

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/.

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.tsx has no dependency array, so it is re-registered on every render, and it swallows Ctrl+W/Ctrl+T even while focus is in the omnibox or the editor.
  • EngineWindow.events.resized is emitted but never consumed, and focus() is never called.
  • Nothing calls Automation.detach() except tab destruction, so a tab stays CDP-attached for life once a script touches it; debugger.attach also 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.
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"]