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. Connect to live workspaces; do not spawn the Rust toolchain. The desktop app should attach to workspaces the user has already started (beamtalk workspace create … --background --persistent), discovered from ~/.beamtalk/workspaces/. It is a client, not a process manager for the language runtime. This removes cross-platform bundling and lifecycle supervision of the Rust beamtalk binary — the most painful packaging slice.

  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:951 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:936 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:966 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.

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 beamtalk workspace status (or a dist ping).
  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. Detach / quit — terminate that node's child process; the window closes.

Local-only posture (security). Three things the broker must pin, none of which the remote-shaped release does for it:

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.

Decided sub-decisions

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 posture and SECRET_KEY_BASE provisioning 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 spawns the Rust workspace too

Bundle and supervise beamtalk workspace create 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 — for no benefit to the "attach to what's already running" use case.

Consequences

Positive

Negative

Neutral

Implementation

  1. Front-side hooks (three small, additive changes). (a) seed ensure_distributed/0's sname with the workspace id (or a BT_ATTACH_NODE_SUFFIX env) so two front processes can't collide on epmd; (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. 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. (~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. (~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 posture actually takes. 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. (~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.

References