ADR 0097: Desktop Attach Client — One Front Node per Workspace

Status

Proposed (2026-06-13)

Builds on ADR 0091 — Connection Security for Remote Workspace Access (the Attach topology and its cookie boundary) and ADR 0017 — Browser Connectivity to Running Workspaces (the LiveView IDE, Phase 3). Reuses the bin/server launcher and the boot-time global-env attach model already shipped for the bt_attach release.

Context

Problem statement

The LiveView IDE (editors/liveview, the bt_attach Phoenix app) is a web UI that attaches to a running Beamtalk workspace over Erlang distribution. We want to ship it as a desktop application — a native window the user opens from their dock, not a localhost:4000 URL they have to remember to start by hand.

The shaping discussion converged on two constraints that narrow the design sharply:

  1. Attach to live workspaces; do not bundle or supervise the Rust toolchain. The desktop app attaches to workspaces discovered from ~/.beamtalk/workspaces/, typically started with beamtalk workspace create … --background --persistent. It is a client, not a process supervisor for the language runtime: it never ships the Rust beamtalk binary and never babysits its lifecycle — the most painful packaging slice. The constraint is on bundling, not invoking: the broker already shells out to the installed CLI for liveness (workspace status), and invoking that same CLI for workspace create/stop costs no additional packaging — --background --persistent daemonizes the workspace, so no supervision follows. In-scope lifecycle is "invoke the user's CLI"; out-of-scope is shipping or supervising it.

  2. Multiple workspaces, switchable at runtime. A developer typically has more than one workspace alive. The desktop app's reason to exist (over just pointing a browser at the front) is precisely the connection-broker UX: discover live workspaces, attach to one (or several), and manage those connections natively.

The crux is how one desktop process attaches to N workspaces given how the attach client is built today.

Current state

The bt_attach front is hardwired to a single workspace, fixed at boot:

MechanismWhereBehaviour
Target nodeworkspace.ex:54 (node_name/0)reads BT_WORKSPACE_NODE env once, module-global
Cookieworkspace.ex:1966 set_cookie/0reads BT_WORKSPACE_COOKIE env, calls Node.set_cookie(node_name(), token) — the per-peer arity (erlang:set_cookie(Node, Cookie)), scoped to the one target node
Self node nameworkspace.ex:1951 ensure_distributed/0the front starts its own distribution via :net_kernel.start([:"bt_attach_#{System.unique_integer([:positive])}@localhost", :shortnames]) — it does not use RELEASE_NODE
Every RPCworkspace.ex:1981 rpc/3:rpc.call(node_name(), …) — always the one global target
Launcherrel/overlays/bin/server <id>resolves node_name from ~/.beamtalk/workspaces/<id>/metadata.json + the sibling cookie file, exports the two env vars, runs bin/bt_attach start

So today: one front node, bound to one workspace, for the life of the process — the node_name()/cookie globals are read once and every RPC targets that single peer.

A correctness nuance worth stating up front, because it shapes the alternatives: a BEAM node is not limited to one cookie. Erlang's erlang:set_cookie(Node, Cookie) (the arity set_cookie/0 already calls) sets a cookie per peer node, so a single VM can legitimately hold different cookies for workspaces A and B simultaneously. The single-node-multi-workspace alternative is therefore technically viable — the argument against it (below) is about blast radius and refactor cost, not impossibility.

The release is already self-contained (ERTS-embedded mix release), and PORT (plus PHX_HOST) pass through config/runtime.exs under config_env() == :prod — which is how the release runs. Distribution starts lazily on the first browser mount (connect/0ensure_distributed/0), not at boot. Discovery data (metadata.json + cookie) already lives on disk in a stable layout.

One invariant to state rather than assume: the front's lazy :net_kernel.start/1 — unlike erl -sname boot — does not auto-start epmd; it fails if none is listening. The design therefore assumes "workspace running ⇒ epmd listening on loopback 4369", which holds today because the workspace's own boot daemonizes epmd. If workspace lifecycle ever adopts the pinned-dist-port / epmdless direction ADR 0091 floats for its private-network posture, the front's name-resolution path breaks with no fallback — a known future incompatibility, and why /readiness should report epmd absent as its own failure mode (Impl §1c).

Constraints

Decision

