Discover local t3 serve instances for explicit pairing - #4474
Discover local t3 serve instances for explicit pairing#4474colonelpanic8 wants to merge 17 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
ApprovabilityVerdict: Needs human review This PR introduces a significant new feature: local server discovery and pairing with a new authentication mechanism. It adds a credential-issuing endpoint ( You can customize Macroscope's approvability policy. Learn more. |
c3ff84a to
8f3ac9f
Compare
8f3ac9f to
836a24f
Compare
9d90bf8 to
ec578bf
Compare
…ed local backend + loopback pairing) # Conflicts: # apps/web/src/components/CommandPalette.tsx # apps/web/src/components/SidebarV2.tsx # apps/web/src/routes/_chat.tsx
…ed local backend + loopback pairing) # Conflicts: # apps/web/src/components/CommandPalette.tsx # apps/web/src/components/SidebarV2.tsx # apps/web/src/routes/_chat.tsx
…ed local backend + loopback pairing) # Conflicts: # apps/web/src/components/CommandPalette.tsx # apps/web/src/components/SidebarV2.tsx # apps/web/src/routes/_chat.tsx
…ed local backend + loopback pairing) # Conflicts: # apps/web/src/components/CommandPalette.tsx # apps/web/src/components/SidebarV2.tsx # apps/web/src/routes/_chat.tsx
…ed local backend + loopback pairing) # Conflicts: # apps/web/src/components/CommandPalette.tsx # apps/web/src/components/SidebarV2.tsx # apps/web/src/routes/_chat.tsx
ec578bf to
a4b1608
Compare
Let the desktop app run as a pure client for remote or independently managed backends, instead of always owning a local backend process. - Add a persisted `managed` / `client-only` backend mode with a launch-only `--backend-mode` override, exposed over IPC. - In client-only mode, bootstrap skips the backend pool entirely and serves the renderer from packaged static assets (or the dev proxy), opening the window on renderer readiness rather than backend readiness, and skipping backend shutdown on quit. - `ElectronProtocol` takes an explicit `source: "proxy" | "static"`. Static serving adds path-safe file resolution, SPA fallback, MIME types, CSP and HEAD support. - Web routing treats client-only like hosted static, and local environment bootstraps are empty, so a client-only desktop is never stranded on a dead primary-backend state. Co-Authored-By: Claude <noreply@anthropic.com>
Let a Linux desktop client find a loopback `t3 serve` running as the same user and pair with it, without copying a URL out of the terminal. - A headless server bound to loopback publishes an owner-only (0700 directory, 0600 file) JSON advertisement under XDG_RUNTIME_DIR, written atomically, rotated before the credential expires, and removed on shutdown. The advertisement owns its own pairing credential rather than the one printed to the terminal. - The desktop scans that directory and validates ownership, permissions, size, path containment, timestamps, canonical loopback URLs and environment identity before presenting a candidate. - Pairing requires an explicit Pair action, re-reads the advertisement first so a rotated credential is used, saves to the encrypted connection catalog, and never takes ownership of the server process. Co-Authored-By: Claude <noreply@anthropic.com>
…redential The advertisement previously carried a live admin-scoped pairing URL and rotated it every ~4 minutes, so a credential was continuously at rest in XDG_RUNTIME_DIR for the whole uptime of `t3 serve`. Same-UID access was already game over for code execution, but the admin scopes went further: AuthAccessWriteScope mints durable credentials and AuthRelayWriteScope relay-links the environment, turning a passive one-shot file read into persistent, off-machine access. Advertise presence instead, and prove same-user at pair time: - The record is now credential-free: instanceId, pid, startedAt, httpBaseUrl, environmentId, label. Published once, removed on shutdown. The rotation loop, the shutdown revoke and the tracked credential are all gone. - New unauthenticated `POST /api/auth/local-pair`. The caller writes a 256-bit nonce to a 0600 file in the owner-private challenge directory and presents the path; the server canonicalizes both paths, requires containment, requires a regular 0600 file owned by its own uid, and compares contents to the nonce in constant time. Only then does it issue a credential, in the response. Every check fails closed and all rejections collapse to one `invalid_credential`. - The desktop performs the handshake in the main process behind a new `pairLocalServer(instanceId)` IPC and deletes the challenge file in an `ensuring` block. The renderer now receives presence records only, and a credential exactly once, after the user clicks Pair. Because advertisements no longer expire, a record left by a killed process is rejected by the environment-identity probe instead. Co-Authored-By: Claude <noreply@anthropic.com>
The sidebar decided a thread or project was remote by comparing its environment against the primary environment id. A client-only desktop (and the hosted static web app) never registers a primary, so that id is always null and every row read as local: no cloud/server icon, and no environment name on the project header. Make the comparison explicit about the two different meanings of a null primary id. `isRemoteEnvironmentId` now takes an `ownsLocalEnvironment` flag: when the app can never serve an environment from its own backend, every environment is remote; when it can, a null primary id still means "the local backend has not registered yet" and nothing is flagged remote. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f83cf79 to
00de753
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 00de753. Configure here.

