From 1d93294bc79de71f7d76dadb46d212cc797d24bc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 6 Jul 2026 03:22:43 -0700 Subject: [PATCH 1/6] Add hosted /connect pages for CLI paste-code auth Phase 1 of the headless `t3 connect` flow (.plans/t3-connect-remote-setup.html). The CLI's OAuth login hardcodes a 127.0.0.1 loopback redirect, which cannot work on a machine reached over SSH. These hosted pages give the CLI an out-of-band leg for the same Clerk PKCE flow: - /connect reads state + code_challenge from the URL fragment, waits for a Clerk session, and forwards to Clerk's authorize endpoint. - /connect/callback shows the account being connected plus a one-time code.state blob to paste back into the waiting terminal, refusing to render codes for requests this browser did not start. Both routes bounce to / unless running as the hosted static app, since the same bundle ships inside local instances. The fragment carries no secrets; the PKCE verifier never leaves the CLI box, so an observed code cannot be exchanged. packages/shared/connectAuth.ts holds the URL/blob codecs shared with the CLI side, and VITE_CLERK_CLI_OAUTH_CLIENT_ID exposes the existing CLI OAuth client id to the web build. Co-Authored-By: Claude Fable 5 --- .plans/t3-connect-remote-setup.html | 257 ++++++++++++++++++ apps/web/src/cloud/connectCliAuth.test.ts | 69 +++++ apps/web/src/cloud/connectCliAuth.ts | 91 +++++++ .../cloud/ConnectCliAuthSurface.tsx | 171 ++++++++++++ apps/web/src/routeTree.gen.ts | 42 +++ apps/web/src/routes/__root.tsx | 2 +- apps/web/src/routes/connect.tsx | 21 ++ apps/web/src/routes/connect_.callback.tsx | 13 + apps/web/src/vite-env.d.ts | 1 + apps/web/vite.config.ts | 4 + packages/shared/package.json | 4 + packages/shared/src/connectAuth.test.ts | 78 ++++++ packages/shared/src/connectAuth.ts | 98 +++++++ scripts/lib/public-config.ts | 7 +- 14 files changed, 856 insertions(+), 2 deletions(-) create mode 100644 .plans/t3-connect-remote-setup.html create mode 100644 apps/web/src/cloud/connectCliAuth.test.ts create mode 100644 apps/web/src/cloud/connectCliAuth.ts create mode 100644 apps/web/src/components/cloud/ConnectCliAuthSurface.tsx create mode 100644 apps/web/src/routes/connect.tsx create mode 100644 apps/web/src/routes/connect_.callback.tsx create mode 100644 packages/shared/src/connectAuth.test.ts create mode 100644 packages/shared/src/connectAuth.ts diff --git a/.plans/t3-connect-remote-setup.html b/.plans/t3-connect-remote-setup.html new file mode 100644 index 00000000000..3c5b702716f --- /dev/null +++ b/.plans/t3-connect-remote-setup.html @@ -0,0 +1,257 @@ + + + + + +Plan: seamless `npx t3 connect` for remote boxes + + + +
+ +

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

+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)

+ +
+ + + + + + 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 + paste code + + + + + 6. user pastes code into 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 pasted code is worthless if observed. state/challenge ride the URL fragment — they are not secrets.
+
+ +
+

Details that keep it simple

+
    +
  • Stateless URL, no short-link service. The /connect page reads state + code_challenge from 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 verifies state matches 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/callback as 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.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
PhaseScopeFiles~LOC
1Hosted 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.tsx
apps/web/src/routes/connect.callback.tsx
~200
2CLI 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
3Background 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
4Web-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 routinelater
+ +

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 connect when 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; interactive npx usage 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.
  • +
+ +
+ + diff --git a/apps/web/src/cloud/connectCliAuth.test.ts b/apps/web/src/cloud/connectCliAuth.test.ts new file mode 100644 index 00000000000..a6905411208 --- /dev/null +++ b/apps/web/src/cloud/connectCliAuth.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + buildConnectCliClerkAuthorizeUrl, + hasConnectCliAuthConfig, + readConnectCliCallbackResult, +} from "./connectCliAuth"; + +// Any pk_test_* key decodes to .clerk.accounts.dev. +const TEST_PUBLISHABLE_KEY = `pk_test_${btoa("witty-mole-42.clerk.accounts.dev$")}`; + +describe("connectCliAuth", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("requires both the publishable key and the CLI OAuth client id", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay"); + vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.com"); + expect(hasConnectCliAuthConfig()).toBe(false); + + vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); + expect(hasConnectCliAuthConfig()).toBe(true); + }); + + it("builds the Clerk authorize URL with the current origin's callback", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); + + const authorizeUrl = buildConnectCliClerkAuthorizeUrl( + { state: "state-1", challenge: "challenge-1" }, + "https://app.t3.codes", + ); + expect(authorizeUrl).not.toBeNull(); + + const url = new URL(authorizeUrl!); + expect(url.hostname).toBe("witty-mole-42.clerk.accounts.dev"); + expect(url.pathname).toBe("/oauth/authorize"); + expect(url.searchParams.get("redirect_uri")).toBe("https://app.t3.codes/connect/callback"); + expect(url.searchParams.get("state")).toBe("state-1"); + expect(url.searchParams.get("code_challenge")).toBe("challenge-1"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + }); + + it("returns null when the CLI OAuth client id is not configured", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + expect( + buildConnectCliClerkAuthorizeUrl( + { state: "state-1", challenge: "challenge-1" }, + "https://app.t3.codes", + ), + ).toBeNull(); + }); + + it("reads the code and state Clerk echoes back to the callback", () => { + expect( + readConnectCliCallbackResult( + new URL("https://app.t3.codes/connect/callback?code=abc&state=state-1"), + ), + ).toEqual({ code: "abc", state: "state-1" }); + expect( + readConnectCliCallbackResult(new URL("https://app.t3.codes/connect/callback?code=abc")), + ).toBeNull(); + expect( + readConnectCliCallbackResult(new URL("https://app.t3.codes/connect/callback?state=s")), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts new file mode 100644 index 00000000000..2afb277432d --- /dev/null +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -0,0 +1,91 @@ +import { + buildConnectClerkAuthorizeUrl, + CONNECT_CALLBACK_PATH, + CONNECT_OAUTH_SCOPES, + readConnectAuthorizeRequest, + type ConnectAuthorizeRequest, +} from "@t3tools/shared/connectAuth"; +import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; + +import { resolveCloudPublicConfig } from "./publicConfig"; + +const CONNECT_CLI_AUTH_STATE_STORAGE_KEY = "t3code-connect-cli-auth-state"; + +function trimNonEmpty(value: string | undefined): string | null { + return value?.trim() || null; +} + +export function resolveConnectCliOAuthClientId(): string | null { + return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined); +} + +export function hasConnectCliAuthConfig(): boolean { + return Boolean( + resolveCloudPublicConfig().clerkPublishableKey && resolveConnectCliOAuthClientId(), + ); +} + +export function readConnectCliAuthorizeRequest( + url: URL = new URL(window.location.href), +): ConnectAuthorizeRequest | null { + return readConnectAuthorizeRequest(url); +} + +/** + * Builds the Clerk authorize URL for a CLI-initiated connect request. The + * state is mirrored into sessionStorage so the callback page can verify the + * response matches a request this browser actually started. + */ +export function buildConnectCliClerkAuthorizeUrl( + request: ConnectAuthorizeRequest, + currentOrigin: string = window.location.origin, +): string | null { + const { clerkPublishableKey } = resolveCloudPublicConfig(); + const clientId = resolveConnectCliOAuthClientId(); + if (!clerkPublishableKey || !clientId) { + return null; + } + return buildConnectClerkAuthorizeUrl({ + authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`, + clientId, + redirectUri: new URL(CONNECT_CALLBACK_PATH, currentOrigin).toString(), + scopes: CONNECT_OAUTH_SCOPES, + state: request.state, + challenge: request.challenge, + }); +} + +export function rememberConnectCliAuthState(state: string): void { + try { + window.sessionStorage.setItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY, state); + } catch { + // Session storage can be unavailable (e.g. blocked). The callback page + // then falls back to trusting the state Clerk echoed back. + } +} + +export function consumeConnectCliAuthState(): string | null { + try { + const state = window.sessionStorage.getItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY); + window.sessionStorage.removeItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY); + return state; + } catch { + return null; + } +} + +export interface ConnectCliCallbackResult { + readonly code: string; + readonly state: string; +} + +export function readConnectCliCallbackResult( + url: URL = new URL(window.location.href), +): ConnectCliCallbackResult | null { + const code = url.searchParams.get("code")?.trim() ?? ""; + const state = url.searchParams.get("state")?.trim() ?? ""; + if (!code || !state) { + return null; + } + return { code, state }; +} diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx new file mode 100644 index 00000000000..04bffc82dbb --- /dev/null +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -0,0 +1,171 @@ +import { useAuth, useClerk, useUser } from "@clerk/react"; +import { encodeConnectAuthCode } from "@t3tools/shared/connectAuth"; +import { useEffect, useMemo, useState } from "react"; + +import { APP_DISPLAY_NAME } from "../../branding"; +import { + buildConnectCliClerkAuthorizeUrl, + consumeConnectCliAuthState, + readConnectCliAuthorizeRequest, + readConnectCliCallbackResult, + rememberConnectCliAuthState, +} from "../../cloud/connectCliAuth"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { Button } from "../ui/button"; + +function ConnectCliAuthShell({ children }: { readonly children: React.ReactNode }) { + return ( +
+
+
+
+
+
+ +
+

+ {APP_DISPLAY_NAME} +

+ {children} +
+
+ ); +} + +function ConnectCliAuthMessage({ + title, + description, +}: { + readonly title: string; + readonly description: string; +}) { + return ( + <> +

{title}

+

{description}

+ + ); +} + +const invalidLinkMessage = { + title: "This connect link is incomplete", + description: + "The link is missing its authorization request. Re-run `t3 connect` in your terminal and open the freshly printed URL.", +} as const; + +/** + * /connect: the URL a headless CLI prints. Waits for a Clerk session, then + * forwards the CLI's PKCE request to Clerk's authorize endpoint. + */ +export function ConnectCliAuthorizeSurface() { + const request = useMemo(() => readConnectCliAuthorizeRequest(), []); + const clerk = useClerk(); + const { isLoaded, isSignedIn } = useAuth(); + const [redirecting, setRedirecting] = useState(false); + + useEffect(() => { + if (!request || !isLoaded || redirecting) { + return; + } + if (!isSignedIn) { + clerk.openSignIn({ forceRedirectUrl: window.location.href }); + return; + } + const authorizeUrl = buildConnectCliClerkAuthorizeUrl(request); + if (!authorizeUrl) { + return; + } + setRedirecting(true); + rememberConnectCliAuthState(request.state); + window.location.assign(authorizeUrl); + }, [clerk, isLoaded, isSignedIn, redirecting, request]); + + if (!request) { + return ( + + + + ); + } + + return ( + + + + ); +} + +/** + * /connect/callback: Clerk's redirect target. Shows the one-time code the + * user pastes back into the waiting terminal. + */ +export function ConnectCliCallbackSurface() { + const result = useMemo(() => readConnectCliCallbackResult(), []); + const expectedState = useMemo(() => consumeConnectCliAuthState(), []); + const { user } = useUser(); + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "authentication code" }); + + if (!result) { + return ( + + + + ); + } + + // A response for a request this browser did not start is the CSRF shape the + // state parameter exists to stop; refuse to display a code for it. + if (expectedState !== null && expectedState !== result.state) { + return ( + + + + ); + } + + const accountLabel = user?.primaryEmailAddress?.emailAddress ?? user?.username ?? null; + const authCode = encodeConnectAuthCode(result); + + return ( + + + +
+ + {authCode} + +
+ +
+ +
+ +

+ Only paste this code into a terminal session you started yourself. Anyone holding it can + link their machine to your T3 Connect account while it is valid. +

+
+ ); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3a9140e278c..b524fe018e9 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' +import { Route as ConnectRouteImport } from './routes/connect' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' @@ -20,6 +21,7 @@ import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -33,6 +35,11 @@ const PairRoute = PairRouteImport.update({ path: '/pair', getParentRoute: () => rootRouteImport, } as any) +const ConnectRoute = ConnectRouteImport.update({ + id: '/connect', + path: '/connect', + getParentRoute: () => rootRouteImport, +} as any) const ChatRoute = ChatRouteImport.update({ id: '/_chat', getParentRoute: () => rootRouteImport, @@ -77,6 +84,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) +const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ + id: '/connect_/callback', + path: '/connect/callback', + getParentRoute: () => rootRouteImport, +} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -91,8 +103,10 @@ const ChatEnvironmentIdThreadIdRoute = export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute + '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -104,8 +118,10 @@ export interface FileRoutesByFullPath { '/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRoutesByTo { + '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -120,8 +136,10 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren + '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/connect_/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -137,8 +155,10 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/connect' | '/pair' | '/settings' + | '/connect/callback' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -150,8 +170,10 @@ export interface FileRouteTypes { | '/draft/$draftId' fileRoutesByTo: FileRoutesByTo to: + | '/connect' | '/pair' | '/settings' + | '/connect/callback' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -165,8 +187,10 @@ export interface FileRouteTypes { id: | '__root__' | '/_chat' + | '/connect' | '/pair' | '/settings' + | '/connect_/callback' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -181,8 +205,10 @@ export interface FileRouteTypes { } export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren + ConnectRoute: typeof ConnectRoute PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren + ConnectCallbackRoute: typeof ConnectCallbackRoute } declare module '@tanstack/react-router' { @@ -201,6 +227,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PairRouteImport parentRoute: typeof rootRouteImport } + '/connect': { + id: '/connect' + path: '/connect' + fullPath: '/connect' + preLoaderRoute: typeof ConnectRouteImport + parentRoute: typeof rootRouteImport + } '/_chat': { id: '/_chat' path: '' @@ -264,6 +297,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } + '/connect_/callback': { + id: '/connect_/callback' + path: '/connect/callback' + fullPath: '/connect/callback' + preLoaderRoute: typeof ConnectCallbackRouteImport + parentRoute: typeof rootRouteImport + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -321,8 +361,10 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, + ConnectRoute: ConnectRoute, PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, + ConnectCallbackRoute: ConnectCallbackRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 36de3b95706..b7ae1a31977 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -96,7 +96,7 @@ function RootRouteView() { }; }, [pathname]); - if (pathname === "/pair") { + if (pathname === "/pair" || pathname === "/connect" || pathname.startsWith("/connect/")) { return ( <> diff --git a/apps/web/src/routes/connect.tsx b/apps/web/src/routes/connect.tsx new file mode 100644 index 00000000000..f6ff1813886 --- /dev/null +++ b/apps/web/src/routes/connect.tsx @@ -0,0 +1,21 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { hasConnectCliAuthConfig } from "../cloud/connectCliAuth"; +import { hasCloudPublicConfig } from "../cloud/publicConfig"; +import { ConnectCliAuthorizeSurface } from "../components/cloud/ConnectCliAuthSurface"; +import { isHostedStaticApp } from "../hostedPairing"; + +// The web bundle also ships inside local/desktop instances; the CLI connect +// handshake only exists on the hosted app, so everything else bounces home. +export function connectCliAuthRoutesEnabled(): boolean { + return isHostedStaticApp() && hasCloudPublicConfig() && hasConnectCliAuthConfig(); +} + +export const Route = createFileRoute("/connect")({ + beforeLoad: () => { + if (!connectCliAuthRoutesEnabled()) { + throw redirect({ to: "/", replace: true }); + } + }, + component: ConnectCliAuthorizeSurface, +}); diff --git a/apps/web/src/routes/connect_.callback.tsx b/apps/web/src/routes/connect_.callback.tsx new file mode 100644 index 00000000000..bb8b57a8a60 --- /dev/null +++ b/apps/web/src/routes/connect_.callback.tsx @@ -0,0 +1,13 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { ConnectCliCallbackSurface } from "../components/cloud/ConnectCliAuthSurface"; +import { connectCliAuthRoutesEnabled } from "./connect"; + +export const Route = createFileRoute("/connect_/callback")({ + beforeLoad: () => { + if (!connectCliAuthRoutesEnabled()) { + throw redirect({ to: "/", replace: true }); + } + }, + component: ConnectCliCallbackSurface, +}); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index d8a6d71b49a..ac1da93f5c8 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -9,6 +9,7 @@ interface ImportMetaEnv { readonly VITE_HOSTED_APP_CHANNEL: string; readonly VITE_CLERK_PUBLISHABLE_KEY: string; readonly VITE_CLERK_JWT_TEMPLATE: string; + readonly VITE_CLERK_CLI_OAUTH_CLIENT_ID: string; readonly VITE_RELAY_OTLP_TRACES_URL: string; readonly VITE_RELAY_OTLP_TRACES_DATASET: string; readonly VITE_RELAY_OTLP_TRACES_TOKEN: string; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e7bfa63204a..093709a7cc9 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -18,6 +18,7 @@ const configuredWsUrl = process.env.VITE_WS_URL?.trim(); const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || ""; +const configuredClerkCliOAuthClientId = repoEnv.VITE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || ""; const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || ""; const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || ""; const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || ""; @@ -115,6 +116,9 @@ export default defineConfig(() => { "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl), "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey), "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate), + "import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID": JSON.stringify( + configuredClerkCliOAuthClientId, + ), "import.meta.env.VITE_RELAY_OTLP_TRACES_URL": JSON.stringify(configuredRelayTracingUrl), "import.meta.env.VITE_RELAY_OTLP_TRACES_DATASET": JSON.stringify( configuredRelayTracingDataset, diff --git a/packages/shared/package.json b/packages/shared/package.json index e08844cbfae..a4cde40a15e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -131,6 +131,10 @@ "types": "./src/cliArgs.ts", "import": "./src/cliArgs.ts" }, + "./connectAuth": { + "types": "./src/connectAuth.ts", + "import": "./src/connectAuth.ts" + }, "./path": { "types": "./src/path.ts", "import": "./src/path.ts" diff --git a/packages/shared/src/connectAuth.test.ts b/packages/shared/src/connectAuth.test.ts new file mode 100644 index 00000000000..5f8b62e334d --- /dev/null +++ b/packages/shared/src/connectAuth.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildConnectAuthorizeRequestUrl, + buildConnectClerkAuthorizeUrl, + connectCallbackUrl, + encodeConnectAuthCode, + parseConnectAuthCode, + readConnectAuthorizeRequest, +} from "./connectAuth.ts"; + +describe("connectAuth", () => { + it("round-trips state and challenge through the authorize URL fragment", () => { + const url = buildConnectAuthorizeRequestUrl({ + hostedAppUrl: "https://app.t3.codes", + state: "0b5f9a52-83a7-4f6e-9d2a-1c4b8e7f6a3d", + challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + }); + const parsed = new URL(url); + + expect(parsed.origin).toBe("https://app.t3.codes"); + expect(parsed.pathname).toBe("/connect"); + expect(parsed.search).toBe(""); + expect(readConnectAuthorizeRequest(parsed)).toEqual({ + state: "0b5f9a52-83a7-4f6e-9d2a-1c4b8e7f6a3d", + challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + }); + }); + + it("rejects authorize requests missing state or challenge", () => { + expect(readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect"))).toBeNull(); + expect( + readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#state=abc")), + ).toBeNull(); + expect( + readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#challenge=abc")), + ).toBeNull(); + }); + + it("builds a PKCE authorize URL against the Clerk endpoint", () => { + const url = new URL( + buildConnectClerkAuthorizeUrl({ + authorizationEndpoint: "https://clerk.t3.codes/oauth/authorize", + clientId: "oauthapp_123", + redirectUri: connectCallbackUrl("https://app.t3.codes"), + scopes: ["openid", "profile", "email"], + state: "state-1", + challenge: "challenge-1", + }), + ); + + expect(url.origin).toBe("https://clerk.t3.codes"); + expect(url.pathname).toBe("/oauth/authorize"); + expect(url.searchParams.get("client_id")).toBe("oauthapp_123"); + expect(url.searchParams.get("redirect_uri")).toBe("https://app.t3.codes/connect/callback"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("scope")).toBe("openid profile email"); + expect(url.searchParams.get("state")).toBe("state-1"); + expect(url.searchParams.get("code_challenge")).toBe("challenge-1"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + }); + + it("round-trips the pasted code blob and preserves dots inside the code", () => { + const blob = encodeConnectAuthCode({ code: "az9.code.chunk", state: "state-uuid" }); + expect(parseConnectAuthCode(blob)).toEqual({ code: "az9.code.chunk", state: "state-uuid" }); + expect(parseConnectAuthCode(` ${blob}\n`)).toEqual({ + code: "az9.code.chunk", + state: "state-uuid", + }); + }); + + it("rejects malformed pasted blobs", () => { + expect(parseConnectAuthCode("")).toBeNull(); + expect(parseConnectAuthCode("no-separator")).toBeNull(); + expect(parseConnectAuthCode(".leading")).toBeNull(); + expect(parseConnectAuthCode("trailing.")).toBeNull(); + }); +}); diff --git a/packages/shared/src/connectAuth.ts b/packages/shared/src/connectAuth.ts new file mode 100644 index 00000000000..a8320e03136 --- /dev/null +++ b/packages/shared/src/connectAuth.ts @@ -0,0 +1,98 @@ +const CONNECT_AUTH_STATE_PARAM = "state"; +const CONNECT_AUTH_CHALLENGE_PARAM = "challenge"; +const CONNECT_AUTH_CODE_SEPARATOR = "."; + +export const CONNECT_AUTHORIZE_PATH = "/connect"; +export const CONNECT_CALLBACK_PATH = "/connect/callback"; + +/** + * Requested at authorize time by the hosted page and honored by the CLI's + * token exchange; keep both sides on this single definition. + */ +export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email"] as const; + +const readHashParams = (url: URL): URLSearchParams => + new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); + +export interface ConnectAuthorizeRequest { + readonly state: string; + readonly challenge: string; +} + +/** + * The URL a headless CLI prints for the user to open on a machine with a + * browser. `state` and `code_challenge` ride the fragment so they never reach + * the hosted app's server or CDN logs; neither is a secret. + */ +export function buildConnectAuthorizeRequestUrl(input: { + readonly hostedAppUrl: string; + readonly state: string; + readonly challenge: string; +}): string { + const url = new URL(CONNECT_AUTHORIZE_PATH, input.hostedAppUrl); + url.hash = new URLSearchParams([ + [CONNECT_AUTH_STATE_PARAM, input.state], + [CONNECT_AUTH_CHALLENGE_PARAM, input.challenge], + ]).toString(); + return url.toString(); +} + +export function readConnectAuthorizeRequest(url: URL): ConnectAuthorizeRequest | null { + const params = readHashParams(url); + const state = params.get(CONNECT_AUTH_STATE_PARAM)?.trim() ?? ""; + const challenge = params.get(CONNECT_AUTH_CHALLENGE_PARAM)?.trim() ?? ""; + if (!state || !challenge) { + return null; + } + return { state, challenge }; +} + +export function connectCallbackUrl(hostedAppUrl: string): string { + return new URL(CONNECT_CALLBACK_PATH, hostedAppUrl).toString(); +} + +export function buildConnectClerkAuthorizeUrl(input: { + readonly authorizationEndpoint: string; + readonly clientId: string; + readonly redirectUri: string; + readonly scopes: ReadonlyArray; + readonly state: string; + readonly challenge: string; +}): string { + const url = new URL(input.authorizationEndpoint); + url.searchParams.set("client_id", input.clientId); + url.searchParams.set("redirect_uri", input.redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", input.scopes.join(" ")); + url.searchParams.set("state", input.state); + url.searchParams.set("code_challenge", input.challenge); + url.searchParams.set("code_challenge_method", "S256"); + return url.toString(); +} + +export interface ConnectAuthCode { + readonly code: string; + readonly state: string; +} + +/** + * The single blob the hosted callback page displays and the CLI accepts. + * Bundling `state` with the authorization code lets the CLI keep the loopback + * flow's CSRF check without any backend: it verifies the returned state + * matches the one it generated. Clerk authorization codes and the CLI's + * UUID states never contain ".". + */ +export function encodeConnectAuthCode(input: ConnectAuthCode): string { + return `${input.code}${CONNECT_AUTH_CODE_SEPARATOR}${input.state}`; +} + +export function parseConnectAuthCode(blob: string): ConnectAuthCode | null { + const trimmed = blob.trim(); + const separatorIndex = trimmed.lastIndexOf(CONNECT_AUTH_CODE_SEPARATOR); + if (separatorIndex <= 0 || separatorIndex === trimmed.length - 1) { + return null; + } + const code = trimmed.slice(0, separatorIndex); + const state = trimmed.slice(separatorIndex + 1); + return { code, state }; +} diff --git a/scripts/lib/public-config.ts b/scripts/lib/public-config.ts index 9b988df9c5b..36ca5497f80 100644 --- a/scripts/lib/public-config.ts +++ b/scripts/lib/public-config.ts @@ -55,6 +55,7 @@ export function loadRepoEnv({ ...(config.clerkCliOAuthClientId ? { T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: config.clerkCliOAuthClientId, + VITE_CLERK_CLI_OAUTH_CLIENT_ID: config.clerkCliOAuthClientId, } : {}), ...(config.relayUrl @@ -116,7 +117,11 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP "VITE_CLERK_JWT_TEMPLATE", "EXPO_PUBLIC_CLERK_JWT_TEMPLATE", ), - clerkCliOAuthClientId: firstNonEmpty(sources, "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID"), + clerkCliOAuthClientId: firstNonEmpty( + sources, + "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID", + "VITE_CLERK_CLI_OAUTH_CLIENT_ID", + ), relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"), mobileOtlpTracesUrl: firstNonEmpty( sources, From 3de864223e6662f6d2055fc2f37c2f1b033777f4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 6 Jul 2026 03:51:46 -0700 Subject: [PATCH 2/6] Add paste-code auth and make bare `t3 connect` do full setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the headless connect flow (.plans/t3-connect-remote-setup.html). CliTokenManager gains pasteCodeLogin: it generates the same PKCE verifier/challenge/state as the loopback flow, prints a hosted app.t3.codes/connect URL instead of binding 127.0.0.1:34338, and exchanges the pasted code.state blob (validating state to keep the CSRF check). The prompt is injected by the caller so the flow is testable without a TTY. `t3 connect` (bare) now runs the whole setup — relay client install, authorization, desired-link flag — instead of requiring login + link as separate steps. Paste mode is auto-selected inside SSH sessions (SSH_CONNECTION/SSH_TTY) and available anywhere via --paste; desktop keeps the loopback browser flow unchanged. T3CODE_HOSTED_APP_URL overrides the hosted origin for staging. Co-Authored-By: Claude Fable 5 --- .env.example | 4 + apps/server/src/cli/connect.ts | 123 ++++++++++++--- apps/server/src/cloud/CliTokenManager.test.ts | 140 ++++++++++++++++++ apps/server/src/cloud/CliTokenManager.ts | 138 +++++++++++++---- apps/server/src/cloud/http.test.ts | 1 + apps/server/src/cloud/publicConfig.ts | 14 +- 6 files changed, 367 insertions(+), 53 deletions(-) create mode 100644 apps/server/src/cloud/CliTokenManager.test.ts diff --git a/.env.example b/.env.example index 79b2adaf0c8..1e4a5dad337 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,10 @@ # Get this from your relay deployment. `infra/relay` deploys update it automatically. # T3CODE_RELAY_URL=https://relay.example.com +# Optional: hosted app origin used by the CLI paste-code connect flow. +# Defaults to https://app.t3.codes; override to test against a staging deployment. +# T3CODE_HOSTED_APP_URL=https://nightly.app.t3.codes + # Public, ingest-only mobile OpenTelemetry configuration. # T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces # T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 3ce53391fa6..a5886e5d4aa 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -9,6 +9,7 @@ import * as RelayClient from "@t3tools/shared/relayClient"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; +import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -16,6 +17,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; import { FetchHttpClient, @@ -42,6 +44,53 @@ const jsonFlag = Flag.boolean("json").pipe( Flag.withDefault(false), ); +const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError); + +const pasteFlag = Flag.boolean("paste").pipe( + Flag.withDescription( + "Authorize by pasting a code from the hosted app instead of a local browser callback.", + ), + Flag.withDefault(false), +); + +/** + * Inside an SSH session there is no local browser to complete the loopback + * OAuth callback, so the paste-code flow is the only one that can work. + */ +const detectHeadlessSession = Effect.sync( + () => process.env.SSH_CONNECTION !== undefined || process.env.SSH_TTY !== undefined, +); + +const promptForPastedCode = ({ authorizeUrl, validate }: CliTokenManager.PasteCodePromptInput) => + Console.log(`To set up T3 Connect, open this URL and sign in:\n ${authorizeUrl}\n`).pipe( + Effect.andThen( + Prompt.run(Prompt.text({ message: "Paste your authentication code here", validate })), + ), + ); + +const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: { + readonly paste: boolean; +}) { + const tokens = yield* CliTokenManager.CloudCliTokenManager; + const paste = options.paste || (yield* detectHeadlessSession); + if (!paste) { + yield* tokens.get; + return; + } + const existing = yield* tokens.getExisting; + if (Option.isSome(existing)) { + return; + } + const token = yield* CliTokenManager.pasteCodeLogin(promptForPastedCode).pipe( + Effect.mapError((cause) => + isCloudCliTokenManagerError(cause) + ? cause + : new CliTokenManager.CloudCliAuthorizationError({ cause }), + ), + ); + yield* tokens.store(token); +}); + function bytesToString(value: Uint8Array): string { return new TextDecoder().decode(value); } @@ -321,6 +370,7 @@ const runCloudCommand = ( | CliTokenManager.CloudCliTokenManager | RelayClient.RelayClient | EnvironmentAuth.EnvironmentAuth + | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient | Prompt.Environment @@ -350,16 +400,38 @@ const runCloudCommand = ( return yield* run.pipe(Effect.provide(runtimeLayer)); }); +const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(function* (options: { + readonly paste: boolean; +}) { + const relayClient = yield* RelayClient.RelayClient; + const installed = yield* acquireRelayClientForLink( + relayClient, + confirmRelayClientInstall, + reportRelayClientInstallProgress, + ); + if (Option.isNone(installed)) { + yield* Console.log("T3 Connect setup cancelled. The relay client was not installed."); + return false; + } + yield* Console.log( + `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`, + ); + + yield* authorizeCli(options); + yield* CliState.setCliDesiredCloudLink(true); + return true; +}); + const connectLoginCommand = Command.make("login", { ...projectLocationFlags, + paste: pasteFlag, }).pipe( Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."), Command.withHandler((flags) => runCloudCommand( flags, Effect.gen(function* () { - const tokens = yield* CliTokenManager.CloudCliTokenManager; - yield* tokens.get; + yield* authorizeCli(flags); yield* Console.log("Signed in to T3 Connect."); }), ), @@ -368,32 +440,18 @@ const connectLoginCommand = Command.make("login", { const connectLinkCommand = Command.make("link", { ...projectLocationFlags, + paste: pasteFlag, }).pipe( Command.withDescription("Authorize this environment for T3 Connect on next start."), Command.withHandler((flags) => runCloudCommand( flags, Effect.gen(function* () { - const relayClient = yield* RelayClient.RelayClient; - const installed = yield* acquireRelayClientForLink( - relayClient, - confirmRelayClientInstall, - reportRelayClientInstallProgress, - ); - if (Option.isNone(installed)) { - yield* Console.log("T3 Connect setup cancelled. The relay client was not installed."); - return; + if (yield* linkEnvironmentForConnect(flags)) { + yield* Console.log( + "This T3 environment will be available through T3 Connect the next time T3 starts.", + ); } - yield* Console.log( - `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`, - ); - - const tokens = yield* CliTokenManager.CloudCliTokenManager; - yield* tokens.get; - yield* CliState.setCliDesiredCloudLink(true); - yield* Console.log( - "This T3 environment will be available through T3 Connect the next time T3 starts.", - ); }), ), ), @@ -456,8 +514,27 @@ const connectLogoutCommand = Command.make("logout", { ), ); -export const connectCommand = Command.make("connect").pipe( - Command.withDescription("Manage headless T3 Connect access."), +export const connectCommand = Command.make("connect", { + ...projectLocationFlags, + paste: pasteFlag, +}).pipe( + Command.withDescription("Set up T3 Connect for this machine."), + Command.withHandler((flags) => + runCloudCommand( + flags, + Effect.gen(function* () { + if (!(yield* linkEnvironmentForConnect(flags))) { + return; + } + yield* Console.log( + "\nConnected! This environment will be available through T3 Connect whenever T3 is running.", + ); + yield* Console.log( + "Start it with `t3 serve`, or run `t3 connect status` to inspect the link.", + ); + }), + ), + ), Command.withSubcommands([ connectLoginCommand, connectLinkCommand, diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts new file mode 100644 index 00000000000..8039d2f5e6a --- /dev/null +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -0,0 +1,140 @@ +import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import * as CliTokenManager from "./CliTokenManager.ts"; +import type { PasteCodePromptInput } from "./CliTokenManager.ts"; + +// pk_test_ +const TEST_ENV = { + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_Y2xlcmsuZXhhbXBsZS50ZXN0JA==", + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_client_test", + T3CODE_HOSTED_APP_URL: "https://hosted.example.test", +}; + +interface RecordedTokenRequest { + readonly url: string; + readonly params: URLSearchParams; +} + +const makeTokenEndpointLayer = (requests: Array) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + requests.push({ url: request.url, params: new URLSearchParams(body) }); + return HttpClientResponse.fromWeb( + request, + new Response( + // @effect-diagnostics-next-line preferSchemaOverJson:off - Test fixture matches Clerk's token endpoint response. + JSON.stringify({ + access_token: "access-token-1", + refresh_token: "refresh-token-1", + expires_in: 3600, + token_type: "bearer", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + }), + ), + ); + +const provideTestEnv = Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: TEST_ENV })), +); + +const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError); + +class PromptRejectedError extends Schema.TaggedErrorClass()( + "PromptRejectedError", + { message: Schema.String }, +) {} + +it.layer(NodeServices.layer)("CliTokenManager.pasteCodeLogin", (it) => { + it.effect("prints a hosted authorize URL and exchanges the pasted code with PKCE", () => + Effect.gen(function* () { + const requests: Array = []; + let seenAuthorizeUrl = ""; + + const token = yield* CliTokenManager.pasteCodeLogin( + ({ authorizeUrl, validate }: PasteCodePromptInput) => + Effect.gen(function* () { + seenAuthorizeUrl = authorizeUrl; + const request = readConnectAuthorizeRequest(new URL(authorizeUrl)); + assert.isNotNull(request); + return yield* validate(`clerk-code-123.${request!.state}`).pipe( + Effect.mapError((message) => new PromptRejectedError({ message })), + ); + }), + ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv); + + const authorizeUrl = new URL(seenAuthorizeUrl); + assert.equal(authorizeUrl.origin, "https://hosted.example.test"); + assert.equal(authorizeUrl.pathname, "/connect"); + const request = readConnectAuthorizeRequest(authorizeUrl); + assert.isNotNull(request); + + assert.equal(token.accessToken, "access-token-1"); + assert.equal(token.refreshToken, "refresh-token-1"); + + assert.lengthOf(requests, 1); + const exchange = requests[0]!; + assert.equal(exchange.url, "https://clerk.example.test/oauth/token"); + assert.equal(exchange.params.get("grant_type"), "authorization_code"); + assert.equal(exchange.params.get("code"), "clerk-code-123"); + assert.equal( + exchange.params.get("redirect_uri"), + "https://hosted.example.test/connect/callback", + ); + assert.equal(exchange.params.get("client_id"), "oauth_client_test"); + // The verifier must hash to the challenge advertised in the authorize URL. + const verifier = exchange.params.get("code_verifier"); + assert.isNotNull(verifier); + const digest = yield* Effect.promise(() => + crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier!)), + ); + assert.equal(Buffer.from(digest).toString("base64url"), request!.challenge); + }), + ); + + it.effect("rejects pasted codes whose state does not match the request", () => + Effect.gen(function* () { + const requests: Array = []; + + const validationErrors: Array = []; + const result = yield* CliTokenManager.pasteCodeLogin(({ validate }: PasteCodePromptInput) => + validate("clerk-code-123.wrong-state").pipe( + Effect.tapError((message) => Effect.sync(() => validationErrors.push(message))), + Effect.mapError((message) => new PromptRejectedError({ message })), + ), + ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip); + + assert.lengthOf(requests, 0); + assert.lengthOf(validationErrors, 1); + assert.include(validationErrors[0], "different connect request"); + assert.instanceOf(result, PromptRejectedError); + }), + ); + + it.effect("fails without touching the token endpoint when the prompt returns garbage", () => + Effect.gen(function* () { + const requests: Array = []; + + const result = yield* CliTokenManager.pasteCodeLogin(() => + Effect.succeed("not-a-connect-code"), + ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip); + + assert.lengthOf(requests, 0); + assert.isTrue(isAuthorizationError(result)); + }), + ); +}); diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 00709370b26..047c3d87d0f 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -21,8 +21,18 @@ import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { + buildConnectAuthorizeRequestUrl, + connectCallbackUrl, + parseConnectAuthCode, +} from "@t3tools/shared/connectAuth"; + import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts"; +import { + cloudCliOAuthConfig, + hostedAppUrlConfig, + type CloudCliOAuthConfig, +} from "./publicConfig.ts"; const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token"; const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); @@ -33,7 +43,7 @@ const PersistedToken = Schema.Struct({ refreshToken: Schema.String, expiresAtEpochMs: Schema.Number, }); -type PersistedToken = typeof PersistedToken.Type; +export type PersistedToken = typeof PersistedToken.Type; const PersistedTokenJson = Schema.fromJsonString(PersistedToken); const decodePersistedToken = Schema.decodeUnknownEffect(PersistedTokenJson); @@ -106,6 +116,7 @@ export class CloudCliTokenManager extends Context.Service< readonly get: Effect.Effect; readonly getExisting: Effect.Effect, CloudCliTokenManagerError>; readonly hasCredential: Effect.Effect; + readonly store: (token: PersistedToken) => Effect.Effect; readonly clear: Effect.Effect; } >()("t3/cloud/CliTokenManager/CloudCliTokenManager") {} @@ -123,9 +134,86 @@ function bytesToString(value: Uint8Array): string { return new TextDecoder().decode(value); } -export const make = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; +const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( + metadata: Pick, + params: Record, +) { const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); + const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( + HttpClientRequest.bodyUrlParams(params), + httpClient.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)), + ); + const now = yield* Clock.currentTimeMillis; + return { + accessToken: response.access_token, + refreshToken: response.refresh_token ?? params.refresh_token ?? "", + expiresAtEpochMs: now + response.expires_in * 1_000, + } satisfies PersistedToken; +}); + +const makePkceRequest = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); + const challenge = Encoding.encodeBase64Url( + yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), + ); + const state = yield* crypto.randomUUIDv4; + return { verifier, challenge, state }; +}); + +export interface PasteCodePromptInput { + readonly authorizeUrl: string; + readonly validate: (value: string) => Effect.Effect; +} + +/** + * Out-of-band authorization for machines without a local browser (SSH). The + * user opens the hosted /connect URL elsewhere, signs in, and pastes the + * displayed code back into this terminal. The PKCE verifier never leaves + * this process, so the pasted code is useless to an observer, and the state + * bundled into the blob preserves the loopback flow's CSRF check. + */ +export const pasteCodeLogin = Effect.fn("cloud.cli_token.paste_code_login")(function* ( + promptForCode: (input: PasteCodePromptInput) => Effect.Effect, +) { + const metadata = yield* cloudCliOAuthConfig; + const hostedAppUrl = yield* hostedAppUrlConfig; + const { verifier, challenge, state } = yield* makePkceRequest; + + const pasted = yield* promptForCode({ + authorizeUrl: buildConnectAuthorizeRequestUrl({ hostedAppUrl, state, challenge }), + validate: (value) => { + const parsed = parseConnectAuthCode(value); + if (parsed === null) { + return Effect.fail("That does not look like a T3 Connect code. Copy the full code."); + } + if (parsed.state !== state) { + return Effect.fail( + "That code belongs to a different connect request. Open the URL above and try again.", + ); + } + return Effect.succeed(value); + }, + }); + const authCode = parseConnectAuthCode(pasted); + if (authCode === null || authCode.state !== state) { + return yield* new CloudCliAuthorizationError({ + cause: "Pasted code did not match this connect request.", + }); + } + + return yield* exchangeToken(metadata, { + grant_type: "authorization_code", + code: authCode.code, + redirect_uri: connectCallbackUrl(hostedAppUrl), + client_id: metadata.clientId, + code_verifier: verifier, + }); +}); + +export const make = Effect.gen(function* () { + const services = yield* Effect.context(); const secrets = yield* ServerSecretStore.ServerSecretStore; const semaphore = yield* Semaphore.make(1); const persist = Effect.fn("cloud.cli_token.persist")(function* (token: PersistedToken) { @@ -144,23 +232,6 @@ export const make = Effect.gen(function* () { return Option.some(yield* decodePersistedToken(bytesToString(encoded.value))); }); - const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( - metadata: CloudCliOAuthConfig, - params: Record, - ) { - const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( - HttpClientRequest.bodyUrlParams(params), - httpClient.execute, - Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)), - ); - const now = yield* Clock.currentTimeMillis; - return { - accessToken: response.access_token, - refreshToken: response.refresh_token ?? params.refresh_token ?? "", - expiresAtEpochMs: now + response.expires_in * 1_000, - } satisfies PersistedToken; - }); - const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) { const metadata = yield* cloudCliOAuthConfig; return yield* exchangeToken(metadata, { @@ -172,11 +243,7 @@ export const make = Effect.gen(function* () { const login = Effect.fn("cloud.cli_token.login")(function* () { const metadata = yield* cloudCliOAuthConfig; - const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); - const challenge = Encoding.encodeBase64Url( - yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), - ); - const state = yield* crypto.randomUUIDv4; + const { verifier, challenge, state } = yield* makePkceRequest; const callback = yield* Deferred.make(); const callbackRoute = HttpRouter.add( "GET", @@ -253,7 +320,10 @@ export const make = Effect.gen(function* () { }); const getExisting = semaphore.withPermits(1)( - getExistingNoLock().pipe(wrapError((cause) => new CloudCliCredentialRefreshError({ cause }))), + getExistingNoLock().pipe( + wrapError((cause) => new CloudCliCredentialRefreshError({ cause })), + Effect.provide(services), + ), ); const hasCredential = semaphore.withPermits(1)( read().pipe( @@ -267,10 +337,20 @@ export const make = Effect.gen(function* () { return Option.isSome(token) ? token.value : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist)); - }).pipe(wrapError((cause) => new CloudCliAuthorizationError({ cause }))), + }).pipe( + wrapError((cause) => new CloudCliAuthorizationError({ cause })), + Effect.provide(services), + ), ); + const store = (token: PersistedToken) => + semaphore.withPermits(1)( + persist(token).pipe( + Effect.asVoid, + wrapError((cause) => new CloudCliAuthorizationError({ cause })), + ), + ); - return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear }); + return CloudCliTokenManager.of({ get, getExisting, hasCredential, store, clear }); }); export const layer = Layer.effect(CloudCliTokenManager, make); diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index ed2e5a4cf75..1aee587ad4c 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -194,6 +194,7 @@ describe("reconcileDesiredCloudLink", () => { get: unusedSecretStoreOperation(), getExisting: Effect.succeed(Option.none()), hasCredential: unusedSecretStoreOperation(), + store: () => unusedSecretStoreOperation(), clear: unusedSecretStoreOperation(), }), ), diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index 176b31d7566..c0d7bdd0382 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -1,3 +1,4 @@ +import { CONNECT_OAUTH_SCOPES } from "@t3tools/shared/connectAuth"; import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Config from "effect/Config"; @@ -15,7 +16,8 @@ declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefi declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined; const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback"; -const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const; +const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES; +const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; function validateRelayUrl(value: string) { const relayUrl = normalizeSecureRelayUrl(value); @@ -100,6 +102,16 @@ export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) { export const relayUrlConfig = makeRelayUrlConfig(); +/** + * Hosted app origin used for the paste-code connect flow on headless + * machines. Overridable so staging/nightly builds can point their CLIs at a + * matching hosted deployment. + */ +export const hostedAppUrlConfig = Config.nonEmptyString("T3CODE_HOSTED_APP_URL").pipe( + Config.withDefault(DEFAULT_HOSTED_APP_URL), + Config.map((value) => value.trim()), +); + function makePublicValueConfig(name: string, fallback: string) { const runtimeConfig = Config.nonEmptyString(name); return (fallback ? runtimeConfig.pipe(Config.withDefault(fallback)) : runtimeConfig).pipe( From 83092b98a4f93412a5c675801d3a49281ffeb582 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 6 Jul 2026 04:04:03 -0700 Subject: [PATCH 3/6] Offer a systemd user boot service at the end of `t3 connect` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the headless connect flow (.plans/t3-connect-remote-setup.html). After linking, `t3 connect` asks whether T3 Code should run in the background whenever the machine boots. Saying yes installs a systemd user unit running `t3 serve`, enables it immediately, and runs `loginctl enable-linger` so the service survives SSH logout and reboots. Linux-only for now; other platforms get a clear skip message. `t3 connect logout` removes the unit again. Because systemd user units run with a minimal environment and fail invisibly, the unit uses only absolute paths (node binary + entry point) and appends service output and install failures to a log file whose path is printed at setup time. When the CLI is running out of the ephemeral npx cache, the exact running version is first pinned via `npm install --prefix` into ~/.t3/runtime/versions/ — a real install (t3 ships native deps like node-pty), and never `npx t3@latest` in the unit, which would make boot depend on the npm registry. Co-Authored-By: Claude Fable 5 --- apps/server/src/cli/connect.ts | 56 +++- apps/server/src/cloud/bootService.test.ts | 233 +++++++++++++++++ apps/server/src/cloud/bootService.ts | 295 ++++++++++++++++++++++ 3 files changed, 581 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/cloud/bootService.test.ts create mode 100644 apps/server/src/cloud/bootService.ts diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index a5886e5d4aa..0eaef49b6a9 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -27,8 +27,10 @@ import { } from "effect/unstable/http"; import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; +import packageJson from "../../package.json" with { type: "json" }; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as BootService from "../cloud/bootService.ts"; import * as CliState from "../cloud/CliState.ts"; import * as CliTokenManager from "../cloud/CliTokenManager.ts"; import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts"; @@ -348,6 +350,19 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { if (options.clearAuthorization) { const tokens = yield* CliTokenManager.CloudCliTokenManager; yield* tokens.clear; + + const bootService = yield* BootService.BootService; + const { installed } = yield* bootService.status.pipe( + Effect.orElseSucceed(() => ({ installed: false })), + ); + if (installed) { + yield* bootService.uninstall.pipe( + Effect.tap(Console.log("Removed the T3 Code background service.")), + Effect.catch((error) => + Console.warn(`Could not remove the background service: ${error.message}`), + ), + ); + } } yield* reportCloudDisconnectResults({ @@ -370,6 +385,7 @@ const runCloudCommand = ( | CliTokenManager.CloudCliTokenManager | RelayClient.RelayClient | EnvironmentAuth.EnvironmentAuth + | BootService.BootService | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient @@ -391,6 +407,11 @@ const runCloudCommand = ( RelayClient.layerCloudflared({ baseDir: config.baseDir }), EnvironmentAuth.runtimeLayer, ServerEnvironment.layer, + BootService.layer({ + baseDir: config.baseDir, + logsDir: config.logsDir, + cliVersion: packageJson.version, + }), headlessRelayClientTracingLayer, ).pipe( Layer.provideMerge(FetchHttpClient.layer), @@ -514,6 +535,29 @@ const connectLogoutCommand = Command.make("logout", { ), ); +const offerBootService = Effect.gen(function* () { + const bootService = yield* BootService.BootService; + const { installed } = yield* bootService.status; + if (installed) { + yield* Console.log("T3 Code is already set up to run in the background on this machine."); + return true; + } + const wanted = yield* Prompt.run( + Prompt.confirm({ + message: + "Run T3 Code in the background whenever this machine boots? " + + "It stays reachable through T3 Connect even after you log out.", + initial: true, + }), + ); + if (!wanted) { + return false; + } + const plan = yield* bootService.install; + yield* Console.log(`Background service installed. Logs: ${plan.logPath}`); + return true; +}); + export const connectCommand = Command.make("connect", { ...projectLocationFlags, paste: pasteFlag, @@ -526,11 +570,17 @@ export const connectCommand = Command.make("connect", { if (!(yield* linkEnvironmentForConnect(flags))) { return; } - yield* Console.log( - "\nConnected! This environment will be available through T3 Connect whenever T3 is running.", + yield* Console.log("\nConnected!"); + + const background = yield* offerBootService.pipe( + Effect.catchTag("BootServiceUnsupportedError", (error) => + Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), + ), ); yield* Console.log( - "Start it with `t3 serve`, or run `t3 connect status` to inspect the link.", + background + ? "\nGreat, T3 Code is now set up and ready to go." + : "\nT3 Connect is set up. Start the server with `t3 serve` to make this machine reachable.", ); }), ), diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts new file mode 100644 index 00000000000..7af7e5bcf8d --- /dev/null +++ b/apps/server/src/cloud/bootService.test.ts @@ -0,0 +1,233 @@ +// @effect-diagnostics nodeBuiltinImport:off - Tests stage fixture directories on the real filesystem. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as ProcessRunner from "../processRunner.ts"; +import * as BootService from "./bootService.ts"; + +const isUnsupportedError = Schema.is(BootService.BootServiceUnsupportedError); +const isCommandError = Schema.is(BootService.BootServiceCommandError); + +interface RecordedCommand { + readonly command: string; + readonly args: ReadonlyArray; +} + +const makeRecordingRunnerLayer = ( + commands: Array, + options?: { readonly failCommand?: string }, +) => + Layer.succeed( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.sync(() => { + commands.push({ command: input.command, args: input.args }); + const failed = input.command === options?.failCommand; + return { + stdout: "", + stderr: failed ? `${input.command} exploded` : "", + code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ); + +const makeHost = (entry: string): BootService.BootServiceHost => ({ + execPath: "/usr/local/bin/node", + cliEntryPath: entry, + cliVersion: "0.0.27", +}); + +const provideHostRefs = (home: string, platform: NodeJS.Platform = "linux") => + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, platform), + Layer.succeed(HostProcessEnvironment, { HOME: home, USER: "theo" }), + ), + ); + +const makeTestDirs = () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-boot-service-test-")); + return { + home: root, + baseDir: NodePath.join(root, ".t3"), + logsDir: NodePath.join(root, ".t3", "userdata", "logs"), + }; +}; + +it("renders a systemd unit with absolute paths and append-mode logging", () => { + const unit = BootService.renderBootServiceUnit({ + nodePath: "/usr/local/bin/node", + t3EntryPath: "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs", + baseDir: "/home/theo/.t3", + logPath: "/home/theo/.t3/userdata/logs/boot-service.log", + unitPath: "/home/theo/.config/systemd/user/t3code.service", + }); + + assert.equal( + unit, + [ + "[Unit]", + "Description=T3 Code server (T3 Connect)", + "After=network-online.target", + "", + "[Service]", + "Type=simple", + "Environment=T3CODE_HOME=/home/theo/.t3", + "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve", + "Restart=always", + "RestartSec=5", + "StandardOutput=append:/home/theo/.t3/userdata/logs/boot-service.log", + "StandardError=append:/home/theo/.t3/userdata/logs/boot-service.log", + "", + "[Install]", + "WantedBy=default.target", + "", + ].join("\n"), + ); +}); + +it("flags npx cache entry points as ephemeral", () => { + assert.isTrue( + BootService.isEphemeralNpxEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), + ); + assert.isTrue( + BootService.isEphemeralNpxEntry("C:\\Users\\theo\\AppData\\npm-cache\\_npx\\abc\\bin.mjs"), + ); + assert.isFalse(BootService.isEphemeralNpxEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs")); + assert.isFalse( + BootService.isEphemeralNpxEntry( + "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs", + ), + ); +}); + +it.layer(NodeServices.layer)("BootService", (it) => { + it.effect("installs the unit, enables the service, and enables linger", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + const plan = yield* service.install; + + // A stable entry point is reused directly — no npm install. + assert.equal(plan.t3EntryPath, "/usr/local/lib/node_modules/t3/dist/bin.mjs"); + assert.deepEqual( + commands.map((entry) => [entry.command, ...entry.args].join(" ")), + [ + "systemctl --user daemon-reload", + "systemctl --user enable --now t3code.service", + "loginctl enable-linger theo", + ], + ); + + const unitPath = NodePath.join(dirs.home, ".config", "systemd", "user", "t3code.service"); + const unit = NodeFS.readFileSync(unitPath, "utf8"); + assert.include( + unit, + "ExecStart=/usr/local/bin/node /usr/local/lib/node_modules/t3/dist/bin.mjs serve", + ); + assert.include(unit, `Environment=T3CODE_HOME=${dirs.baseDir}`); + + const status = yield* service.status; + assert.isTrue(status.installed); + + yield* service.uninstall; + assert.isFalse(NodeFS.existsSync(unitPath)); + const statusAfter = yield* service.status; + assert.isFalse(statusAfter.installed); + }), + ); + + it.effect("pins a runtime via npm install when running from the npx cache", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + const plan = yield* service.install; + + const runtimeDir = NodePath.join(dirs.baseDir, "runtime", "versions", "0.0.27"); + assert.equal( + plan.t3EntryPath, + NodePath.join(runtimeDir, "node_modules", "t3", "dist", "bin.mjs"), + ); + assert.deepEqual(commands[0], { + command: "npm", + args: ["install", "--prefix", runtimeDir, "--no-fund", "--no-audit", "t3@0.0.27"], + }); + }), + ); + + it.effect("fails on non-Linux platforms without touching the filesystem", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), + }).pipe( + Effect.provide(makeRecordingRunnerLayer(commands)), + provideHostRefs(dirs.home, "darwin"), + ); + + const error = yield* service.install.pipe(Effect.flip); + assert.isTrue(isUnsupportedError(error)); + assert.lengthOf(commands, 0); + assert.isFalse( + NodeFS.existsSync(NodePath.join(dirs.home, ".config", "systemd", "user", "t3code.service")), + ); + }), + ); + + it.effect("appends failed steps to the boot-service log", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), + }).pipe( + Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "systemctl" })), + provideHostRefs(dirs.home), + ); + + const error = yield* service.install.pipe(Effect.flip); + assert.isTrue(isCommandError(error)); + assert.include(error.message, "systemctl exploded"); + + const logPath = NodePath.join(dirs.logsDir, "boot-service.log"); + assert.isTrue(NodeFS.existsSync(logPath)); + assert.include(NodeFS.readFileSync(logPath, "utf8"), "systemctl exploded"); + }), + ); +}); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts new file mode 100644 index 00000000000..a40d56d2efd --- /dev/null +++ b/apps/server/src/cloud/bootService.ts @@ -0,0 +1,295 @@ +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as ProcessRunner from "../processRunner.ts"; + +/** + * Installs T3 Code as a per-user boot service so a connected machine stays + * reachable through T3 Connect after the SSH session ends. Linux-only for + * now: systemd user unit + loginctl enable-linger. The service runs a pinned + * runtime installed under /runtime — never `npx t3`, whose cache is + * ephemeral and whose registry fetch at boot would make startup depend on + * the network. + */ + +export const BOOT_SERVICE_NAME = "t3code"; +export const BOOT_RUNTIME_DIR = "runtime"; + +const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; + +/** + * `npx t3` runs out of npm's ephemeral cache, which can be evicted at any + * time — a boot service must never point there. Global installs, repo + * checkouts, and the pinned runtime below are all stable. + */ +export function isEphemeralNpxEntry(entryPath: string): boolean { + return entryPath.includes("/_npx/") || entryPath.includes("\\_npx\\"); +} + +export interface BootServicePlan { + /** Absolute path of the node binary running this CLI. */ + readonly nodePath: string; + /** Absolute path of the pinned t3 entry point the unit will run. */ + readonly t3EntryPath: string; + readonly baseDir: string; + readonly logPath: string; + readonly unitPath: string; +} + +/** + * Pure so it is testable byte-for-byte. systemd user units run with a + * minimal environment: every path must be absolute, and the service must + * not rely on PATH, nvm shims, or shell profiles. Failures land in + * `logPath` because `systemctl --user` failures are otherwise invisible. + */ +export function renderBootServiceUnit(plan: BootServicePlan): string { + return [ + "[Unit]", + "Description=T3 Code server (T3 Connect)", + "After=network-online.target", + "", + "[Service]", + "Type=simple", + `Environment=T3CODE_HOME=${plan.baseDir}`, + `ExecStart=${plan.nodePath} ${plan.t3EntryPath} serve`, + "Restart=always", + "RestartSec=5", + `StandardOutput=append:${plan.logPath}`, + `StandardError=append:${plan.logPath}`, + "", + "[Install]", + "WantedBy=default.target", + "", + ].join("\n"); +} + +export class BootServiceUnsupportedError extends Schema.TaggedErrorClass()( + "BootServiceUnsupportedError", + { platform: Schema.String }, +) { + override get message(): string { + return `Background setup currently supports Linux with systemd; this machine reports '${this.platform}'.`; + } +} + +export class BootServiceCommandError extends Schema.TaggedErrorClass()( + "BootServiceCommandError", + { + step: Schema.String, + detail: Schema.String, + }, +) { + override get message(): string { + return `Background setup failed while ${this.step}: ${this.detail}`; + } +} + +export class BootServiceInstallError extends Schema.TaggedErrorClass()( + "BootServiceInstallError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not set up the T3 Code background service."; + } +} + +export type BootServiceError = + | BootServiceUnsupportedError + | BootServiceCommandError + | BootServiceInstallError; + +export interface BootServiceStatus { + readonly installed: boolean; + readonly unitPath: string; + readonly logPath: string; +} + +export class BootService extends Context.Service< + BootService, + { + /** Installs the pinned runtime + unit, enables linger, starts the service. */ + readonly install: Effect.Effect; + /** Stops and removes the unit; leaves the pinned runtime for reuse. */ + readonly uninstall: Effect.Effect; + readonly status: Effect.Effect; + } +>()("t3/cloud/bootService") {} + +export interface BootServiceHost { + readonly execPath: string; + readonly cliEntryPath: string; + readonly cliVersion: string; +} + +const defaultHost = (cliVersion: string): BootServiceHost => ({ + execPath: process.execPath, + // When running the packed CLI this is dist/bin.mjs; when stable (global + // install, repo checkout) the boot service runs this same artifact. + cliEntryPath: process.argv[1] ?? "", + cliVersion, +}); + +export const make = Effect.fnUntraced(function* (input: { + readonly baseDir: string; + readonly logsDir: string; + readonly cliVersion: string; + readonly host?: BootServiceHost; +}) { + const host = input.host ?? defaultHost(input.cliVersion); + const platform = yield* HostProcessPlatform; + const env = yield* HostProcessEnvironment; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + + const homeDir = env.HOME ?? ""; + const unitDir = path.join(homeDir, ".config", "systemd", "user"); + const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); + const logPath = path.join(input.logsDir, "boot-service.log"); + const runtimeVersionDir = path.join(input.baseDir, BOOT_RUNTIME_DIR, "versions", host.cliVersion); + const runtimeEntryPath = path.join(runtimeVersionDir, "node_modules", "t3", "dist", "bin.mjs"); + + const requireSystemdLinux = Effect.gen(function* () { + if (platform !== "linux" || homeDir === "") { + return yield* new BootServiceUnsupportedError({ platform }); + } + }); + + const runStep = (step: string, command: string, args: ReadonlyArray) => + runner.run({ command, args, env: { ...env } }).pipe( + Effect.mapError( + (cause) => new BootServiceCommandError({ step, detail: String(cause.message) }), + ), + Effect.filterOrFail( + (result) => result.code === 0, + (result) => + new BootServiceCommandError({ + step, + detail: result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`, + }), + ), + Effect.tapError((error) => + DateTime.now.pipe( + Effect.flatMap((now) => + fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { + flag: "a", + }), + ), + Effect.ignore, + ), + ), + ); + + /** + * Resolves the entry point the unit should run. A stable install (global + * bin, repo checkout, previously pinned runtime) is used as-is; an npx + * cache entry is replaced by `npm install --prefix`-ing the exact running + * version into /runtime/versions/. A real install (not a copy + * of bin.mjs) because t3 ships native deps like node-pty. + */ + const resolveStableEntry = Effect.gen(function* () { + if (!isEphemeralNpxEntry(host.cliEntryPath)) { + return host.cliEntryPath; + } + const alreadyPinned = yield* fs + .exists(runtimeEntryPath) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + if (alreadyPinned) { + return runtimeEntryPath; + } + yield* fs + .makeDirectory(runtimeVersionDir, { recursive: true }) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + yield* runStep("installing the pinned t3 runtime (this can take a minute)", "npm", [ + "install", + "--prefix", + runtimeVersionDir, + "--no-fund", + "--no-audit", + `t3@${host.cliVersion}`, + ]); + return runtimeEntryPath; + }); + + const install: BootService["Service"]["install"] = Effect.gen(function* () { + yield* requireSystemdLinux; + yield* fs + .makeDirectory(input.logsDir, { recursive: true }) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + + const plan: BootServicePlan = { + nodePath: host.execPath, + t3EntryPath: yield* resolveStableEntry, + baseDir: input.baseDir, + logPath, + unitPath, + }; + + yield* fs.makeDirectory(unitDir, { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(unitPath, renderBootServiceUnit(plan))), + Effect.mapError((cause) => new BootServiceInstallError({ cause })), + ); + + yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); + yield* runStep("enabling the service", "systemctl", [ + "--user", + "enable", + "--now", + BOOT_SERVICE_UNIT_FILE, + ]); + // Linger keeps the user manager (and this service) running without an + // open session — the whole point on a box reached over SSH. + yield* runStep("enabling lingering for this user", "loginctl", [ + "enable-linger", + env.USER ?? "", + ]); + + return plan; + }).pipe(Effect.withSpan("cloud.boot_service.install")); + + const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { + yield* requireSystemdLinux; + const exists = yield* fs + .exists(unitPath) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + if (!exists) { + return; + } + yield* runStep("stopping the service", "systemctl", [ + "--user", + "disable", + "--now", + BOOT_SERVICE_UNIT_FILE, + ]).pipe(Effect.ignore({ log: true })); + yield* fs + .remove(unitPath) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); + }).pipe(Effect.withSpan("cloud.boot_service.uninstall")); + + const status: BootService["Service"]["status"] = Effect.gen(function* () { + if (platform !== "linux" || homeDir === "") { + return { installed: false, unitPath, logPath }; + } + const installed = yield* fs + .exists(unitPath) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + return { installed, unitPath, logPath }; + }).pipe(Effect.withSpan("cloud.boot_service.status")); + + return BootService.of({ install, uninstall, status }); +}); + +export const layer = (input: { + readonly baseDir: string; + readonly logsDir: string; + readonly cliVersion: string; + readonly host?: BootServiceHost; +}) => Layer.effect(BootService, make(input)).pipe(Layer.provide(ProcessRunner.layer)); From a4be126541be5ac57b3ec37aedcb4b5933739576 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 6 Jul 2026 05:09:53 -0700 Subject: [PATCH 4/6] Harden the connect flow based on review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on the headless-connect branch, focused on simplicity and reliability. Reliability fixes: - Boot service: quote/escape systemd unit values (paths with spaces or percents produced word-split ExecStart lines); drop the inert After=network-online.target (not a user-manager unit); add WorkingDirectory=%h so boot-started servers don't run from /; run `loginctl enable-linger` without a username ($USER can be stale or unset); give the pinned npm install a 10-minute timeout (default 60s killed healthy node-pty builds) and record success with a sentinel, removing the half-installed tree on failure so retries don't enable a crashlooping service; detect pnpm dlx / bunx caches as ephemeral, not just npx. - BootService.status now reports supported/current: connect no longer prompts on platforms where install can only fail, offers a repair when the installed unit is stale (old runtime path after an upgrade), and a boot-service failure warns instead of failing the whole command after connect already succeeded. uninstall reports whether it removed a unit and logout no longer pre-checks status (a status error used to silently skip removal). - Paste flow: a stored credential whose refresh fails now falls through to a fresh sign-in instead of dead-ending the command, and the paste prompt gets the same 10-minute timeout as the loopback flow. - Callback page: read the expected state without deleting it — consume-on-render was eaten by StrictMode's double render, disabling the CSRF check; add a Sign in button for a dismissed Clerk modal. Simplicity/duplication fixes: single DEFAULT_HOSTED_APP_URL and readHashParams in packages/shared; the loopback flow now builds its authorize URL with the shared builder; one checkConnectAuthCode used by both the prompt validation and the authoritative re-check; shared AuthSurfaceShell for the pairing/connect pages; route gating moved to cloud/connectCliAuth (routes no longer import from each other); detectHeadlessSession reads HostProcessEnvironment; dropped the duplicated cliVersion on BootServiceHost and unused exports. Co-Authored-By: Claude Fable 5 --- apps/server/src/cli/connect.ts | 71 +++++-- apps/server/src/cloud/CliTokenManager.ts | 61 +++--- apps/server/src/cloud/bootService.test.ts | 100 +++++++++- apps/server/src/cloud/bootService.ts | 182 ++++++++++++------ apps/server/src/cloud/publicConfig.ts | 9 +- apps/web/src/cloud/connectCliAuth.ts | 35 ++-- apps/web/src/cloud/publicConfig.ts | 2 +- .../src/components/auth/AuthSurfaceShell.tsx | 26 +++ .../cloud/ConnectCliAuthSurface.tsx | 60 +++--- apps/web/src/hostedPairing.ts | 4 +- apps/web/src/routes/connect.tsx | 10 +- apps/web/src/routes/connect_.callback.tsx | 2 +- packages/shared/src/connectAuth.ts | 36 +++- packages/shared/src/remote.ts | 2 +- 14 files changed, 413 insertions(+), 187 deletions(-) create mode 100644 apps/web/src/components/auth/AuthSurfaceShell.tsx diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 0eaef49b6a9..757d617235f 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -5,6 +5,7 @@ import { type RelayClientInstallProgressStage, } from "@t3tools/contracts"; import { RelayOkResponse } from "@t3tools/contracts/relay"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as RelayClient from "@t3tools/shared/relayClient"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; @@ -59,8 +60,9 @@ const pasteFlag = Flag.boolean("paste").pipe( * Inside an SSH session there is no local browser to complete the loopback * OAuth callback, so the paste-code flow is the only one that can work. */ -const detectHeadlessSession = Effect.sync( - () => process.env.SSH_CONNECTION !== undefined || process.env.SSH_TTY !== undefined, +const detectHeadlessSession = Effect.map( + HostProcessEnvironment, + (env) => env.SSH_CONNECTION !== undefined || env.SSH_TTY !== undefined, ); const promptForPastedCode = ({ authorizeUrl, validate }: CliTokenManager.PasteCodePromptInput) => @@ -79,7 +81,15 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: { yield* tokens.get; return; } - const existing = yield* tokens.getExisting; + // A stored credential whose refresh fails (revoked, expired grant) must + // fall through to a fresh paste login, not dead-end the command. + const existing = yield* tokens.getExisting.pipe( + Effect.catchTag("CloudCliCredentialRefreshError", () => + Console.log( + "The stored T3 Connect credential could not be refreshed; signing in again.", + ).pipe(Effect.as(Option.none())), + ), + ); if (Option.isSome(existing)) { return; } @@ -351,18 +361,20 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { const tokens = yield* CliTokenManager.CloudCliTokenManager; yield* tokens.clear; + // uninstall itself no-ops when nothing is installed (and on non-Linux), + // so no status pre-check that could mask a real removal failure. const bootService = yield* BootService.BootService; - const { installed } = yield* bootService.status.pipe( - Effect.orElseSucceed(() => ({ installed: false })), - ); - if (installed) { - yield* bootService.uninstall.pipe( - Effect.tap(Console.log("Removed the T3 Code background service.")), - Effect.catch((error) => - Console.warn(`Could not remove the background service: ${error.message}`), + yield* bootService.uninstall.pipe( + Effect.tap((removed) => + removed ? Console.log("Removed the T3 Code background service.") : Effect.void, + ), + Effect.catchTag("BootServiceUnsupportedError", () => Effect.succeed(false)), + Effect.catch((error) => + Console.warn(`Could not remove the background service: ${error.message}`).pipe( + Effect.as(false), ), - ); - } + ), + ); } yield* reportCloudDisconnectResults({ @@ -537,16 +549,22 @@ const connectLogoutCommand = Command.make("logout", { const offerBootService = Effect.gen(function* () { const bootService = yield* BootService.BootService; - const { installed } = yield* bootService.status; - if (installed) { + const { supported, installed, current } = yield* bootService.status; + if (!supported) { + // Don't prompt for something that can only fail; background setup is + // Linux/systemd-only for now. + return false; + } + if (installed && current) { yield* Console.log("T3 Code is already set up to run in the background on this machine."); return true; } const wanted = yield* Prompt.run( Prompt.confirm({ - message: - "Run T3 Code in the background whenever this machine boots? " + - "It stays reachable through T3 Connect even after you log out.", + message: installed + ? "The installed T3 Code background service is from an older setup. Update it now?" + : "Run T3 Code in the background whenever this machine boots? " + + "It stays reachable through T3 Connect even after you log out.", initial: true, }), ); @@ -572,10 +590,21 @@ export const connectCommand = Command.make("connect", { } yield* Console.log("\nConnected!"); + // Connect itself already succeeded; a boot-service failure must not + // fail the command, just tell the user what happened and move on. const background = yield* offerBootService.pipe( - Effect.catchTag("BootServiceUnsupportedError", (error) => - Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), - ), + Effect.catchTags({ + BootServiceUnsupportedError: (error) => + Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), + BootServiceCommandError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe( + Effect.as(false), + ), + BootServiceInstallError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe( + Effect.as(false), + ), + }), ); yield* Console.log( background diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 047c3d87d0f..1243915e60f 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -23,8 +23,9 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import { buildConnectAuthorizeRequestUrl, + buildConnectClerkAuthorizeUrl, + checkConnectAuthCode, connectCallbackUrl, - parseConnectAuthCode, } from "@t3tools/shared/connectAuth"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -184,23 +185,22 @@ export const pasteCodeLogin = Effect.fn("cloud.cli_token.paste_code_login")(func const pasted = yield* promptForCode({ authorizeUrl: buildConnectAuthorizeRequestUrl({ hostedAppUrl, state, challenge }), validate: (value) => { - const parsed = parseConnectAuthCode(value); - if (parsed === null) { - return Effect.fail("That does not look like a T3 Connect code. Copy the full code."); - } - if (parsed.state !== state) { - return Effect.fail( - "That code belongs to a different connect request. Open the URL above and try again.", - ); - } - return Effect.succeed(value); + const checked = checkConnectAuthCode(value, state); + return typeof checked === "string" ? Effect.fail(checked) : Effect.succeed(value); }, - }); - const authCode = parseConnectAuthCode(pasted); - if (authCode === null || authCode.state !== state) { - return yield* new CloudCliAuthorizationError({ - cause: "Pasted code did not match this connect request.", - }); + }).pipe( + // Clerk authorization codes expire on this horizon anyway; matching the + // loopback flow's timeout turns an abandoned prompt into a clear error. + Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), + Effect.catchTag("TimeoutError", (cause) => + Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })), + ), + ); + // promptForCode is caller-supplied, so re-check the returned value rather + // than trusting that the prompt ran validate. + const authCode = checkConnectAuthCode(pasted, state); + if (typeof authCode === "string") { + return yield* new CloudCliAuthorizationError({ cause: authCode }); } return yield* exchangeToken(metadata, { @@ -213,7 +213,14 @@ export const pasteCodeLogin = Effect.fn("cloud.cli_token.paste_code_login")(func }); export const make = Effect.gen(function* () { - const services = yield* Effect.context(); + // Capture exactly the services the login/refresh flows need at build time + // (matching the pre-paste-flow behavior of capturing the instances), not + // the whole ambient context. + const crypto = yield* Crypto.Crypto; + const httpClient = yield* HttpClient.HttpClient; + const services = Context.make(Crypto.Crypto, crypto).pipe( + Context.add(HttpClient.HttpClient, httpClient), + ); const secrets = yield* ServerSecretStore.ServerSecretStore; const semaphore = yield* Semaphore.make(1); const persist = Effect.fn("cloud.cli_token.persist")(function* (token: PersistedToken) { @@ -281,15 +288,15 @@ export const make = Effect.gen(function* () { ), Layer.build, ); - const authorizationUrl = new URL(metadata.authorizationEndpoint); - authorizationUrl.searchParams.set("client_id", metadata.clientId); - authorizationUrl.searchParams.set("redirect_uri", metadata.redirectUri); - authorizationUrl.searchParams.set("response_type", "code"); - authorizationUrl.searchParams.set("scope", metadata.scopes.join(" ")); - authorizationUrl.searchParams.set("state", state); - authorizationUrl.searchParams.set("code_challenge", challenge); - authorizationUrl.searchParams.set("code_challenge_method", "S256"); - yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl.toString()}\n`); + const authorizationUrl = buildConnectClerkAuthorizeUrl({ + authorizationEndpoint: metadata.authorizationEndpoint, + clientId: metadata.clientId, + redirectUri: metadata.redirectUri, + scopes: metadata.scopes, + state, + challenge, + }); + yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl}\n`); const code = yield* Deferred.await(callback).pipe( Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), Effect.catchTag("TimeoutError", (cause) => diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 7af7e5bcf8d..d9c7b706b9a 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -49,7 +49,6 @@ const makeRecordingRunnerLayer = ( const makeHost = (entry: string): BootService.BootServiceHost => ({ execPath: "/usr/local/bin/node", cliEntryPath: entry, - cliVersion: "0.0.27", }); const provideHostRefs = (home: string, platform: NodeJS.Platform = "linux") => @@ -83,10 +82,10 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => { [ "[Unit]", "Description=T3 Code server (T3 Connect)", - "After=network-online.target", "", "[Service]", "Type=simple", + "WorkingDirectory=%h", "Environment=T3CODE_HOME=/home/theo/.t3", "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve", "Restart=always", @@ -101,16 +100,40 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => { ); }); -it("flags npx cache entry points as ephemeral", () => { +it("quotes systemd values containing spaces and escapes percent specifiers", () => { + assert.equal(BootService.quoteSystemdValue("/plain/path"), "/plain/path"); + assert.equal(BootService.quoteSystemdValue("/home/me/T3 Data"), '"/home/me/T3 Data"'); + assert.equal(BootService.quoteSystemdValue("/opt/100%cpu"), "/opt/100%%cpu"); + + const unit = BootService.renderBootServiceUnit({ + nodePath: "/home/me/my tools/node", + t3EntryPath: "/home/me/T3 Data/bin.mjs", + baseDir: "/home/me/T3 Data", + logPath: "/home/me/logs/boot.log", + unitPath: "/home/me/.config/systemd/user/t3code.service", + }); + assert.include(unit, 'ExecStart="/home/me/my tools/node" "/home/me/T3 Data/bin.mjs" serve'); + assert.include(unit, 'Environment=T3CODE_HOME="/home/me/T3 Data"'); +}); + +it("flags package-manager cache entry points as ephemeral", () => { + assert.isTrue( + BootService.isEphemeralCacheEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), + ); + assert.isTrue( + BootService.isEphemeralCacheEntry("C:\\Users\\theo\\AppData\\npm-cache\\_npx\\abc\\bin.mjs"), + ); assert.isTrue( - BootService.isEphemeralNpxEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), + BootService.isEphemeralCacheEntry( + "/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", + ), ); assert.isTrue( - BootService.isEphemeralNpxEntry("C:\\Users\\theo\\AppData\\npm-cache\\_npx\\abc\\bin.mjs"), + BootService.isEphemeralCacheEntry("/home/theo/.bun/install/cache/t3@0.0.27/dist/bin.mjs"), ); - assert.isFalse(BootService.isEphemeralNpxEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs")); + assert.isFalse(BootService.isEphemeralCacheEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs")); assert.isFalse( - BootService.isEphemeralNpxEntry( + BootService.isEphemeralCacheEntry( "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs", ), ); @@ -137,7 +160,7 @@ it.layer(NodeServices.layer)("BootService", (it) => { [ "systemctl --user daemon-reload", "systemctl --user enable --now t3code.service", - "loginctl enable-linger theo", + "loginctl enable-linger", ], ); @@ -150,12 +173,17 @@ it.layer(NodeServices.layer)("BootService", (it) => { assert.include(unit, `Environment=T3CODE_HOME=${dirs.baseDir}`); const status = yield* service.status; + assert.isTrue(status.supported); assert.isTrue(status.installed); + assert.isTrue(status.current); - yield* service.uninstall; + const removed = yield* service.uninstall; + assert.isTrue(removed); assert.isFalse(NodeFS.existsSync(unitPath)); const statusAfter = yield* service.status; assert.isFalse(statusAfter.installed); + const removedAgain = yield* service.uninstall; + assert.isFalse(removedAgain); }), ); @@ -181,6 +209,56 @@ it.layer(NodeServices.layer)("BootService", (it) => { command: "npm", args: ["install", "--prefix", runtimeDir, "--no-fund", "--no-audit", "t3@0.0.27"], }); + // Success is recorded via a sentinel so interrupted installs re-run. + assert.isTrue(NodeFS.existsSync(NodePath.join(runtimeDir, ".install-complete"))); + }), + ); + + it.effect("cleans up and fails when the pinned runtime install fails", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"), + }).pipe( + Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "npm" })), + provideHostRefs(dirs.home), + ); + + const error = yield* service.install.pipe(Effect.flip); + assert.isTrue(isCommandError(error)); + const runtimeDir = NodePath.join(dirs.baseDir, "runtime", "versions", "0.0.27"); + // The half-installed tree must not be reused by the next attempt. + assert.isFalse(NodeFS.existsSync(runtimeDir)); + assert.isFalse(NodeFS.existsSync(NodePath.join(runtimeDir, ".install-complete"))); + }), + ); + + it.effect("reports an installed-but-stale unit so connect can offer a repair", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + const unitDir = NodePath.join(dirs.home, ".config", "systemd", "user"); + NodeFS.mkdirSync(unitDir, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(unitDir, "t3code.service"), + "[Service]\nExecStart=/old/node /old/t3 serve\n", + ); + + const status = yield* service.status; + assert.isTrue(status.supported); + assert.isTrue(status.installed); + assert.isFalse(status.current); }), ); @@ -204,6 +282,10 @@ it.layer(NodeServices.layer)("BootService", (it) => { assert.isFalse( NodeFS.existsSync(NodePath.join(dirs.home, ".config", "systemd", "user", "t3code.service")), ); + + const status = yield* service.status; + assert.isFalse(status.supported); + assert.isFalse(status.installed); }), ); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index a40d56d2efd..516ed6c9857 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -1,5 +1,6 @@ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -19,18 +20,39 @@ import * as ProcessRunner from "../processRunner.ts"; * the network. */ -export const BOOT_SERVICE_NAME = "t3code"; -export const BOOT_RUNTIME_DIR = "runtime"; +const BOOT_SERVICE_NAME = "t3code"; +const BOOT_RUNTIME_DIR = "runtime"; const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; +const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); + +const EPHEMERAL_CACHE_SEGMENTS = [ + "/_npx/", // npx + "\\_npx\\", + "/pnpm/dlx", // pnpm dlx (~/.cache/pnpm/dlx and $PNPM_HOME/.pnpm/dlx) + "/.pnpm/dlx", + "/.bun/install/cache/", // bunx +]; + +/** + * `npx t3` (and pnpm dlx / bunx) run out of ephemeral package-manager + * caches that can be evicted at any time — a boot service must never point + * there. Global installs, repo checkouts, and the pinned runtime below are + * all stable. + */ +export function isEphemeralCacheEntry(entryPath: string): boolean { + return EPHEMERAL_CACHE_SEGMENTS.some((segment) => entryPath.includes(segment)); +} /** - * `npx t3` runs out of npm's ephemeral cache, which can be evicted at any - * time — a boot service must never point there. Global installs, repo - * checkouts, and the pinned runtime below are all stable. + * systemd word-splits ExecStart and Environment values and expands `%` + * specifiers, so paths with spaces or percents must be quoted and escaped. */ -export function isEphemeralNpxEntry(entryPath: string): boolean { - return entryPath.includes("/_npx/") || entryPath.includes("\\_npx\\"); +export function quoteSystemdValue(value: string): string { + const escaped = value.replaceAll("%", "%%"); + return /[\s"'\\]/.test(escaped) + ? `"${escaped.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` + : escaped; } export interface BootServicePlan { @@ -50,15 +72,18 @@ export interface BootServicePlan { * `logPath` because `systemctl --user` failures are otherwise invisible. */ export function renderBootServiceUnit(plan: BootServicePlan): string { + // No After=network-online.target: it does not exist in the systemd *user* + // manager, so ordering on it is silently ignored. The server retries its + // relay connection, and Restart=always covers early-boot failures. return [ "[Unit]", "Description=T3 Code server (T3 Connect)", - "After=network-online.target", "", "[Service]", "Type=simple", - `Environment=T3CODE_HOME=${plan.baseDir}`, - `ExecStart=${plan.nodePath} ${plan.t3EntryPath} serve`, + "WorkingDirectory=%h", + `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, + `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`, "Restart=always", "RestartSec=5", `StandardOutput=append:${plan.logPath}`, @@ -106,7 +131,10 @@ export type BootServiceError = | BootServiceInstallError; export interface BootServiceStatus { + readonly supported: boolean; readonly installed: boolean; + /** False when the installed unit no longer matches what install would write. */ + readonly current: boolean; readonly unitPath: string; readonly logPath: string; } @@ -116,8 +144,11 @@ export class BootService extends Context.Service< { /** Installs the pinned runtime + unit, enables linger, starts the service. */ readonly install: Effect.Effect; - /** Stops and removes the unit; leaves the pinned runtime for reuse. */ - readonly uninstall: Effect.Effect; + /** + * Stops and removes the unit; leaves the pinned runtime for reuse. + * Returns whether a unit was actually removed. + */ + readonly uninstall: Effect.Effect; readonly status: Effect.Effect; } >()("t3/cloud/bootService") {} @@ -125,15 +156,13 @@ export class BootService extends Context.Service< export interface BootServiceHost { readonly execPath: string; readonly cliEntryPath: string; - readonly cliVersion: string; } -const defaultHost = (cliVersion: string): BootServiceHost => ({ +const defaultHost = (): BootServiceHost => ({ execPath: process.execPath, // When running the packed CLI this is dist/bin.mjs; when stable (global // install, repo checkout) the boot service runs this same artifact. cliEntryPath: process.argv[1] ?? "", - cliVersion, }); export const make = Effect.fnUntraced(function* (input: { @@ -142,7 +171,7 @@ export const make = Effect.fnUntraced(function* (input: { readonly cliVersion: string; readonly host?: BootServiceHost; }) { - const host = input.host ?? defaultHost(input.cliVersion); + const host = input.host ?? defaultHost(); const platform = yield* HostProcessPlatform; const env = yield* HostProcessEnvironment; const fs = yield* FileSystem.FileSystem; @@ -153,8 +182,14 @@ export const make = Effect.fnUntraced(function* (input: { const unitDir = path.join(homeDir, ".config", "systemd", "user"); const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); const logPath = path.join(input.logsDir, "boot-service.log"); - const runtimeVersionDir = path.join(input.baseDir, BOOT_RUNTIME_DIR, "versions", host.cliVersion); + const runtimeVersionDir = path.join( + input.baseDir, + BOOT_RUNTIME_DIR, + "versions", + input.cliVersion, + ); const runtimeEntryPath = path.join(runtimeVersionDir, "node_modules", "t3", "dist", "bin.mjs"); + const runtimeSentinelPath = path.join(runtimeVersionDir, ".install-complete"); const requireSystemdLinux = Effect.gen(function* () { if (platform !== "linux" || homeDir === "") { @@ -162,8 +197,13 @@ export const make = Effect.fnUntraced(function* (input: { } }); - const runStep = (step: string, command: string, args: ReadonlyArray) => - runner.run({ command, args, env: { ...env } }).pipe( + const runStep = ( + step: string, + command: string, + args: ReadonlyArray, + options?: { readonly timeout?: Duration.Input }, + ) => + runner.run({ command, args, env: { ...env }, timeout: options?.timeout }).pipe( Effect.mapError( (cause) => new BootServiceCommandError({ step, detail: String(cause.message) }), ), @@ -188,49 +228,74 @@ export const make = Effect.fnUntraced(function* (input: { ); /** - * Resolves the entry point the unit should run. A stable install (global - * bin, repo checkout, previously pinned runtime) is used as-is; an npx - * cache entry is replaced by `npm install --prefix`-ing the exact running + * Ensures plannedEntryPath exists before the unit points at it. A stable + * install (global bin, repo checkout) is used as-is; an ephemeral cache + * entry is replaced by `npm install --prefix`-ing the exact running * version into /runtime/versions/. A real install (not a copy * of bin.mjs) because t3 ships native deps like node-pty. */ - const resolveStableEntry = Effect.gen(function* () { - if (!isEphemeralNpxEntry(host.cliEntryPath)) { - return host.cliEntryPath; + const ensurePinnedRuntime = Effect.gen(function* () { + if (!isEphemeralCacheEntry(host.cliEntryPath)) { + return; } + // The sentinel is written only after npm exits 0. Checking the entry + // file alone is not enough: npm extracts files before running native + // builds (node-pty), so a killed install leaves a plausible-looking but + // broken tree behind. const alreadyPinned = yield* fs - .exists(runtimeEntryPath) + .exists(runtimeSentinelPath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); if (alreadyPinned) { - return runtimeEntryPath; + return; } + yield* fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe( + Effect.andThen(fs.makeDirectory(runtimeVersionDir, { recursive: true })), + Effect.mapError((cause) => new BootServiceInstallError({ cause })), + ); + yield* runStep( + "installing the pinned t3 runtime (this can take a few minutes)", + "npm", + [ + "install", + "--prefix", + runtimeVersionDir, + "--no-fund", + "--no-audit", + `t3@${input.cliVersion}`, + ], + // Native deps (node-pty) can compile from source on slow boxes; the + // ProcessRunner default of 60s would kill a healthy install. + { timeout: PINNED_RUNTIME_INSTALL_TIMEOUT }, + ).pipe( + Effect.tapError(() => + fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); yield* fs - .makeDirectory(runtimeVersionDir, { recursive: true }) + .writeFileString(runtimeSentinelPath, `${input.cliVersion}\n`) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - yield* runStep("installing the pinned t3 runtime (this can take a minute)", "npm", [ - "install", - "--prefix", - runtimeVersionDir, - "--no-fund", - "--no-audit", - `t3@${host.cliVersion}`, - ]); - return runtimeEntryPath; }); + // Where the unit will point: derivable without touching the network, so + // status can compare units purely; install materializes it first. + const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath) + ? runtimeEntryPath + : host.cliEntryPath; + const plan: BootServicePlan = { + nodePath: host.execPath, + t3EntryPath: plannedEntryPath, + baseDir: input.baseDir, + logPath, + unitPath, + }; + const install: BootService["Service"]["install"] = Effect.gen(function* () { yield* requireSystemdLinux; yield* fs .makeDirectory(input.logsDir, { recursive: true }) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - const plan: BootServicePlan = { - nodePath: host.execPath, - t3EntryPath: yield* resolveStableEntry, - baseDir: input.baseDir, - logPath, - unitPath, - }; + yield* ensurePinnedRuntime; yield* fs.makeDirectory(unitDir, { recursive: true }).pipe( Effect.andThen(fs.writeFileString(unitPath, renderBootServiceUnit(plan))), @@ -245,11 +310,10 @@ export const make = Effect.fnUntraced(function* (input: { BOOT_SERVICE_UNIT_FILE, ]); // Linger keeps the user manager (and this service) running without an - // open session — the whole point on a box reached over SSH. - yield* runStep("enabling lingering for this user", "loginctl", [ - "enable-linger", - env.USER ?? "", - ]); + // open session — the whole point on a box reached over SSH. No username + // argument: loginctl defaults to the calling user, which is always + // right, while $USER can be stale (su without -l) or unset. + yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]); return plan; }).pipe(Effect.withSpan("cloud.boot_service.install")); @@ -260,7 +324,7 @@ export const make = Effect.fnUntraced(function* (input: { .exists(unitPath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); if (!exists) { - return; + return false; } yield* runStep("stopping the service", "systemctl", [ "--user", @@ -272,16 +336,24 @@ export const make = Effect.fnUntraced(function* (input: { .remove(unitPath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); + return true; }).pipe(Effect.withSpan("cloud.boot_service.uninstall")); const status: BootService["Service"]["status"] = Effect.gen(function* () { if (platform !== "linux" || homeDir === "") { - return { installed: false, unitPath, logPath }; + return { supported: false, installed: false, current: false, unitPath, logPath }; } - const installed = yield* fs - .exists(unitPath) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - return { installed, unitPath, logPath }; + const unit = yield* fs.readFileString(unitPath).pipe( + Effect.map((content): string | null => content), + Effect.orElseSucceed((): string | null => null), + ); + if (unit === null) { + return { supported: true, installed: false, current: false, unitPath, logPath }; + } + // A unit written by an older CLI (different pinned runtime, different + // node) counts as installed but stale, so connect offers a repair. + const current = unit === renderBootServiceUnit(plan); + return { supported: true, installed: true, current, unitPath, logPath }; }).pipe(Effect.withSpan("cloud.boot_service.status")); return BootService.of({ install, uninstall, status }); diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index c0d7bdd0382..9f76e87db2f 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -1,4 +1,4 @@ -import { CONNECT_OAUTH_SCOPES } from "@t3tools/shared/connectAuth"; +import { CONNECT_OAUTH_SCOPES, DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Config from "effect/Config"; @@ -17,7 +17,6 @@ declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefine const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback"; const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES; -const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; function validateRelayUrl(value: string) { const relayUrl = normalizeSecureRelayUrl(value); @@ -107,9 +106,9 @@ export const relayUrlConfig = makeRelayUrlConfig(); * machines. Overridable so staging/nightly builds can point their CLIs at a * matching hosted deployment. */ -export const hostedAppUrlConfig = Config.nonEmptyString("T3CODE_HOSTED_APP_URL").pipe( - Config.withDefault(DEFAULT_HOSTED_APP_URL), - Config.map((value) => value.trim()), +export const hostedAppUrlConfig = makePublicValueConfig( + "T3CODE_HOSTED_APP_URL", + DEFAULT_HOSTED_APP_URL, ); function makePublicValueConfig(name: string, fallback: string) { diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts index 2afb277432d..78857f874c4 100644 --- a/apps/web/src/cloud/connectCliAuth.ts +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -1,20 +1,16 @@ import { buildConnectClerkAuthorizeUrl, - CONNECT_CALLBACK_PATH, + connectCallbackUrl, CONNECT_OAUTH_SCOPES, - readConnectAuthorizeRequest, type ConnectAuthorizeRequest, } from "@t3tools/shared/connectAuth"; import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; -import { resolveCloudPublicConfig } from "./publicConfig"; +import { isHostedStaticApp } from "../hostedPairing"; +import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./publicConfig"; const CONNECT_CLI_AUTH_STATE_STORAGE_KEY = "t3code-connect-cli-auth-state"; -function trimNonEmpty(value: string | undefined): string | null { - return value?.trim() || null; -} - export function resolveConnectCliOAuthClientId(): string | null { return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined); } @@ -25,10 +21,13 @@ export function hasConnectCliAuthConfig(): boolean { ); } -export function readConnectCliAuthorizeRequest( - url: URL = new URL(window.location.href), -): ConnectAuthorizeRequest | null { - return readConnectAuthorizeRequest(url); +/** + * Gate for the /connect routes: the CLI handshake only exists on the hosted + * deployment (the same bundle ships inside local instances) and needs the + * Clerk CLI OAuth client configured at build time. + */ +export function connectCliAuthRoutesEnabled(): boolean { + return isHostedStaticApp() && hasCloudPublicConfig() && hasConnectCliAuthConfig(); } /** @@ -48,7 +47,7 @@ export function buildConnectCliClerkAuthorizeUrl( return buildConnectClerkAuthorizeUrl({ authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`, clientId, - redirectUri: new URL(CONNECT_CALLBACK_PATH, currentOrigin).toString(), + redirectUri: connectCallbackUrl(currentOrigin), scopes: CONNECT_OAUTH_SCOPES, state: request.state, challenge: request.challenge, @@ -64,11 +63,15 @@ export function rememberConnectCliAuthState(state: string): void { } } -export function consumeConnectCliAuthState(): string | null { +/** + * Read-only on purpose: this runs during render, where a removal would be + * consumed by React's double-invoked/discarded renders (StrictMode) and + * silently disable the state check. The value is not a secret and is + * overwritten by the next /connect visit. + */ +export function readConnectCliAuthState(): string | null { try { - const state = window.sessionStorage.getItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY); - window.sessionStorage.removeItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY); - return state; + return window.sessionStorage.getItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY); } catch { return null; } diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts index d9d0e5f44cb..2caf4f52f36 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -24,7 +24,7 @@ export interface CloudPublicConfig { }; } -function trimNonEmpty(value: string | undefined): string | null { +export function trimNonEmpty(value: string | undefined): string | null { return value?.trim() || null; } diff --git a/apps/web/src/components/auth/AuthSurfaceShell.tsx b/apps/web/src/components/auth/AuthSurfaceShell.tsx new file mode 100644 index 00000000000..be6be68aae3 --- /dev/null +++ b/apps/web/src/components/auth/AuthSurfaceShell.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; + +import { APP_DISPLAY_NAME } from "../../branding"; + +/** + * Full-screen card used by the pairing and CLI-connect auth surfaces so the + * standalone auth pages share one visual treatment. + */ +export function AuthSurfaceShell({ children }: { readonly children: ReactNode }) { + return ( +
+
+
+
+
+
+ +
+

+ {APP_DISPLAY_NAME} +

+ {children} +
+
+ ); +} diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx index 04bffc82dbb..f1a8357ad6f 100644 --- a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -1,37 +1,17 @@ import { useAuth, useClerk, useUser } from "@clerk/react"; -import { encodeConnectAuthCode } from "@t3tools/shared/connectAuth"; +import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; import { useEffect, useMemo, useState } from "react"; -import { APP_DISPLAY_NAME } from "../../branding"; import { buildConnectCliClerkAuthorizeUrl, - consumeConnectCliAuthState, - readConnectCliAuthorizeRequest, + readConnectCliAuthState, readConnectCliCallbackResult, rememberConnectCliAuthState, } from "../../cloud/connectCliAuth"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { AuthSurfaceShell } from "../auth/AuthSurfaceShell"; import { Button } from "../ui/button"; -function ConnectCliAuthShell({ children }: { readonly children: React.ReactNode }) { - return ( -
-
-
-
-
-
- -
-

- {APP_DISPLAY_NAME} -

- {children} -
-
- ); -} - function ConnectCliAuthMessage({ title, description, @@ -58,7 +38,7 @@ const invalidLinkMessage = { * forwards the CLI's PKCE request to Clerk's authorize endpoint. */ export function ConnectCliAuthorizeSurface() { - const request = useMemo(() => readConnectCliAuthorizeRequest(), []); + const request = useMemo(() => readConnectAuthorizeRequest(new URL(window.location.href)), []); const clerk = useClerk(); const { isLoaded, isSignedIn } = useAuth(); const [redirecting, setRedirecting] = useState(false); @@ -82,14 +62,14 @@ export function ConnectCliAuthorizeSurface() { if (!request) { return ( - + - + ); } return ( - + - + {isLoaded && !isSignedIn ? ( +
+ +
+ ) : null} + ); } @@ -108,18 +98,18 @@ export function ConnectCliAuthorizeSurface() { */ export function ConnectCliCallbackSurface() { const result = useMemo(() => readConnectCliCallbackResult(), []); - const expectedState = useMemo(() => consumeConnectCliAuthState(), []); + const expectedState = useMemo(() => readConnectCliAuthState(), []); const { user } = useUser(); const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "authentication code" }); if (!result) { return ( - + - + ); } @@ -127,12 +117,12 @@ export function ConnectCliCallbackSurface() { // state parameter exists to stop; refuse to display a code for it. if (expectedState !== null && expectedState !== result.state) { return ( - + - + ); } @@ -140,7 +130,7 @@ export function ConnectCliCallbackSurface() { const authCode = encodeConnectAuthCode(result); return ( - + - + ); } diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts index 2caf844af9b..6a9815b4b33 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -1,6 +1,6 @@ -import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "./pairingUrl"; +import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; -const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; +import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "./pairingUrl"; export interface HostedPairingRequest { readonly host: string; diff --git a/apps/web/src/routes/connect.tsx b/apps/web/src/routes/connect.tsx index f6ff1813886..30750a977ea 100644 --- a/apps/web/src/routes/connect.tsx +++ b/apps/web/src/routes/connect.tsx @@ -1,15 +1,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -import { hasConnectCliAuthConfig } from "../cloud/connectCliAuth"; -import { hasCloudPublicConfig } from "../cloud/publicConfig"; +import { connectCliAuthRoutesEnabled } from "../cloud/connectCliAuth"; import { ConnectCliAuthorizeSurface } from "../components/cloud/ConnectCliAuthSurface"; -import { isHostedStaticApp } from "../hostedPairing"; - -// The web bundle also ships inside local/desktop instances; the CLI connect -// handshake only exists on the hosted app, so everything else bounces home. -export function connectCliAuthRoutesEnabled(): boolean { - return isHostedStaticApp() && hasCloudPublicConfig() && hasConnectCliAuthConfig(); -} export const Route = createFileRoute("/connect")({ beforeLoad: () => { diff --git a/apps/web/src/routes/connect_.callback.tsx b/apps/web/src/routes/connect_.callback.tsx index bb8b57a8a60..a5beee0b9d3 100644 --- a/apps/web/src/routes/connect_.callback.tsx +++ b/apps/web/src/routes/connect_.callback.tsx @@ -1,7 +1,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; +import { connectCliAuthRoutesEnabled } from "../cloud/connectCliAuth"; import { ConnectCliCallbackSurface } from "../components/cloud/ConnectCliAuthSurface"; -import { connectCliAuthRoutesEnabled } from "./connect"; export const Route = createFileRoute("/connect_/callback")({ beforeLoad: () => { diff --git a/packages/shared/src/connectAuth.ts b/packages/shared/src/connectAuth.ts index a8320e03136..950a69f6861 100644 --- a/packages/shared/src/connectAuth.ts +++ b/packages/shared/src/connectAuth.ts @@ -1,9 +1,18 @@ +import { readHashParams } from "./remote.ts"; + const CONNECT_AUTH_STATE_PARAM = "state"; const CONNECT_AUTH_CHALLENGE_PARAM = "challenge"; const CONNECT_AUTH_CODE_SEPARATOR = "."; -export const CONNECT_AUTHORIZE_PATH = "/connect"; -export const CONNECT_CALLBACK_PATH = "/connect/callback"; +const CONNECT_AUTHORIZE_PATH = "/connect"; +const CONNECT_CALLBACK_PATH = "/connect/callback"; + +/** + * The CLI prints URLs against this origin and the web bundle uses it to + * decide whether it is the hosted deployment — the two must agree, so the + * default lives here. + */ +export const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; /** * Requested at authorize time by the hosted page and honored by the CLI's @@ -11,9 +20,6 @@ export const CONNECT_CALLBACK_PATH = "/connect/callback"; */ export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email"] as const; -const readHashParams = (url: URL): URLSearchParams => - new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); - export interface ConnectAuthorizeRequest { readonly state: string; readonly challenge: string; @@ -86,6 +92,26 @@ export function encodeConnectAuthCode(input: ConnectAuthCode): string { return `${input.code}${CONNECT_AUTH_CODE_SEPARATOR}${input.state}`; } +/** + * Validates a pasted blob against the state of the request this process + * generated. Returns the parsed code or a user-facing error message; both + * the prompt's live validation and the authoritative post-prompt check go + * through here so they cannot drift. + */ +export function checkConnectAuthCode( + blob: string, + expectedState: string, +): ConnectAuthCode | string { + const parsed = parseConnectAuthCode(blob); + if (parsed === null) { + return "That does not look like a T3 Connect code. Copy the full code."; + } + if (parsed.state !== expectedState) { + return "That code belongs to a different connect request. Open the URL above and try again."; + } + return parsed; +} + export function parseConnectAuthCode(blob: string): ConnectAuthCode | null { const trimmed = blob.trim(); const separatorIndex = trimmed.lastIndexOf(CONNECT_AUTH_CODE_SEPARATOR); diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index 7347dbc74a1..f722a3945e5 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -5,7 +5,7 @@ const HOSTED_PAIRING_HOST_PARAM = "host"; const HOSTED_PAIRING_LABEL_PARAM = "label"; const SUPPORTED_REMOTE_BACKEND_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]); -const readHashParams = (url: URL): URLSearchParams => +export const readHashParams = (url: URL): URLSearchParams => new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass()( From 225ea657c66f744beb2a257834acbc3f7d42dd3d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 6 Jul 2026 13:37:20 -0700 Subject: [PATCH 5/6] Fix boot-service repair restart, %-escaping in log paths, partial installs Round-2 review fixes, each verified against a live systemd user manager: - `systemctl --user enable --now` does not replace an already running process, so repairing a stale unit swapped the file but kept the old server running until reboot. Split into enable + restart. - StandardOutput=append: paths go through systemd specifier expansion but must not be quoted; a `%` in the home path broke standard-output setup and killed the service. Escape % (only) in the append paths. - If an activation step failed after the unit file was written, the next `t3 connect` saw the file and reported the service as already set up even though enable/linger never ran. Remove the unit again when activation fails so the retry actually repairs. Co-Authored-By: Claude Fable 5 --- apps/server/src/cloud/bootService.test.ts | 37 +++++++++++++++- apps/server/src/cloud/bootService.ts | 51 ++++++++++++++++------- 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index d9c7b706b9a..e5bfed31525 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -109,11 +109,15 @@ it("quotes systemd values containing spaces and escapes percent specifiers", () nodePath: "/home/me/my tools/node", t3EntryPath: "/home/me/T3 Data/bin.mjs", baseDir: "/home/me/T3 Data", - logPath: "/home/me/logs/boot.log", + logPath: "/home/me/100%logs/boot.log", unitPath: "/home/me/.config/systemd/user/t3code.service", }); assert.include(unit, 'ExecStart="/home/me/my tools/node" "/home/me/T3 Data/bin.mjs" serve'); assert.include(unit, 'Environment=T3CODE_HOME="/home/me/T3 Data"'); + // append: paths take the rest of the line literally (spaces are fine, + // quoting is not), but % still goes through specifier expansion. + assert.include(unit, "StandardOutput=append:/home/me/100%%logs/boot.log"); + assert.include(unit, "StandardError=append:/home/me/100%%logs/boot.log"); }); it("flags package-manager cache entry points as ephemeral", () => { @@ -159,7 +163,10 @@ it.layer(NodeServices.layer)("BootService", (it) => { commands.map((entry) => [entry.command, ...entry.args].join(" ")), [ "systemctl --user daemon-reload", - "systemctl --user enable --now t3code.service", + "systemctl --user enable t3code.service", + // restart (not enable --now) so repairing a stale unit replaces a + // running process instead of leaving the old one until reboot. + "systemctl --user restart t3code.service", "loginctl enable-linger", ], ); @@ -289,6 +296,32 @@ it.layer(NodeServices.layer)("BootService", (it) => { }), ); + it.effect("removes the unit file when an activation step fails", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), + }).pipe( + Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "loginctl" })), + provideHostRefs(dirs.home), + ); + + const error = yield* service.install.pipe(Effect.flip); + assert.isTrue(isCommandError(error)); + // A leftover unit would make the next connect report "already set up" + // even though linger never happened. + assert.isFalse( + NodeFS.existsSync(NodePath.join(dirs.home, ".config", "systemd", "user", "t3code.service")), + ); + const status = yield* service.status; + assert.isFalse(status.installed); + }), + ); + it.effect("appends failed steps to the boot-service log", () => Effect.gen(function* () { const dirs = makeTestDirs(); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 516ed6c9857..a2302fe02e3 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -44,12 +44,21 @@ export function isEphemeralCacheEntry(entryPath: string): boolean { return EPHEMERAL_CACHE_SEGMENTS.some((segment) => entryPath.includes(segment)); } +/** + * systemd expands `%` specifiers in most directive values, including the + * `append:` file paths, which take the rest of the line literally and must + * NOT be quoted. + */ +export function escapeSystemdSpecifiers(value: string): string { + return value.replaceAll("%", "%%"); +} + /** * systemd word-splits ExecStart and Environment values and expands `%` * specifiers, so paths with spaces or percents must be quoted and escaped. */ export function quoteSystemdValue(value: string): string { - const escaped = value.replaceAll("%", "%%"); + const escaped = escapeSystemdSpecifiers(value); return /[\s"'\\]/.test(escaped) ? `"${escaped.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` : escaped; @@ -86,8 +95,8 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`, "Restart=always", "RestartSec=5", - `StandardOutput=append:${plan.logPath}`, - `StandardError=append:${plan.logPath}`, + `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`, + `StandardError=append:${escapeSystemdSpecifiers(plan.logPath)}`, "", "[Install]", "WantedBy=default.target", @@ -302,18 +311,30 @@ export const make = Effect.fnUntraced(function* (input: { Effect.mapError((cause) => new BootServiceInstallError({ cause })), ); - yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); - yield* runStep("enabling the service", "systemctl", [ - "--user", - "enable", - "--now", - BOOT_SERVICE_UNIT_FILE, - ]); - // Linger keeps the user manager (and this service) running without an - // open session — the whole point on a box reached over SSH. No username - // argument: loginctl defaults to the calling user, which is always - // right, while $USER can be stale (su without -l) or unset. - yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]); + // If any activation step fails, remove the unit again: a leftover file + // would make the next `t3 connect` report the service as already set up + // even though it was never enabled or lingered. + yield* Effect.gen(function* () { + yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); + yield* runStep("enabling the service", "systemctl", [ + "--user", + "enable", + BOOT_SERVICE_UNIT_FILE, + ]); + // restart rather than enable --now: --now does not replace an already + // running process, so repairing a stale unit would leave the old + // server running until reboot. restart also starts a stopped service. + yield* runStep("starting the service", "systemctl", [ + "--user", + "restart", + BOOT_SERVICE_UNIT_FILE, + ]); + // Linger keeps the user manager (and this service) running without an + // open session — the whole point on a box reached over SSH. No + // username argument: loginctl defaults to the calling user, which is + // always right, while $USER can be stale (su without -l) or unset. + yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]); + }).pipe(Effect.tapError(() => fs.remove(unitPath).pipe(Effect.ignore))); return plan; }).pipe(Effect.withSpan("cloud.boot_service.install")); From 7e75fa2fc974952977f09c42a3feea38b514dbf4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 6 Jul 2026 14:27:23 -0700 Subject: [PATCH 6/6] Address final review pass: identity display, fail-closed callback, boot-service edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from three independent Fable review agents (general / simplify / over-engineering) on the whole branch. Security: - The paste flow links whichever account completed the browser auth, and the callback page previously failed open (no recorded state) and labeled the code with the local session's account. An attacker who saw a victim's printed URL could get the victim to link their machine to the attacker's account. Two mitigations: the callback now fails closed (a missing or mismatched state is refused, not displayed), and the CLI prints the account it actually linked ("Connected as theo@…") by reading the OIDC id_token, so account substitution is visible before the machine comes online — matching the original plan's mockup. Correctness/UX: - Loopback `t3 connect` now falls through to a fresh login when the stored credential can't be refreshed, instead of dead-ending — the desktop counterpart of the paste-side fallback. - Ctrl-C / EOF at the paste prompt propagates as QuitError (quiet cancel) instead of being wrapped into an authorization-error dump. Boot service: - `current` now also requires the unit's entry point to exist, so a deleted pinned runtime triggers a repair instead of "already set up". - Failed activation rolls back with disable + remove + daemon-reload (was remove only), so no dangling enable symlink or stale definition survives. - Add StartLimitIntervalSec/Burst so a persistently broken service stops instead of restarting every 5s forever and growing the append log. Co-Authored-By: Claude Fable 5 --- apps/server/src/cli/connect.ts | 39 +++++++++----- apps/server/src/cloud/CliTokenManager.test.ts | 13 ++++- apps/server/src/cloud/CliTokenManager.ts | 52 ++++++++++++++++--- apps/server/src/cloud/bootService.test.ts | 41 ++++++++++++--- apps/server/src/cloud/bootService.ts | 33 ++++++++++-- .../src/components/auth/AuthSurfaceShell.tsx | 4 +- .../cloud/ConnectCliAuthSurface.tsx | 10 ++-- 7 files changed, 154 insertions(+), 38 deletions(-) diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 757d617235f..062641b431a 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -7,6 +7,7 @@ import { import { RelayOkResponse } from "@t3tools/contracts/relay"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as RelayClient from "@t3tools/shared/relayClient"; +import * as Terminal from "effect/Terminal"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; @@ -72,6 +73,7 @@ const promptForPastedCode = ({ authorizeUrl, validate }: CliTokenManager.PasteCo ), ); +/** Returns the connected account identity, if the flow could determine one. */ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: { readonly paste: boolean; }) { @@ -79,7 +81,7 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: { const paste = options.paste || (yield* detectHeadlessSession); if (!paste) { yield* tokens.get; - return; + return null; } // A stored credential whose refresh fails (revoked, expired grant) must // fall through to a fresh paste login, not dead-end the command. @@ -91,16 +93,19 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: { ), ); if (Option.isSome(existing)) { - return; + return null; } - const token = yield* CliTokenManager.pasteCodeLogin(promptForPastedCode).pipe( + const { token, identity } = yield* CliTokenManager.pasteCodeLogin(promptForPastedCode).pipe( Effect.mapError((cause) => - isCloudCliTokenManagerError(cause) + // Ctrl-C / EOF at the prompt is a QuitError; let it propagate so the CLI + // cancels quietly instead of dumping an authorization error. + Terminal.isQuitError(cause) || isCloudCliTokenManagerError(cause) ? cause : new CliTokenManager.CloudCliAuthorizationError({ cause }), ), ); yield* tokens.store(token); + return identity; }); function bytesToString(value: Uint8Array): string { @@ -433,6 +438,8 @@ const runCloudCommand = ( return yield* run.pipe(Effect.provide(runtimeLayer)); }); +const connectedAs = (identity: string | null): string => (identity ? ` as ${identity}` : ""); + const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(function* (options: { readonly paste: boolean; }) { @@ -444,15 +451,15 @@ const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(functi ); if (Option.isNone(installed)) { yield* Console.log("T3 Connect setup cancelled. The relay client was not installed."); - return false; + return null; } yield* Console.log( `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`, ); - yield* authorizeCli(options); + const identity = yield* authorizeCli(options); yield* CliState.setCliDesiredCloudLink(true); - return true; + return { identity } as const; }); const connectLoginCommand = Command.make("login", { @@ -464,8 +471,8 @@ const connectLoginCommand = Command.make("login", { runCloudCommand( flags, Effect.gen(function* () { - yield* authorizeCli(flags); - yield* Console.log("Signed in to T3 Connect."); + const identity = yield* authorizeCli(flags); + yield* Console.log(`Signed in to T3 Connect${connectedAs(identity)}.`); }), ), ), @@ -480,9 +487,11 @@ const connectLinkCommand = Command.make("link", { runCloudCommand( flags, Effect.gen(function* () { - if (yield* linkEnvironmentForConnect(flags)) { + const linked = yield* linkEnvironmentForConnect(flags); + if (linked) { yield* Console.log( - "This T3 environment will be available through T3 Connect the next time T3 starts.", + `Authorized T3 Connect${connectedAs(linked.identity)}. This environment will be ` + + "available the next time T3 starts.", ); } }), @@ -585,10 +594,14 @@ export const connectCommand = Command.make("connect", { runCloudCommand( flags, Effect.gen(function* () { - if (!(yield* linkEnvironmentForConnect(flags))) { + const linked = yield* linkEnvironmentForConnect(flags); + if (!linked) { return; } - yield* Console.log("\nConnected!"); + // Show which account was linked so an unexpected identity (a pasted + // code that authorized a different account) is visible before the + // machine is brought online. + yield* Console.log(`\nConnected${connectedAs(linked.identity)}!`); // Connect itself already succeeded; a boot-service failure must not // fail the command, just tell the user what happened and move on. diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index 8039d2f5e6a..16d073406db 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -23,6 +23,14 @@ interface RecordedTokenRequest { readonly params: URLSearchParams; } +// A JWT whose payload claims { email: "theo@example.test" } (signature is not +// verified — the CLI only reads the claim to display the connected account). +const idTokenWithEmail = (() => { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ email: "theo@example.test" })).toString("base64url"); + return `${header}.${payload}.`; +})(); + const makeTokenEndpointLayer = (requests: Array) => Layer.succeed( HttpClient.HttpClient, @@ -38,6 +46,7 @@ const makeTokenEndpointLayer = (requests: Array) => JSON.stringify({ access_token: "access-token-1", refresh_token: "refresh-token-1", + id_token: idTokenWithEmail, expires_in: 3600, token_type: "bearer", }), @@ -65,7 +74,7 @@ it.layer(NodeServices.layer)("CliTokenManager.pasteCodeLogin", (it) => { const requests: Array = []; let seenAuthorizeUrl = ""; - const token = yield* CliTokenManager.pasteCodeLogin( + const { token, identity } = yield* CliTokenManager.pasteCodeLogin( ({ authorizeUrl, validate }: PasteCodePromptInput) => Effect.gen(function* () { seenAuthorizeUrl = authorizeUrl; @@ -85,6 +94,8 @@ it.layer(NodeServices.layer)("CliTokenManager.pasteCodeLogin", (it) => { assert.equal(token.accessToken, "access-token-1"); assert.equal(token.refreshToken, "refresh-token-1"); + // The id_token's email claim is surfaced so connect can show the account. + assert.equal(identity, "theo@example.test"); assert.lengthOf(requests, 1); const exchange = requests[0]!; diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 1243915e60f..b833f1571ed 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -53,10 +53,37 @@ const encodePersistedToken = Schema.encodeEffect(PersistedTokenJson); const OAuthTokenResponse = Schema.Struct({ access_token: Schema.String, refresh_token: Schema.optional(Schema.String), + id_token: Schema.optional(Schema.String), expires_in: Schema.Number, token_type: Schema.String, }); +/** + * Best-effort read of the `email` (or fallback) claim from an OIDC id_token. + * Only used to show the operator which account they linked, so a malformed + * token degrades to "no identity" rather than an error. + */ +function idTokenIdentity(idToken: string | undefined): string | null { + if (!idToken) return null; + const payload = idToken.split(".")[1]; + if (!payload) return null; + const decoded = Encoding.decodeBase64UrlString(payload); + if (decoded._tag !== "Success") return null; + try { + const claims = JSON.parse(decoded.success) as { + readonly email?: unknown; + readonly preferred_username?: unknown; + readonly sub?: unknown; + }; + for (const value of [claims.email, claims.preferred_username, claims.sub]) { + if (typeof value === "string" && value.length > 0) return value; + } + return null; + } catch { + return null; + } +} + export class CloudCliCredentialRemovalError extends Schema.TaggedErrorClass()( "CloudCliCredentialRemovalError", { cause: Schema.Defect() }, @@ -147,10 +174,13 @@ const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( ); const now = yield* Clock.currentTimeMillis; return { - accessToken: response.access_token, - refreshToken: response.refresh_token ?? params.refresh_token ?? "", - expiresAtEpochMs: now + response.expires_in * 1_000, - } satisfies PersistedToken; + token: { + accessToken: response.access_token, + refreshToken: response.refresh_token ?? params.refresh_token ?? "", + expiresAtEpochMs: now + response.expires_in * 1_000, + } satisfies PersistedToken, + identity: idTokenIdentity(response.id_token), + }; }); const makePkceRequest = Effect.gen(function* () { @@ -241,11 +271,12 @@ export const make = Effect.gen(function* () { const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) { const metadata = yield* cloudCliOAuthConfig; - return yield* exchangeToken(metadata, { + const { token: refreshed } = yield* exchangeToken(metadata, { grant_type: "refresh_token", refresh_token: token.refreshToken, client_id: metadata.clientId, }); + return refreshed; }); const login = Effect.fn("cloud.cli_token.login")(function* () { @@ -307,13 +338,14 @@ export const make = Effect.gen(function* () { ), ), ); - return yield* exchangeToken(metadata, { + const { token } = yield* exchangeToken(metadata, { grant_type: "authorization_code", code, redirect_uri: metadata.redirectUri, client_id: metadata.clientId, code_verifier: verifier, }); + return token; }); const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () { @@ -340,7 +372,13 @@ export const make = Effect.gen(function* () { ); const get = semaphore.withPermits(1)( Effect.gen(function* () { - const token = yield* getExistingNoLock(); + // A stored credential that can't be read or refreshed (corrupt, revoked, + // expired grant) must fall through to a fresh login rather than dead-end + // the command — this is the loopback counterpart of authorizeCli's + // paste-side fallback. + const token = yield* getExistingNoLock().pipe( + Effect.orElseSucceed(() => Option.none()), + ); return Option.isSome(token) ? token.value : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist)); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index e5bfed31525..30e24e567f2 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -61,10 +61,15 @@ const provideHostRefs = (home: string, platform: NodeJS.Platform = "linux") => const makeTestDirs = () => { const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-boot-service-test-")); + // A real file for the stable-entry cases so status can confirm the entry + // point exists. + const stableEntry = NodePath.join(root, "bin.mjs"); + NodeFS.writeFileSync(stableEntry, "#!/usr/bin/env node\n"); return { home: root, baseDir: NodePath.join(root, ".t3"), logsDir: NodePath.join(root, ".t3", "userdata", "logs"), + stableEntry, }; }; @@ -82,6 +87,8 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => { [ "[Unit]", "Description=T3 Code server (T3 Connect)", + "StartLimitIntervalSec=300", + "StartLimitBurst=5", "", "[Service]", "Type=simple", @@ -152,13 +159,13 @@ it.layer(NodeServices.layer)("BootService", (it) => { baseDir: dirs.baseDir, logsDir: dirs.logsDir, cliVersion: "0.0.27", - host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), + host: makeHost(dirs.stableEntry), }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); const plan = yield* service.install; // A stable entry point is reused directly — no npm install. - assert.equal(plan.t3EntryPath, "/usr/local/lib/node_modules/t3/dist/bin.mjs"); + assert.equal(plan.t3EntryPath, dirs.stableEntry); assert.deepEqual( commands.map((entry) => [entry.command, ...entry.args].join(" ")), [ @@ -173,10 +180,7 @@ it.layer(NodeServices.layer)("BootService", (it) => { const unitPath = NodePath.join(dirs.home, ".config", "systemd", "user", "t3code.service"); const unit = NodeFS.readFileSync(unitPath, "utf8"); - assert.include( - unit, - "ExecStart=/usr/local/bin/node /usr/local/lib/node_modules/t3/dist/bin.mjs serve", - ); + assert.include(unit, `ExecStart=/usr/local/bin/node ${dirs.stableEntry} serve`); assert.include(unit, `Environment=T3CODE_HOME=${dirs.baseDir}`); const status = yield* service.status; @@ -252,7 +256,7 @@ it.layer(NodeServices.layer)("BootService", (it) => { baseDir: dirs.baseDir, logsDir: dirs.logsDir, cliVersion: "0.0.27", - host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), + host: makeHost(dirs.stableEntry), }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); const unitDir = NodePath.join(dirs.home, ".config", "systemd", "user"); @@ -269,6 +273,29 @@ it.layer(NodeServices.layer)("BootService", (it) => { }), ); + it.effect("reports a current unit as stale when its entry point is gone", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost(dirs.stableEntry), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + yield* service.install; + assert.isTrue((yield* service.status).current); + + // The pinned runtime (or global bin) was deleted to reclaim space; the + // unit still matches byte-for-byte but would crashloop at boot. + NodeFS.rmSync(dirs.stableEntry); + const status = yield* service.status; + assert.isTrue(status.installed); + assert.isFalse(status.current); + }), + ); + it.effect("fails on non-Linux platforms without touching the filesystem", () => Effect.gen(function* () { const dirs = makeTestDirs(); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index a2302fe02e3..272f6361356 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -87,6 +87,11 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { return [ "[Unit]", "Description=T3 Code server (T3 Connect)", + // Give up after 5 crashes in 5 minutes so a persistently broken install + // (deleted runtime, broken workspace) stops instead of restarting every + // 5s forever and growing the unrotated append log without bound. + "StartLimitIntervalSec=300", + "StartLimitBurst=5", "", "[Service]", "Type=simple", @@ -334,11 +339,28 @@ export const make = Effect.fnUntraced(function* (input: { // username argument: loginctl defaults to the calling user, which is // always right, while $USER can be stale (su without -l) or unset. yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]); - }).pipe(Effect.tapError(() => fs.remove(unitPath).pipe(Effect.ignore))); + }).pipe(Effect.tapError(() => rollbackFailedInstall)); return plan; }).pipe(Effect.withSpan("cloud.boot_service.install")); + // If activation fails partway (e.g. enable succeeds but restart/linger + // fails), leave nothing behind: disable removes the enable symlink, remove + // deletes the file, daemon-reload clears the stale definition — otherwise a + // dangling wants/ symlink logs "Failed to load unit" at every boot and the + // next connect misreports the state. + const rollbackFailedInstall = Effect.gen(function* () { + yield* runStep("cleaning up the service", "systemctl", [ + "--user", + "disable", + BOOT_SERVICE_UNIT_FILE, + ]).pipe(Effect.ignore); + yield* fs.remove(unitPath).pipe(Effect.ignore); + yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]).pipe( + Effect.ignore, + ); + }); + const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { yield* requireSystemdLinux; const exists = yield* fs @@ -371,9 +393,12 @@ export const make = Effect.fnUntraced(function* (input: { if (unit === null) { return { supported: true, installed: false, current: false, unitPath, logPath }; } - // A unit written by an older CLI (different pinned runtime, different - // node) counts as installed but stale, so connect offers a repair. - const current = unit === renderBootServiceUnit(plan); + // A unit is current only if it matches what install would write now (an + // older CLI wrote a different runtime/node path) AND the entry point it + // references still exists (a pinned runtime under ~/.t3 can be deleted to + // reclaim space). Either mismatch makes connect offer a repair. + const entryExists = yield* fs.exists(plannedEntryPath).pipe(Effect.orElseSucceed(() => false)); + const current = unit === renderBootServiceUnit(plan) && entryExists; return { supported: true, installed: true, current, unitPath, logPath }; }).pipe(Effect.withSpan("cloud.boot_service.status")); diff --git a/apps/web/src/components/auth/AuthSurfaceShell.tsx b/apps/web/src/components/auth/AuthSurfaceShell.tsx index be6be68aae3..1545a2578dd 100644 --- a/apps/web/src/components/auth/AuthSurfaceShell.tsx +++ b/apps/web/src/components/auth/AuthSurfaceShell.tsx @@ -3,8 +3,8 @@ import type { ReactNode } from "react"; import { APP_DISPLAY_NAME } from "../../branding"; /** - * Full-screen card used by the pairing and CLI-connect auth surfaces so the - * standalone auth pages share one visual treatment. + * Full-screen card for standalone auth pages, mirroring the pairing surface's + * treatment. Used by the CLI-connect authorize and callback surfaces. */ export function AuthSurfaceShell({ children }: { readonly children: ReactNode }) { return ( diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx index f1a8357ad6f..bba8a56e4c1 100644 --- a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -113,14 +113,16 @@ export function ConnectCliCallbackSurface() { ); } - // A response for a request this browser did not start is the CSRF shape the - // state parameter exists to stop; refuse to display a code for it. - if (expectedState !== null && expectedState !== result.state) { + // Fail closed: the legitimate callback always lands in the same browser + // that visited /connect (which recorded the state), so a missing or + // mismatched state means this page was reached some other way — the CSRF + // shape the state parameter exists to stop. Refuse to display a code. + if (expectedState === null || expectedState !== result.state) { return ( );