ADR 0286: Remote-host desktop kernel
- Status: Accepted for implementation
- Date: 2026-09-18
- Decision: D449
- Related: ADR 0205 (D373 / D374 / D375), ADR 0285 (D448),
03-runtime/19-remote-agent-control-protocol.md§3.4, §4, §5, §7, §8,05-security/02-remote-control-security.md§3.4,06-delivery/07-remote-control-rollout.md§2 R2
Context
ADR 0285 delivered the RACP-WS transport and pairing on both ends. The pi-host bundle now binds a real WebSocket server on loopback and speaks the frozen contract; a RacpClient in packages/racp reaches it with header authentication. What was still missing on the desktop side of R2 was the kernel — the code that lets the renderer treat a remote session exactly the way it treats a local one (spec §3.4). Without it, the transport can only be observed from tests.
Three constraints shaped the kernel:
- The renderer must never learn the transport. Every existing per-session IPC call — 17 channels covering agent, session, tool approval, ask-tool, and plan resolution — has one call site in
apps/desktop/src/lib/api.tsthat cannot know whether the answer came fromhost-coreor from a paired host. - The frozen architecture pins
apps/desktop/electron/main/index.tsat 1500 LOC (scripts/check-architecture.mjs). Every new module must live outside it; wiring must fit the existing composition root. - The desktop cannot open a network listener of its own (security §7). All outbound flows go through the RACP client to a host the user paired with, and every registration is per-session so a lost host cannot silently hijack a local session id.
Decision
The desktop-side R2 kernel is five modules and one boot hook, all under apps/desktop/electron/main/remote/ (module state) and apps/desktop/electron/main/bootstrap/ (boot state), and one new workspace dependency (@pi-desktop/racp).
A single interception seam:
backend-router.ts. The router is consulted fromipc/register.ts'shandle()wrapper; it returns the sentinelROUTE_LOCALwhen noRemoteBackendis registered for the call's session id, and the local handler runs unchanged. A session becomes remote only once its renderer-visible id (remote:<hostKey>:<hostSessionId>, mirroringnative-pi:) has an explicit registration. This makes remote support byte-for-byte compatible when disabled and prevents accidental routing of a local id.A stateless per-request session id.
sessionIdForCallrecovers the session from either the first positional argument,first.sessionId,first.id(sessionGetuses this shape), or a<remoteSessionId>#racp-approval:<hostApprovalId>requestId used by tool approval. This last one letstoolResolvePermissionroute without a correlation map: the router encodes the session into the id it hands the renderer and decodes it when the renderer echoes the id back.A transport-agnostic backend:
remote-backend.ts. OnecreateRemoteBackend({hostKey, client, ...})instance serves every session of a paired host — the router registers it under each id. The backend translates 17 channels to RACP requests and reshapes the results back into the exact response shapesapps/desktop/src/lib/api.tsalready returns for the local handler. Channels the remote profile does not cover (agentSteer, attachments, desktop-only settings) either return false fromhandles()or throwCAPABILITY_UNAVAILABLE, so those calls fall back to the local handler while the session's transcript stays remote.Two synthesis rules to reconcile schema mismatches without a round-trip.
- Tool-approval resolution has no session id in its wire payload, so the backend encodes
<remoteSessionId>#racp-approval:<hostApprovalId>into the requestId (§ Decision 2) and decodes it back forapproval/respond. plansResolvereturnsPlanResolutionResultto the renderer, but RACP'sapproval/respondreturns onlyRacpApprovalResult. The backend synthesizes a minimalPlanProposalfrom the request identity to dismiss the card optimistically; the authoritative snapshot arrives on the follow-upsession.changedevent and replaces the placeholder.
- Tool-approval resolution has no session id in its wire payload, so the backend encodes
A pure event bridge:
remote-event-bridge.ts. RACP events are translated intoIPC.event.agentMessage/IPC.event.sessionsChangedfor the renderer. Item/turn/tool payloads already carry a localAgentEventinpayload.eventand forward verbatim under anAgentEventEnvelopekeyed by the remote session id.approval.requestedof kindtoolbecomes a localtool_permission_requestwith the encoded requestId; plan and goal approvals ride the followingplanning_stateevent and are dropped. AnonLifecyclecallback signalssession.created/archivedfor the router without a second subscription to the same stream.A coordinator per paired host:
remote-host-connection.ts.createRemoteHostConnection({hostKey, client, router, emit})composes the backend, the bridge, and the router. Itsopen()sequence closes the create-race between listing sessions and receiving lifecycle events: attach listener → subscribe host scope →session/list→ per-session subscribes.close()unregisters every session and drops internal state; it is idempotent and safe to call beforeopen().A
RemoteHostClientseam with multi-listenersubscribe. The connection consumes{request, subscribe}.packages/racpexposesRacpClient.onEventas a single-slot construction option, which is not enough for a bridge + resync watchdog + later features.racp-remote-host-client.tsis the only file inelectron/main/remote/that imports@pi-desktop/racp; it wraps the client, fans out its callback to everysubscribe()listener, and swallows listener throws so a bad subscriber cannot silence the others.Encrypted-at-rest registry:
remote-host-registry.ts. Paired hosts live in<dataDir>/remote-hosts.json. Device tokens are encrypted with Electron'ssafeStoragebefore write and decrypted on read; a stolen file without keychain access reveals only URL and label. The registry is injected with anEncryptionPort(isAvailable / encryptString / decryptString) so Node-side tests supply a fake without pulling in Electron.upsertrefuses to write when the keychain is unavailable;listdrops any record it cannot decrypt rather than surfacing an empty token that would auth-fail downstream.Boot hook:
bootstrap/remote-hosts.ts.createRemoteHostsBoot(...)reads the registry, opens one adapter + one connection per host, and returns{open, closeAll}. Sequential open — a host's failure is logged and skipped, not fatal. An empty registry (the default install) is a full no-op: nothing connects, no backend registers, every renderer call keeps hitting the local handler byte-for-byte.bootstrap/startup.tscallsopen()in the background so a slow host never delays the first window;bootstrap/shutdown.tscallscloseAll()from the existing shutdown promise so paired sockets are drained before host-core is torn down. A single module-levelactiveRemoteHostsBoothandle bridges startup and shutdown without expandingindex.tspast its 1500-LOC ceiling.
Invariants the kernel keeps
- Renderer transport-agnosticism. The renderer's
api.tscode does not mention "remote". Its session ids may be namespaced; every response shape it parses is the local one. - Router-off default. With no
RemoteBackendregistered,route()returnsROUTE_LOCALfor every call and the existing handler runs unchanged. Adding the kernel to a build without pairing is a zero-behavior change. - Least privilege at rest. No plaintext device token ever touches disk. A
safeStorageunavailable environment cannot write a token; it can still read what was already written when that platform was available. - Bounded shutdown.
closeAllcloses every paired socket inside the samePromise.allSettledblock that handles plugin, sidecar, and MCP disposals, beforehost-coreis disposed, so in-flight remote turns can send their abort over a live socket.
Out of scope
The kernel is complete for a paired host to answer renderer calls and stream events. What is scheduled for later stages of R2:
- Pairing UX (renderer + IPC). Settings surfaces to enter a URL and pairing token, exchange it, and store the device token. The registry API is ready for this; the surface is not.
- SSH bootstrap (Stage 4). A supervisor that detects system
ssh, downloads thepi-host-bundle(verified by SHA-256 from ADR 0285's release pipeline), starts the remote binary, and opens the-Ltunnel. Every paired host today assumes the loopback URL already exists. - Terminal work-panel client (Stage 5). RACP terminal events are dropped by the event bridge; the work-panel session client will consume them.
- Reverse tool relay (Stage 6). A
RelayToolPortbridge that lets the agent host run local desktop tools against a remote session. Belongs on the agent-host and pi-host, not onpackages/racp. - Resync watchdog.
resync.requiredevents are dropped today; the connection layer will eventually rebuild subscriptions from the last cursor per session (RacpClient.cursorFor). - Multi-listener contract.
subscribe()is used by exactly one consumer today (the event bridge); Stage 3b's resync watchdog will be the second.
Alternatives considered
- Route from each per-domain IPC handler. Every one of 17 handlers would need to know about "remote" and duplicate the same dispatch. Rejected: God-modules would grow, and any new channel would need to be wired in twice.
- Bake remote knowledge into the renderer. A
remote:prefix visible to the renderer forcesapi.tsto branch, and every store slice ends up aware of the transport. Rejected by spec §3.4. - Have the connection layer own its own RacpClient construction. It would couple the coordinator to
packages/racpand prevent unit tests from running without a real client. Rejected in favour of the injectedRemoteHostClientseam. - A single-listener
RemoteHostClient. Simpler, but forces the resync watchdog and the event bridge to share the same callback. Rejected: their concerns are independent and their subscriptions should be too. - Plain-text registry. Simpler read/write, but a compromised backup would hand attackers a device token that authenticates against a real
pi-host. Rejected by security §3.4.
Testing
Every module has a node --test fixture that exercises the seam in isolation (fake RACP client, fake encryption, fake router). The RACP adapter runs against the real in-memory harness (@pi-desktop/racp/test-harness), so its fan-out and lifecycle contracts are checked against the same client the production factory builds. The full desktop suite runs 2120+ tests with the kernel on and every one passes; no test:e2e:* scenario is scheduled for the kernel alone because it is dead code until pairing lands.
Consequences
- The desktop can host a paired remote
pi-hosttoday; adding a URL and device token to<dataDir>/remote-hosts.json(encrypted-at-rest throughsafeStorage) makes the kernel connect, register sessions, and stream events into the existing renderer, no other flag or setting required. apps/desktopnow depends on@pi-desktop/racp, and the racp package publishes a./test-harnessexport subpath. Both changes are additive.apps/desktop/electron/main/index.tsstays at exactly 1500 LOC. The startup/shutdown bridge is a module-level handle insidebootstrap/remote-hosts.ts— small, contained, and easy to remove once R2b lets the wiring live inside a broader remote-hosts service object.- Any Stage 4–7 work (SSH bootstrap, terminal work-panel client, reverse tool relay, pairing UX) plugs into existing seams — the router, the event bridge, the registry — and does not need to revisit the transport layer.