Ship a thin desktop shell whose main process acts as a connection broker: for each workspace the user attaches to, it spawns one dedicated bt_attach BEAM node, bound to that workspace via the existing boot-time env model, on its own HTTP port, and points a window at it. Disconnecting kills that node's OS process.

Concretely, attaching to workspace <id> is:

PORT=<free-port> bin/server <id>

Each front holds the cookie for exactly one workspace. As noted above this is a choice, not a VM constraint — but holding one cookie per process is what gives the design its two real properties: blast-radius isolation (a compromised or wedged front node can reach only its one workspace, honouring the ADR 0091/0058 "cookie = full RCE" stance) and crash isolation (one front dying takes down one window, not all attachments). Both fall out of "one node per workspace," alongside reusing the single-target attach client nearly untouched.

Broker responsibilities (desktop main process)

  1. Discover — enumerate ~/.beamtalk/workspaces/*/metadata.json; liveness via the installed beamtalk CLI (workspace status) or a direct epmd query (TCP 4369 NAMES) — a dist ping is not available to a non-BEAM broker without implementing the distribution handshake, so it is not a fallback. Two contracts this surfaces: (a) GUI apps launched from Finder/the dock do not inherit the user's shell PATH, so the CLI path must be resolved explicitly (configurable, with sane defaults) if the CLI route is used; (b) the broker parses metadata.json with a real JSON parser (not bin/server's sed extraction), and an independently-updating desktop app coupling to a file layout owned by the Rust CLI needs a stated compat contract (additive-only fields, or a version field) — today that layout is stable by convention, not by contract.
  2. Attach — pick a free port, spawn the node as above pinned to a loopback bind and the unauthenticated cookie-only path (see local-only posture below), then probe for readiness before opening the window. Note the probe is two-stage, because an external (non-BEAM) broker cannot trigger the lazy connect/0 from outside the VM: (a) poll the HTTP port to confirm Phoenix is up; (b) hit a small attach-health endpointGET /readiness — that forces connect/0 + one cheap RPC to the workspace and returns 200 only on success, so a bad cookie / dead workspace surfaces before the window opens rather than on the user's first eval. That endpoint does not exist today; it is a small, explicit addition to the front's router + a thin controller (see Implementation §1) — not free, but contained.
  3. Monitor — the readiness probe runs once, before the window opens; attachment health after that is not free. The broker re-polls /readiness periodically post-attach to drive window state (the "dead workspace = greyed window" UX needs this mechanism — a dead workspace does not kill the front, whose RPCs just start returning {:badrpc, :nodedown}), and the front should retry connect/0 on :nodedown so OS sleep/resume — which drops dist connections — self-heals.
  4. Detach / quit — terminate that node's child process; the window closes. If the broker dies uncleanly (SIGKILL, logout), N cookie-bearing front BEAMs are orphaned — the reaping mechanism (process group, parent-death watch, or PID-file sweep on next start) must be specified in the spike, since crash-reaping is cited below as a load-bearing argument for the Tauri shell.
  5. Create / stop, via the installed CLI — the picker's empty state and lifecycle buttons shell out to the user's installed beamtalk CLI (workspace create … --background --persistent, workspace stop) — never a bundled copy (constraint 1). If the CLI can't be resolved (GUI-app PATH, §1), the picker degrades to setup instructions: the first-run experience must not be a blank window with an unstated terminal prerequisite.

Local-only posture (security). Three things the broker must pin — none of which the remote-shaped release does for it — and one it deliberately leaves ephemeral:

What this is NOT

Scope boundary: remote / OIDC workspaces (out of scope — future, separate ADR)

This ADR covers local workspaces only. Attaching to a remote workspace — one already fronted by an OIDC-authenticated Phoenix deployment per ADR 0091 — is a deliberately separate topology, because it reuses none of the broker designed here:

Local (this ADR)Remote + OIDC (future)
Who runs the front?broker spawns one bt_attach per workspacealready running (Docker, ADR 0091); broker spawns nothing
TransportErlang dist + cookie, loopbackHTTPS to a remote host
Authnone (single-user localhost, ADR 0020)server-side OIDC in the front
Desktop's jobprocess broker (spawn / port / reap)a persistent webview pointed at https://host
Cookie / port mgmtyesnone

The OIDC flow is entirely server-side and redirect-driven in the front (oidc_controller.ex: /oidc/auth → 302 to IdP → /oidc/callback → session cookie). A desktop webview therefore implements no OAuth itself — it just follows redirects and persists the session cookie. The one real wrinkle: the IdP login page is a third-party origin, and several IdPs (notably Google) reject OAuth from embedded webviews (disallowed_useragent). The remote path would need a system-browser handoff for the IdP step, with a loopback / custom- scheme redirect back into the app — the classic desktop-OAuth pattern, which a Rust shell (Tauri) supports but the no-shell coordinator alternative could not.

The two topologies meet only at the connection picker: one list offering local workspaces (attach = spawn) and remote endpoints (attach = open-webview- at-URL). That shared surface is the natural seam at which a future "desktop remote attach + OIDC" ADR would extend this one — it does not require revisiting any decision here.

Open sub-decisions (deferred to implementation spike)

Prior Art

Desktop wrappers over a local server process are well-trodden; the BEAM-native angle is the interesting part.

What we adopt: Livebook's "desktop shell wraps a local Phoenix release" shape. What we reject: Livebook's single-runtime multiplexing — it doesn't fit cookie-bearing dist nodes.

User Impact

Steelman Analysis

Option A — Node per workspace (chosen)

Option B — Single front node, per-session node targeting

Tension points

Alternatives Considered

Single front node with per-session node targeting

Refactor workspace.ex so the target {node, cookie} is carried per LiveView session/mount rather than read from module-global env, and have one Phoenix node Node.connect/1 (with per-peer set_cookie/2) to several workspaces. This is technically viable — per-peer cookies make it work — so the rejection is on cost/risk, not feasibility. Rejected for now because (a) it concentrates multiple workspaces' cookies in one process, so a single compromise/crash blasts all attachments, against the ADR 0091/0058 isolation stance; and (b) it requires a non-trivial refactor of the shared, tested attach client (threading the target through node_name/0, set_cookie/0, and every RPC). Its only edge — amortizing ERTS across connections — is irrelevant at desktop scale. Preserved as the natural evolution if a many-workspace operator surface ever needs it; Option A does not block it.

Browser, no desktop shell

Just just web <id> and open a tab. Rejected: provides none of the connection-broker UX (discovery, native window management) that is the desktop app's whole justification. If we weren't adding discovery + native connection management, a desktop wrapper wouldn't earn its keep.

No-shell coordinator front (+ optional PWA install)

Instead of a Tauri/Rust shell, make the broker itself a small Phoenix front: one always-on bt_attach-style node that lists ~/.beamtalk/workspaces/* and, on click, shells out to bin/server (or opens a window at) the per-workspace front. The "native app" feel comes from the browser's Install app (PWA) affordance — a dockable, chrome-less localhost window with near-zero shell code. This is the real 80/20 challenger: it delivers discovery + a dock icon + pick-a-workspace without Tauri, a Rust broker, or a new CI build lane. Not rejected outright — instead, the spike (Implementation §6) must build this first and justify the Tauri shell against it. The Tauri case rests on things the coordinator can't easily do: enforcing the loopback/no-OIDC/check_origin posture outside the BEAM (a coordinator front would have to spawn children with the same care anyway), true per-window OS integration, ownership of OS-reserved keybindings (Ctrl/Cmd-W etc. as IDE actions — see "Shell: Tauri"; a PWA window still cedes these to the browser), and reaping orphaned child processes on crash. If those don't prove load-bearing in the spike, the coordinator wins and this ADR's shell choice should flip.

Desktop app bundles and supervises the Rust workspace runtime

Ship the Rust toolchain inside the app and supervise workspace processes from the shell. Rejected per the framing constraint: it reintroduces cross-platform bundling of the Rust toolchain and lifecycle supervision of a second, less-well-behaved process — the most expensive and platform-fragile slice. What is not rejected: invoking the user's already-installed CLI to create/stop workspaces (Broker §5). An earlier "attach-only" framing conflated bundling with invoking; the rejection applies to bundling only.

beamtalk workspace open <id> — CLI-only launcher, no desktop app

Add a CLI command that spawns a front for the workspace (free port, loopback bind) and opens the browser/PWA at it; discovery is beamtalk workspace list. Zero new artifacts, no CI lane, no GUI-PATH problem (the CLI is the entry point), and it fits ADR 0099's CLI application story. Rejected as a replacement, cheap as a complement: it delivers launch but none of the connection-broker UX — no persistent picker, no window management, no post-attach monitoring or orphan reaping (stray fronts accumulate with no owner) — and it inherits the PWA path's inability to own OS-reserved keybindings. Worth shipping independently of this ADR as a CLI affordance; the spike's no-shell coordinator comparison effectively subsumes it.

Consequences

Positive

Negative

Neutral

Implementation

  1. Front-side hooks (three small, additive changes). (a) seed ensure_distributed/0's sname with the workspace id plus per-process entropy (e.g. bt_attach_<id>_<os_pid>, via a BT_ATTACH_NODE_SUFFIX env) so two front processes can't collide on epmd — id-only would collide deterministically when two fronts attach to the same workspace; (b) read BT_ATTACH_BIND_IP in config/runtime.exs (default = today's all-interfaces) so the broker can pin loopback; (c) add a /readiness endpoint (router + thin controller) that forces connect/0 + one cheap RPC and returns 200 only when the workspace is actually reachable — including a version handshake (workspace reports its runtime/protocol version; the front refuses or warns on mismatch) and a failure taxonomy that distinguishes epmd absent from bad cookie from dead workspace. Confirm PORT passthrough. (~S)
  2. Broker core (desktop main process) — discovery of ~/.beamtalk/workspaces/*, free-port selection, spawn with PORT + BT_ATTACH_BIND_IP + cookie env, two-stage readiness probe (HTTP port up, then GET /readiness for true workspace reachability — distribution starts lazily, so HTTP-up alone is not enough), child-exit reaping. (~M)
  3. Picker / launcher UI — native list of live workspaces, attach/detach, disconnected-state handling, the first-run empty state, and create/stop via the installed CLI (Broker §5). (~M)
  4. Window-per-workspace wiring — one window per attached front. (~S)
  5. Packaging — bundle the bt_attach release into the shell; CI release lane mirroring liveview-release.yml. Three named costs the "reuse bin/server" framing hides: (a) an ERTS-embedded release is per-OS/per-arch, so this is a build matrix (macOS arm64 + x86_64, Linux x86_64, Windows — ADR 0027 makes Windows a supported platform), not one artifact; (b) bin/server is a POSIX sh script with no Windows counterpart — on Windows the broker must set the env and invoke bin/bt_attach itself; (c) macOS notarization requires signing every nested Mach-O in the release (beam.smp, NIFs, epmd) — Livebook treats this as a substantial ongoing surface, not a one-off. (~L, was ~M)
  6. Spike first, and make it decide the shell question. Validate: (a) two-instance boot (distinct snames + ports); (b) crash → respawn of the same workspace — same seeded sname must re-register cleanly after epmd drops the dead one, and the freed port must be reusable; (c) the attach-health probe catching a dead-workspace/bad-cookie before the window opens; (d) the loopback-bind + no-OIDC + pinned-check_origin posture actually takes; (e) webview parity: OS-reserved keybinding interception (Cmd/Ctrl-W etc.) actually works in the Tauri webview on all three platforms — the keybinding argument for Tauri is asserted from browser behavior, not yet demonstrated in WebKitGTK/WebView2/WKWebView — and WebKitGTK's rendering/websocket performance is adequate for an editor-grade LiveView UI (a known Tauri-on-Linux risk that cuts against the shell as surely as the keybinding argument cuts for it); (f) post-attach lifecycle: workspace killed while attached → greyed window (not a wall of LiveView errors), and sleep/resume → reconnect; (g) the orphan-reaping mechanism on broker crash, concretely. Build the no-shell coordinator (Alternatives) alongside and only commit to the Tauri shell + CI lane (§5) if the broker responsibilities prove they need to live outside the BEAM — the spike's exit criteria are (a)–(g), decided per-duty, not vibes. (~M, was ~S — this is the load-bearing spike, not a warm-up.)

Rough total: ~2 weeks, low risk — the front changes are small and additive; the attach client's RPC/eval core is untouched.

Affected components: desktop shell (new), editors/liveview/rel/overlays/bin/, three small front-side additions (ensure_distributed/0 sname seed, config/runtime.exs BT_ATTACH_BIND_IP, a /readiness router + controller), CI release lanes. Not affected: the wire/RPC/eval layer, the Rust toolchain.

Implementation Tracking

Epic: BT-2982 Issues:

Status: Planned

References