Seamless npx t3 connect for remote boxes

Design principle: the smallest diff that ships the UX. No relay/infra changes, no new backend surface, no new auth primitives — every step reuses code that already exists. One PR, built as four phases with clear commit boundaries — each phase compiles, passes tests, and leaves the product working, so the PR reviews commit-by-commit. (Phase 4, web-triggered update, is an optional follow-up PR.)

$ npx t3 connect

To set up T3 Connect, open this URL and sign in:
  https://app.t3.codes/connect#B64URL_STATE_AND_CHALLENGE

Enter your authentication code: [code]

Connected as theo@t3.gg!

Run T3 Code in the background whenever this machine boots? (y/n): y

T3 Code is set up and ready to go.

Why this is a small change

The entire t3 connect data plane already works: Clerk PKCE token exchange, encrypted secret store, cloudflared relay-client install, relay environment linking, DPoP tokens. The only broken piece on an SSH box is the redirect: CliTokenManager.login() hardcodes a loopback callback (http://127.0.0.1:34338/callback) that requires a browser on the same machine.

We swap that one leg for a hosted out-of-band authorization page and keep everything else. Because PKCE's code_verifier never leaves the box, the displayed one-time code is useless to anyone who sees it — no new token-minting or storage is needed anywhere.

Reused as-is (zero changes)

Auth flow (hosted out-of-band OAuth, Clerk PKCE)

Remote box — t3 CLI Laptop — app.t3.codes Clerk 1. gen verifier + challenge + state 2. user opens /connect#{state,challenge} 3. sign in → /oauth/authorize (PKCE) 4. redirect /connect/callback?code&state 5. shows account + authorization code 6. user enters code in terminal 7. POST /oauth/token {code + verifier} → access/refresh tokens 8. store token, set desired link, install relay client → Connected!
The verifier never leaves the box (steps 1→7), so the authorization code is worthless if observed. state/challenge ride the URL fragment — they are not secrets.

Details that keep it simple

The phases (one PR, one commit each)

Ordering is dependency order: each phase is independently revertable and the tree is green at every boundary. Phases 1–3 are the PR; phase 4 ships separately later.

PhaseScopeFiles~LOC
1 Hosted code page (web-only, purely additive, zero risk). Two static routes modeled on pair.tsx: /connect (ensure Clerk session, then client-side redirect to authorize — it's a static SPA, no server 302) and /connect/callback (validate params, show account + copyable code + safety warning). Both routes guard against non-hosted deployments — redirect to / unless isHostedStaticApp(), same pattern as pair.tsx, since this bundle also ships in local instances. Plus the Clerk dashboard redirect-URI entry. apps/web/src/routes/connect.tsx
apps/web/src/routes/connect.callback.tsx
~200
2 CLI out-of-band OAuth flow + single command. Add an out-of-band OAuth login path to CliTokenManager (print URL, Prompt.text for the code, reuse exchangeToken). Make bare t3 connect a handler = login + link (subcommands untouched). Auto-pick headless mode inside SSH sessions (SSH_CONNECTION/SSH_TTY — nothing else); --headless flag as manual override. Loopback stays the default on desktop — no regression. cloud/CliTokenManager.ts (+60)
cloud/publicConfig.ts (+10)
cli/connect.ts (+60)
~150
3 Background on boot — Linux first (the SSH case). One new module: pinned runtime install to ~/.t3/runtime/versions/<v> + current symlink, systemd user unit with absolute node/t3 paths, enable-linger. y/n prompt at the end of connect; teardown in logout. Install and service-start failures must land in a log file (under ~/.t3/userdata/logs/) whose path is printed at connect time — systemd user units fail invisibly otherwise. Unit-file generation is pure string-building → trivially testable. macOS launchd / Windows follow as 3b/3c only if wanted. cloud/bootService.ts (new)
cli/connect.ts (+prompt/teardown)
~250
4 Web-triggered update (optional follow-up PR; not part of this one, not needed for the core UX). Web detects daemon version < latest-on-channel using the existing version-skew surface + hosted manifest; one authenticated "update" command — client says update, daemon resolves/verifies the version itself (never client-specified — that would be RCE). Stage install → verify → atomic symlink swap → systemctl --user restart. Progress streams reuse the RelayClientInstallProgressEvent pattern. web banner + one control command + daemon update routine later

Runtime layout (phase 3)

~/.t3/runtime/
├── versions/0.0.27/        ← npm install --prefix (gets native deps right: node-pty etc.)
└── current -> versions/0.0.27

~/.config/systemd/user/t3code.service   ← ExecStart=/abs/path/node .../current/.../t3 serve
loginctl enable-linger $USER            ← survives SSH logout / reboot

Why a real npm install and not "reuse the npx binary": the npx cache is ephemeral and t3 ships native deps (node-pty, @ff-labs/fff-node) that need per-platform prebuilds. Why pinned and not npx t3@latest in the unit: a boot-time registry fetch means the box may simply not come up (network down, PATH-less systemd env, nvm). Deterministic boot; updates happen out-of-band (phase 4 follow-up) or by re-running npx t3 connect.

Explicitly not doing

Risks & checks

Decision log