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.)
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)
exchangeToken()PKCE exchange —apps/server/src/cloud/CliTokenManager.ts:147- Token persistence in
ServerSecretStore(cloud-cli-oauth-token) acquireRelayClientForLink()cloudflared install + progress —cli/connect.ts:146CliState.setCliDesiredCloudLink()+ server-side provisioning on start- All relay endpoints (
infra/relay) and contracts — untouched - Existing subcommands
login/link/status/unlink/logout— semantics unchanged - Web app Clerk session + hosted-page precedent (
routes/pair.tsx,hostedPairing.ts)
Auth flow (hosted out-of-band OAuth, Clerk PKCE)
Details that keep it simple
- Stateless URL, no short-link service. The
/connectpage readsstate+code_challengefrom the URL fragment and builds the Clerk authorize URL client-side. ~100-char URL — fine to transfer into an SSH session. - State check without a backend: the callback page displays one authorization blob of
code.state; the CLI splits it and verifiesstatematches what it generated. One line on each side, preserves the loopback flow's CSRF check. - Phishing is addressed with copy, not code: the callback page shows which account is being connected ("Connecting as theo@…") and warns: "Only enter this code in a terminal session you started yourself." No mechanism needed.
- Code expiry is a non-issue: Clerk auth codes live 10 minutes — the same timeout the existing loopback flow already uses. Wrong/expired code → friendly retry that reprints the URL.
- One external config step: register
https://app.t3.codes/connect/callbackas an allowed redirect URI on the existing Clerk CLI OAuth client. No new client, no new scopes.
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.
| Phase | Scope | Files | ~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.tsxapps/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
- ✗ Relay /
infra/ contracts changes — none, in any phase - ✗ Short-link service (
app.t3.codes/c/AB7K) — only matters for hand-typing; revisit if ever needed - ✗ RFC 8628 device grant — wrong UX direction, unverified Clerk support
- ✗ Auto-update loop in the daemon — web-triggered only (phase 4 follow-up), user stays in control
- ✗ Project auto-registration — workspace assumed set up; the web UI handles the rest
- ✗ Changing existing loopback flow, subcommands, or desktop behavior
Risks & checks
- ⚠ Clerk redirect URI: confirm the CLI OAuth client accepts the hosted redirect and that the token endpoint honors PKCE exchange for codes issued to it. Verify in staging before the phase 2 commit. (Only external dependency in the plan.)
- ⚠ systemd user env is minimal: always write absolute paths for node + t3 into the unit; never rely on PATH. Service failures are invisible by default — hence the phase 3 requirement to log to a printed file path.
- ⚠ Linger prompt honesty: the y/n prompt should say the machine becomes reachable via T3 Connect whenever powered on — that's the feature, but say it.
- ✓ Re-running
connectwhen linked → idempotent: refresh token, re-confirm service, done.
Decision log
- Auth: hosted out-of-band OAuth redirect on Clerk PKCE (not relay-brokered pairing, not device grant) — chosen for minimal new surface.
- URL: stateless static page, no backend short-link.
- Service: real per-user login service (systemd user + linger first); detect + offer install, never silent.
- Binary: pinned managed runtime under
~/.t3; interactivenpxusage untouched. - Updates: not always-latest; web UI surfaces available updates with one-click trigger (phase 4 follow-up).
- Delivery: one PR with a commit per phase (green tree at every boundary), not separate PRs.
- Workspace: assumed already set up; no auto-registration.