Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions .plans/21-roaming-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ machine's projects are visible but dead.
> cloud/relay discovery later, everything downstream identical. Same
> discipline as D0's `RoamingBlobStore` (M7 storage backend) — cloud
> arrives as a new producer behind an existing seam, not a redesign.
> 2026-07-05 — M2.5 analysis pass done (see [M2.5 analysis](#m25-analysis-2026-07-05)):
> unified pairing is feasible with one structural correction — pairing codes are
> single-use, so the laptop's server orchestrates the handshake from one
> exchange (attach bearer + mirror credential both derive from it); the
> machine-credential route becomes flag-independent and carries the sync-options
> consent; the Add Environment dialog degrades to attach-only against servers
> you don't administer; the live→offline row flip needs the sidebar merge to
> consult connection liveness (cached snapshots of a dead peer currently
> suppress the Materialize rows).
> 2026-07-05 (evening) — **Canonical workflow section added; M2 deviation
> recorded** (user decision, restated during first real two-machine use).
> M2 shipped pairing as a standalone "Machine sync" flow establishing only
Expand Down Expand Up @@ -886,6 +895,149 @@ Decisions/deviations recorded during implementation and review:
- **VaultSync watcher scopes swap atomically** (reviewer-caught leak:
close-then-set let concurrent rescans strand watch fibers).

## M2.5 analysis (2026-07-05)

Assumption check against current code before M2.5 implementation, run
explicitly against the canonical workflow. Confirmed as planned: the existing
pairing/thin-client flow is the Add Environment → "Remote link" dialog in
`ConnectionsSettings.tsx` (host + pairing code, or a pasted pairing URL) →
`connectPairing` (`packages/client-runtime/src/connection/onboarding.ts`) →
`/.well-known/t3/environment` + `/oauth/token` exchange → persisted
`BearerConnection{Target,Profile,Credential}` in the connection catalog
(IndexedDB on web, encrypted `connection-catalog.json` via IPC on desktop);
remote conversations already work over that attach (environment-scoped
`thread.create`/`thread.turn.start` via WS or `/api/orchestration/dispatch`);
the sidebar already renders ONE list merging local, attached-remote, and
mirrored-offline rows, and `selectOfflineRoamingProjects` already suppresses a
registry row whenever a live row covers the same repository (canonicalKey).
M1/M2 server plumbing (add-peer, machine-credential mint, auto-enroll,
materialize) is all reusable as-is; `RoamingAutoEnroll` already triggers on
peer-added, settings changes, and project-created — no new hook needed on
either side of the unified handshake.

**Deviations recorded (design deltas applied to the M2.5 row):**

- **One code, one exchange — the local server orchestrates the handshake.**
Pairing credentials are single-use at `/oauth/token`, so the M2.5 row's "one
pairing code establishes attach AND mirror" cannot mean two exchanges. The
client sends the introduction to its own server (`POST /api/roaming/peers`,
extended); that server exchanges the code once at the peer, and from the
resulting bearer (a) mints the 365-day `roaming:mirror` machine credential
(existing route) and (b) mints + immediately exchanges a fresh
standard-scoped pairing credential on the peer, returning the resulting
**standard** attach bearer in `RoamingAddPeerResponse`. The client registers
the attach from that bearer (the registration half of `connectPairing`,
factored out). The client's persistent session stays standard-scoped.
Handshake-credential hygiene (from design review): the exchange requests
exactly the scopes the handshake needs (standard + `access:write` — the
delegation cap forces standard to be present so the attach code can be
minted), never the code's full grant; the handshake session is revoked on
the peer best-effort once the mints complete (otherwise every pairing
strands a live ~30-day privileged session in the peer's SessionStore); the
bearer-bearing `addPeer` response carries `cache-control: no-store` like
every auth credential response and the token never reaches logs; and since
`addPeer` tries `baseUrls` in order, the response's `attach` block names
the base URL that actually worked so the client never registers an
unreachable one.
- **The code carries the capability; the preset carries the UX.** The
handshake's peer-side mints require `access:write`, so the pairing code for
your own machine must be admin-scoped. The existing create-pairing-URL
dialog already has scope presets (Read only / Standard) plus per-scope
checkboxes and an access:write warning; it gains one preset — "Another
machine of yours" (admin scopes). No new generation flow, no sync concept.
- **Attach-only degradation is a first-class outcome, not an error.** The same
Add Environment dialog is how users attach to servers they *don't*
administer. If the exchanged bearer lacks `access:write`, the handshake
skips the mirror half and returns that bearer for attach registration, with
`mirror: null` + reason; the UI attaches normally and shows an honest
notice. Remote conversations (canonical step 3) work in both outcomes;
only offline availability needs the admin-capable code. UX-cliff guard
(from review): which outcome you get hinges on a preset chosen on the
*other* machine at code-generation time, so the degradation notice must say
how to get the full pairing (regenerate the code with the
"Another machine of yours" preset), and that preset keeps a loud
administrative-access warning.
- **The pairing routes become flag-independent; pairing IS how the flag turns
on — on both machines.** Today ALL roaming routes 404 while the `roaming`
setting is off, which dead-ends the unified flow twice: the peer's mint
route (desktop would need a settings ritual before pairing could succeed)
AND the laptop's own `POST /api/roaming/peers` (a fresh laptop defaults to
`roaming=false`, so the very call that starts the handshake would 404 —
design-review blocker). Both routes are un-gated from the flag (auth
unchanged: `access:write`). A successful mint flips the peer's `roaming`
on; a successful `addPeer` flips the local one on and applies the dialog's
sync options locally. Consent story: the desktop user consented by
generating the admin-scoped code; the laptop user confirmed the one dialog.
- **Sync-options propagation amends M2's "not mirrored" consent decision —
deliberately and narrowly.** M2 recorded `roamingSecretsSync` as "not
mirrored — each machine consents to shipping its own files"; a unified
pairing that leaves the desktop's secrets capture off would regress
canonical step 4 (materialize with synced secrets must work out of the box)
into a desktop-side settings ritual. Amendment: the mint request gains
`syncOptions: { secretsSync?: boolean }`, and the peer applies it **only
when the same mint flips `roaming` off→on** (first pairing). A later
pairing never touches it, and an explicit prior choice is never overridden
remotely — the per-machine setting stays the mechanism and the ordinary
settings row stays the way to change your mind. (An `access:write` caller
could already reach files via a terminal, so this grants no new capability
class; what it changes is turning a manual capability into the standing
default flow, which is exactly what the canonical workflow's pre-checked
"Secret files" row asks for.)
- **Live→offline is a real UI gap (reviewer-grade catch).** A disconnected
bearer environment keeps its cached shell snapshot, so a dead peer's
projects stay in `projectsAtom` as live-looking rows AND suppress the
mirrored offline rows via the canonicalKey dedup — exactly the "visible but
dead" state the canonical workflow prohibits. Fix in the sidebar merge,
with review-specified semantics: liveness = shell status `live` OR
`synchronizing` (reconnects pass through synchronizing — flapping rows to
offline on every blip is worse than a short stale window); the filter
applies only to non-primary, non-desktopLocal environments (the primary and
WSL-style local sandboxes are never "dead peers"); and the SAME filtered
project set must feed both the rendered rows and the
`selectOfflineRoamingProjects` dedup keyset — filtering only one of the two
would show a stale live row and its offline twin simultaneously. Accepted:
WS disconnect detection has latency, so a brief visible-but-dead window
exists before the flip; acknowledged, not fixable at this layer. (The
reverse dedup — peer alive, registry row suppressed — already works.)
- **Peer-introduction seam lands client-side.** `PeerIntroduction =
{ baseUrls, pairingCredential, label? }` with a `pairMachine(introduction,
syncOptions)` operation in `packages/client-runtime` as the single consumer;
the manual dialog is producer #1, relay/cloud discovery later constructs the
same value (its hooks — `relay/discovery.ts`, `CloudLinkRow` — already
yield per-environment availability + connect scopes, so the shape fits).
Server-side, `RoamingAddPeerRequest` already IS the introduction; it gains
only `syncOptions`.
- **Sync options render in the Remote-link dialog; the secrets toggle
survives as one settings row.** The JetBrains-style step (Projects
checked+disabled; Secret files pre-checked; WIP/Conversations arrive
M4/M6) renders inside the existing Add Environment → Remote link dialog.
`MachineSyncSettings.tsx` is deleted; `roamingSecretsSync` gets an ordinary
settings row (no section, no concept) for changing your mind later.
- **Known scope-schema wart (no action):** `roaming:mirror` exists in the
auth contract but `/oauth/token`'s explicit-scope parser omits it. The
design never requests it over OAuth (the mint route issues it), so this
stays as-is; noted for M7.
- **Harness acceptance scope:** the M0 harness proves the full transport
chain headlessly — one `POST /api/roaming/peers` call with a real pairing
code yields both credentials; the attach bearer opens the peer's WS
(`/api/auth/websocket-ticket` → `subscribeShell`) and streams its projects;
`thread.create` dispatched over that attach lands in the peer's shell
stream. A full LLM turn depends on provider keys being present and is run
when available; the canonical-workflow UI walk (one list, live rows,
offline flip) is demonstrated on the real desktop build. This split stays
in the exit criteria explicitly — the harness half must never quietly
substitute for the UI walk.

This design was independently reviewed before implementation (2026-07-05,
opus-4.8): verdict "ship with changes"; the changes (local route un-gating,
handshake scope-narrowing + session revoke, first-pairing-only consent
propagation, credential-response hygiene, precise liveness semantics, UX-cliff
copy) are folded into the bullets above. The review also settled the
alternative two-exchange design (mint the code with `remainingUses: 2`)
against: the uses counter exists only for the in-memory desktop-bootstrap
grant, not DB-backed pairing links, and a 2-use admin code is strictly weaker
under interception.

## Execution process

**Branching (fork discipline):** `main` tracks upstream and receives their
Expand Down