Docs

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-automationspane-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-rpc runtime and calls window.tranquilHost.paneControls.register([{ id, icon, title, action }]). Each action is a Cap’n Web function stub that runs back in the page; the host retains a dup() 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. registerDefaultRemoteControls gives them a single host-owned control — matched by hasWebview(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.