ADR-0010: Trust-tiered pane controls for browser tabs
Status: Active · Date: 2026-07-19
Context
Tranquil is browser-first (ADR-0002) and a browser tab is an Electron <webview> (ADR-0005). Each browser tab renders a
right-aligned pane-control cluster in its tab-bar (injected by tranquil-automations’ pane-controls.js, which only reads from / appends to the tabs package’s .tab-bar, never modifies
it). The open question is who defines the buttons for a given tab, and how do we do that safely?
A browser tab can host very different things: our own trusted internal pages, the business startup
mockups, or an arbitrary remote http(s):// site. The buttons need per-page actions — but
letting an arbitrary web page define host-side buttons, and run their click handlers in the host
context, would breach exactly the guest ↔ host trust boundary that the Cap’n Web object-capability
layer (ADR-0003) exists to enforce. So control provisioning has to be gated on
trust, not offered uniformly.
Decision
Provision pane controls in three mutually exclusive tiers, keyed by the page’s trust level, so any given tab falls into exactly one bucket:
- Trusted pages → self-register over RPC. A trusted page gets a
tranquil-rpcruntime and callswindow.tranquilHost.paneControls.register([{ id, icon, title, action }]). Eachactionis a Cap’n Web function stub that runs back in the page; the host retains adup()of the stub for the control’s lifetime and disposes it with the session (page close/reload). The registration matches only that page’s own item (it === ctx.item), so a page can only place controls on its own pane. The page owns its buttons and their behavior. - Mockups → host-registered by URL. The internal business mockups are registered host-side by
URL match (
mockup-controls.js), independent of whether the page is trusted at load time. They are explicitly excluded from the untrusted default (!isMockupUrl(url)). - Untrusted pages → one fixed, host-defined default. Remote sites (and
file://outside a trusted root) get no RPC runtime and cannot self-register.registerDefaultRemoteControlsgives them a single host-owned control — matched byhasWebview(item) && !isMockupUrl(url) && !rpc.isTrusted(url)— whose action runs entirely host-side and never calls back into the page.
As of this ADR the untrusted default is a Show/Hide URL Bar toggle (an eye codicon), replacing
the earlier Reload button. Its action calls the item’s own view method
(hostCtx.item.view.toggleURLBar()) rather than dispatching the tranquil-browser:toggle-url-bar workspace command, because that command targets only the center pane while pane controls also
render in the right/bottom docks — going through the item works everywhere.
Options considered
- Trust-tiered provisioning (chosen): each tab is bucketed by trust, and only trusted pages can define or run host-side button actions. Pros: untrusted web content can never reach the host through a control; trusted pages stay fully dynamic. Cons: three provisioning paths to keep coherent.
- Every page self-registers over RPC: rejected — would require handing an RPC capability to arbitrary remote pages, which is exactly the breach ADR-0003 is designed to prevent.
- All controls host-defined by a static URL table: rejected — trusted internal pages want to define their own controls dynamically at runtime; a fixed host-side table doesn’t scale to arbitrary trusted content (it works for the small, known mockup set, which is why mockups use it).
Consequences
- Untrusted browser tabs always show a small, host-controlled cluster — currently the URL-bar toggle — whose behavior the page cannot influence. Adding a control for untrusted pages means extending that fixed default set only.
rpc.isTrusted(url)is the single security-relevant gate. A bug that mis-classifies a remote page as trusted would expose the RPC surface to it, so that predicate is the thing to guard and test; it is the natural re-evaluation trigger if the trust model ever changes.- Adding controls for a new internal page = either make the page trusted (self-register over RPC) or add a host-side URL match (the mockup pattern). The two paths stay distinct on purpose.
- Stub lifecycle is load-bearing for trusted controls: the host must
dup()each action stub and dispose it with the session, or reloads accumulate duplicate buttons holding dead stubs (“RpcImportHook was already disposed”). This couples the feature to Cap’n Web disposal semantics. - Re-evaluate if untrusted pages ever legitimately need per-page dynamic controls — that would force a sandboxed, non-RPC mechanism rather than the fixed default.
Validation
Verified in a running window: a remote site’s browser tab shows the single host-defined control at
the right of its tab-bar, and clicking it toggles that tab’s URL bar — in the center pane and after
the tab is dragged into the right or bottom dock. Trusted pages continue to render their own
self-registered controls, and the business mockups render their host-registered controls unchanged;
non-browser items (editors) have no webview and show no cluster. Implementation lives in tranquil-automations (lib/pane-controls.js, lib/pane-controls-capability.js, lib/mockup-controls.js), building on the RPC layer in tranquil-rpc.