Skip to content

Discover local t3 serve instances for explicit pairing#4474

Open
colonelpanic8 wants to merge 10 commits into
pingdotgg:mainfrom
colonelpanic8:desktop-local-server-discovery
Open

Discover local t3 serve instances for explicit pairing#4474
colonelpanic8 wants to merge 10 commits into
pingdotgg:mainfrom
colonelpanic8:desktop-local-server-discovery

Conversation

@colonelpanic8

@colonelpanic8 colonelpanic8 commented Jul 24, 2026

Copy link
Copy Markdown

Stacked on #4444. Base is main, so until #4444 merges this PR's diff also shows the client-only commit. The reviewable changes are the commits after #4444: local discovery, the credential-free filesystem challenge, and the atomic publication/rollback fix.

What Changed

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 presence record (0700 directory, 0600 file) under XDG_RUNTIME_DIR, written atomically via temp+rename, published once, and removed on shutdown. It contains no credential.
  • The desktop scans that directory and validates ownership, permissions, size, path containment, startedAt sanity, canonical loopback URLs, and environment identity before presenting a candidate.
  • Pairing requires an explicit Pair action. The handshake runs entirely in the Electron main process behind a 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 saves to the encrypted connection catalog and never takes ownership of the server process.

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_DIR being 0700 already answers that question:

  1. The desktop writes a 256-bit nonce to a 0600 file in $XDG_RUNTIME_DIR/t3code/challenges/.
  2. It POSTs { instanceId, challengePath, nonce } to the unauthenticated /api/auth/local-pair.
  3. The server requires: discovery active in this process, request arrived over loopback, matching instanceId, realPath on 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.
  4. Only then is a credential issued — in the HTTP response, never written to disk.
  5. The desktop deletes the challenge file in an ensuring block, 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_credential so 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 adds AuthAccessWriteScope (mint durable credentials with caller-chosen scopes, revoke other sessions) and AuthRelayWriteScope (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

  • TOCTOU between stat/realPath and readFileString, 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+readAlloc is the correct fix and is expressible in this Effect version, though realPath containment would still be needed since OpenFlag has no O_NOFOLLOW.
  • Unbounded scan. No cap on directory entries, and each Pair click re-scans. Same-UID only, but a slice cap would be cheap insurance.
  • No stale-file reaping after kill -9. Bounded in practice by tmpfs clearing at logout, and expired records are filtered before the network probe, so they cost only a stat + read.
  • The environment-identity probe is a staleness/port-reuse check, not an authenticity check — a hostile local server writes both the record and the probe response. It now carries more weight than before: advertisements no longer expire, so a record left behind by a kill -9'd process is rejected by the probe failing rather than by a timestamp.
  • Wrong-uid rejection is not covered by tests. Simulating it needs a file owned by another uid, which requires root or a second account; the suite runs unprivileged. The equal-uid branch is exercised by the happy path.
  • Needs manual re-verification against a real t3 serve before 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 — clean

  • CI=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 tests

  • Loopback URL validation exercised against 0x7f.1, 127.1, 2130706433, [::ffff:127.0.0.1], credentials-in-authority, and trailing-dot forms

  • Regression test locks in that a discovered pairing URL with a query string is rejected (readHostedPairingRequest honours a host= param)

Note

Add local t3 serve instance discovery and explicit pairing to desktop settings

  • Introduces a DesktopLocalServerDiscovery service that reads filesystem advertisement files written by headless t3 serve instances (Linux, loopback-only) and exposes discover and pairLocalServer operations via IPC to the renderer.
  • Adds a POST /api/auth/local-pair endpoint on the server that validates a filesystem-based pairing challenge (nonce file, ownership, containment, timing-safe compare) and issues a pairing credential without requiring a bearer token.
  • Adds a DesktopBackendMode service and settings field (managed | client-only) with IPC get/set, app relaunch on mode change, and rollback on relaunch failure.
  • Updates ConnectionsSettings UI to show a 'Desktop application' backend-mode selector and an 'Available on this computer' section listing discovered servers with Pair / Pair again actions.
  • In client-only mode the desktop skips starting a local backend, serves the renderer from packaged static assets via a new source:'static' branch in ElectronProtocol, and returns empty lists from local-environment IPC calls.
  • Risk: advertisement validation relies on filesystem ownership and mode bits; behavior is Linux-only and silently skipped on other platforms.

Macroscope summarized ec578bf.


Note

High Risk
Introduces unauthenticated local pairing and credential issuance gated on filesystem proofs, plus a new desktop startup mode that changes how the renderer and backends are loaded—both are security- and reliability-sensitive.

Overview
Local t3 serve discovery and pairing lets a Linux desktop client find headless loopback servers for the same user and connect after an explicit Pair click, without copying a terminal URL. Headless servers publish credential-free presence JSON under XDG_RUNTIME_DIR; the desktop scans, validates ownership/permissions/loopback/identity, and runs the handshake in the main process via discoverLocalServers / pairLocalServer IPC so the renderer never holds ambient secrets. Pairing writes a private nonce file, POSTs to new /api/auth/local-pair, and only then receives a pairing URL; the server validates loopback, instance match, canonical challenge paths, and constant-time nonce compare before issuing credentials.

Desktop backend mode (managed | client-only) is persisted in settings, overridable via --backend-mode, latched at startup (invalid CLI → fatal error), and changeable from settings with relaunch and rollback on failure. In client-only, bootstrap skips the backend pool, registers IPC, opens the window on handleRendererReady, and serves the UI via ElectronProtocol source: "static" (packaged client) or dev proxy; shutdown skips stopping backends.

Renderer and UI updates include environment presence scope (remote vs local labeling), Connections logic for Pair / Pair again candidates, and empty local bootstraps in client-only mode.

Reviewed by Cursor Bugbot for commit ec578bf. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a33f56a-6818-4409-83aa-bf82b9a7071b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Jul 24, 2026
Comment thread apps/web/src/environments/primary/target.ts
@macroscopeapp

macroscopeapp Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces a significant new feature: local server discovery and pairing with a new 'client-only' desktop mode. It includes new authentication mechanisms (filesystem-based proof of identity), runtime behavior changes, and security-sensitive code in the auth package. An unresolved review comment also identifies a potential bug in error handling.

You can customize Macroscope's approvability policy. Learn more.

Comment thread apps/server/src/localServerAdvertisement.ts Outdated
@colonelpanic8
colonelpanic8 force-pushed the desktop-local-server-discovery branch from c3ff84a to 8f3ac9f Compare July 24, 2026 21:59
Comment thread packages/contracts/src/localServerDiscovery.ts Outdated
Comment thread apps/desktop/src/app/DesktopLocalServerDiscovery.ts
Comment thread apps/desktop/src/ipc/methods/backendMode.ts Outdated
Comment thread apps/desktop/src/ipc/methods/backendMode.ts
@colonelpanic8
colonelpanic8 force-pushed the desktop-local-server-discovery branch from 8f3ac9f to 836a24f Compare July 25, 2026 00:28
Comment thread apps/web/src/components/settings/ConnectionsSettings.logic.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9d90bf8. Configure here.

yield* installDesktopIpcHandlers();
yield* logBootstrapInfo("bootstrap ipc handlers registered");
if (!(yield* Ref.get(state.quitting))) {
yield* handleClientOnlyRendererReady(desktopWindow.handleRendererReady);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Client-only window errors quit app

Medium Severity

In client-only bootstrap, handleClientOnlyRendererReady logs then re-fails main-window creation, so bootstrap hits fatal startup and quits. The prior Effect.catch only logged and let startup finish, leaving backendReadyRef set so activate could retry—matching managed mode’s swallowed handleBackendReady path.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9d90bf8. Configure here.

colonelpanic8 and others added 10 commits July 24, 2026 20:20
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>
@colonelpanic8
colonelpanic8 force-pushed the desktop-local-server-discovery branch from 9d90bf8 to ec578bf Compare July 25, 2026 03:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant