Skip to content

Discover local t3 serve instances for explicit pairing - #4474

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

Discover local t3 serve instances for explicit pairing#4474
colonelpanic8 wants to merge 17 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 in desktop settings

  • Headless t3 serve instances on loopback now publish a signed advertisement file under XDG_RUNTIME_DIR with strict ownership/permission checks; the desktop reads these via a new DesktopLocalServerDiscovery service and exposes them in Connection Settings as 'Available on this computer'.
  • A new POST /api/auth/local-pair endpoint validates a filesystem-based nonce challenge and issues a pairing credential; the desktop writes the challenge file, posts it, and calls connectPairing to complete onboarding.
  • Introduces a DesktopBackendMode setting (managed | client-only) persisted in app settings and overridable via --backend-mode CLI flag; switching modes schedules an app relaunch and reverts the setting if relaunch fails.
  • In client-only mode the desktop skips starting the local backend, serves renderer assets statically or proxies a dev server via a refactored ElectronProtocol discriminated-union registration (source: 'proxy' | 'static'), and suppresses local environment bootstraps and primary environment target resolution.
  • UI components (Sidebar, CommandPalette, thread status indicators, project grouping) now use a new EnvironmentPresenceScope to classify environments as remote when the app owns no local backend.
  • Risk: local server advertisement and pairing are Linux-only (requires 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-only mode), discover loopback t3 serve processes 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 backendMode plus --backend-mode CLI override is latched at startup. In client-only, bootstrap skips the backend pool, registers IPC, and opens the window on renderer readiness (dev proxy or packaged static assets via new source: "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_DIR while LocalServerDiscoveryState is active. The desktop scans, validates permissions/loopback/identity via probe, and exposes discoverLocalServers / pairLocalServer IPC (handshake in main process: write 0600 nonce challenge, POST to /api/auth/local-pair, validate pairing URL, always delete challenge). Server adds localPair auth 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.

@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: a785a55a-8f3e-43b9-90ba-ac92f97bab88

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 authentication mechanism. It adds a credential-issuing endpoint (/api/auth/local-pair) using filesystem-based challenge/response authentication, new desktop backend modes, and substantial UI changes. The authentication and security implications warrant careful human review.

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
Comment thread apps/desktop/src/app/DesktopApp.ts
@colonelpanic8
colonelpanic8 force-pushed the desktop-local-server-discovery branch from 9d90bf8 to ec578bf Compare July 25, 2026 03:23
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 25, 2026
…ed local backend + loopback pairing)

# Conflicts:
#	apps/web/src/components/CommandPalette.tsx
#	apps/web/src/components/SidebarV2.tsx
#	apps/web/src/routes/_chat.tsx
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 25, 2026
…ed local backend + loopback pairing)

# Conflicts:
#	apps/web/src/components/CommandPalette.tsx
#	apps/web/src/components/SidebarV2.tsx
#	apps/web/src/routes/_chat.tsx
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 25, 2026
…ed local backend + loopback pairing)

# Conflicts:
#	apps/web/src/components/CommandPalette.tsx
#	apps/web/src/components/SidebarV2.tsx
#	apps/web/src/routes/_chat.tsx
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 26, 2026
…ed local backend + loopback pairing)

# Conflicts:
#	apps/web/src/components/CommandPalette.tsx
#	apps/web/src/components/SidebarV2.tsx
#	apps/web/src/routes/_chat.tsx
colonelpanic8 added a commit to colonelpanic8/t3code that referenced this pull request Jul 26, 2026
…ed local backend + loopback pairing)

# Conflicts:
#	apps/web/src/components/CommandPalette.tsx
#	apps/web/src/components/SidebarV2.tsx
#	apps/web/src/routes/_chat.tsx
@colonelpanic8
colonelpanic8 force-pushed the desktop-local-server-discovery branch from ec578bf to a4b1608 Compare July 28, 2026 02:07
Comment thread apps/desktop/src/app/DesktopApp.ts
Comment thread apps/server/src/startupAccess.ts Outdated
Comment thread apps/web/src/components/settings/ConnectionsSettings.tsx
Comment thread apps/server/src/startupAccess.ts Outdated
Comment thread apps/web/src/components/settings/ConnectionsSettings.tsx Outdated
Comment thread apps/web/src/components/settings/ConnectionsSettings.tsx
colonelpanic8 and others added 5 commits July 27, 2026 19:47
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>
colonelpanic8 and others added 11 commits July 27, 2026 19:47
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 f83cf79 to 00de753 Compare July 28, 2026 02:51

@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 2 potential issues.

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 00de753. Configure here.

Comment thread apps/desktop/src/ipc/methods/backendMode.ts
Comment thread apps/desktop/src/app/DesktopLocalServerDiscovery.ts
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