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
+Paste your authentication code here: [paste]
+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 copy/paste 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:146
+ CliState.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 OOB, 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 for copy/paste over SSH.
+ - State check without a backend: the callback page displays one paste-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 paste this code into 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 paste → 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 + paste 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 paste flow + single command. Add an OOB 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 paste mode inside SSH sessions (SSH_CONNECTION/SSH_TTY — nothing else); --paste 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 OOB 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. +