What Changed
Let a Linux desktop client find a loopback
t3 serverunning as the same user and pair with it, without copying a URL out of the terminal.XDG_RUNTIME_DIR, written atomically via temp+rename, published once, and removed on shutdown. It contains no credential.startedAtsanity, canonical loopback URLs, and environment identity before presenting a candidate.pairLocalServer(instanceId)IPC, so the renderer receives presence records only — and a credential exactly once, after the click, for the server that was clicked.Pairing design — no credential at rest
The advertisement carries no credential. It is presence metadata only:
instanceId,pid,startedAt,httpBaseUrl,environmentId,label.A client proves it is the same local user at pair time, because
$XDG_RUNTIME_DIRbeing 0700 already answers that question:$XDG_RUNTIME_DIR/t3code/challenges/.{ instanceId, challengePath, nonce }to the unauthenticated/api/auth/local-pair.instanceId,realPathon both the directory and the supplied path with containment enforced, a regular 0600 file owned by its own uid and under the size cap, and contents equal to the nonce compared in constant time.ensuringblock, success or failure.A different-uid attacker cannot create a file in a directory they do not own, and cannot name an existing one because they would have to supply contents they cannot read. Every check fails closed, and all rejections collapse to a single
invalid_credentialso the response cannot be used to probe which one tripped. Rejection reasons are logged at debug level only.The challenge directory is deliberately separate from the advertisement directory, so the server never reads a caller-named path out of the directory it publishes into. The endpoint is closed entirely unless discovery is active, which means non-Linux, non-headless, and non-loopback servers do not expose it at all.
Why this shape. An earlier revision stored a live pairing URL in the advertisement and rotated it every ~4 minutes. Same-UID file access is already sufficient for code execution, so that was defensible — but the credential was
AuthAdministrativeScopes, and admin addsAuthAccessWriteScope(mint durable credentials with caller-chosen scopes, revoke other sessions) andAuthRelayWriteScope(relay-link the environment). That turned a passive one-shot file read into persistent, off-machine access, which is exactly the containment the loopback-only argument was supposed to provide.Removing the credential from disk also deleted the rotation loop, the shutdown revoke, and the tracked-credential state — so a class of liveness bugs went with it.
Known lower-severity items, not addressed here
stat/realPathandreadFileString, on both the advertisement read and the challenge read. Crosses no privilege boundary: winning it needs write access inside a 0700 directory, i.e. the same UID. Now largely uninteresting on the advertisement side, since winning that race yields a hostname and a label rather than a credential.open+fstat+readAllocis the correct fix and is expressible in this Effect version, thoughrealPathcontainment would still be needed sinceOpenFlaghas noO_NOFOLLOW.slicecap would be cheap insurance.kill -9. Bounded in practice by tmpfs clearing at logout, and expired records are filtered before the network probe, so they cost only astat+ read.kill -9'd process is rejected by the probe failing rather than by a timestamp.t3 servebefore merge. This revision reworked a flow that was previously hand-tested end to end in Electron — in particular the challenge file cleanup on failure paths, and that the endpoint is genuinely unreachable on a non-loopback bind.Checklist
Post-rebase review-fix verification: formatting and lint across all 55 changed TS/TSX files; 5 affected-package typechecks; 18 focused test files, 130 tests; server route-layer smoke test
corepack pnpm exec vp check(0 errors; 11 pre-existing unrelated warnings)corepack pnpm exec vp run --filter @t3tools/contracts --filter @t3tools/shared --filter t3 --filter @t3tools/desktop --filter @t3tools/web typecheck— cleanCI=true vitest run apps/desktop/src apps/web/src/components/settings apps/web/src/environments apps/server/src/startupAccess.test.ts apps/server/src/localServerAdvertisement.test.ts packages/shared/src/localServerDiscovery.test.ts— 66 files, 449 testsLoopback URL validation exercised against
0x7f.1,127.1,2130706433,[::ffff:127.0.0.1], credentials-in-authority, and trailing-dot formsRegression test locks in that a discovered pairing URL with a query string is rejected (
readHostedPairingRequesthonours ahost=param)Note
Add local t3 serve instance discovery and explicit pairing in desktop settings
t3 serveinstances on loopback now publish a signed advertisement file underXDG_RUNTIME_DIRwith strict ownership/permission checks; the desktop reads these via a newDesktopLocalServerDiscoveryservice and exposes them in Connection Settings as 'Available on this computer'.POST /api/auth/local-pairendpoint validates a filesystem-based nonce challenge and issues a pairing credential; the desktop writes the challenge file, posts it, and callsconnectPairingto complete onboarding.DesktopBackendModesetting (managed|client-only) persisted in app settings and overridable via--backend-modeCLI flag; switching modes schedules an app relaunch and reverts the setting if relaunch fails.client-onlymode the desktop skips starting the local backend, serves renderer assets statically or proxies a dev server via a refactoredElectronProtocoldiscriminated-union registration (source: 'proxy' | 'static'), and suppresses local environment bootstraps and primary environment target resolution.Sidebar,CommandPalette, thread status indicators, project grouping) now use a newEnvironmentPresenceScopeto classify environments as remote when the app owns no local backend.XDG_RUNTIME_DIR); macOS/Windows instances will not be discovered.Macroscope summarized 4624c4b.
Note
High Risk
Touches authentication bootstrap (
/api/auth/local-pair), credential issuance, and desktop startup/shutdown paths; pairing security depends on filesystem and loopback checks being correct on Linux.Overview
This PR lets the desktop run without spawning a managed backend (
client-onlymode), discover loopbackt3 serveprocesses on Linux, and pair on an explicit user action—credentials are issued only in the HTTP response, not stored in advertisement files.Desktop backend mode — Persisted
backendModeplus--backend-modeCLI override is latched at startup. Inclient-only, bootstrap skips the backend pool, registers IPC, and opens the window on renderer readiness (dev proxy or packaged static assets via newsource: "static"protocol handling with SPA fallback and path checks). Shutdown no longer stops backends in this mode. Settings changes that require a different effective mode trigger relaunch (with rollback if relaunch fails).Local discovery & pairing — Headless loopback Linux servers publish a credential-free JSON advertisement under
XDG_RUNTIME_DIRwhileLocalServerDiscoveryStateis active. The desktop scans, validates permissions/loopback/identity via probe, and exposesdiscoverLocalServers/pairLocalServerIPC (handshake in main process: write 0600 nonce challenge, POST to/api/auth/local-pair, validate pairing URL, always delete challenge). Server addslocalPairauth handler with fail-closed filesystem proof (loopback, instance match, contained challenge path, owner/mode/size, constant-time nonce compare) before issuing a startup pairing credential.UI & presence — Connections settings logic for pairing candidates; sidebar/command palette use environment presence scope so remote vs local labeling respects whether the app owns a local environment.
Reviewed by Cursor Bugbot for commit 4624c4b. Bugbot is set up for automated code reviews on this repo. Configure here.