From 4bc7f56330022367739f567458db0137cec50be4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 04:00:49 -0700 Subject: [PATCH 1/3] feat: add reusable loopback dev auth --- .agents/skills/test-t3-app/SKILL.md | 23 +- .agents/skills/test-t3-mobile/SKILL.md | 49 ++- .plans/21-agent-dev-auth.html | 370 ++++++++++++++++++ .../connection/ConnectionsNewRouteScreen.tsx | 55 ++- .../src/auth/EnvironmentAuthPolicy.test.ts | 18 + apps/server/src/auth/EnvironmentAuthPolicy.ts | 3 +- .../server/src/auth/PairingGrantStore.test.ts | 40 +- apps/server/src/auth/PairingGrantStore.ts | 15 +- apps/server/src/auth/SessionStore.ts | 1 + apps/server/src/auth/utils.ts | 3 +- apps/server/src/bin.test.ts | 1 + apps/server/src/cli/config.test.ts | 92 +++++ apps/server/src/cli/config.ts | 31 ++ apps/server/src/config.ts | 2 + .../src/environment/ServerEnvironment.test.ts | 1 + apps/server/src/server.test.ts | 1 + apps/server/src/startupAccess.ts | 21 +- packages/shared/package.json | 4 + packages/shared/src/networkHost.ts | 19 + scripts/dev-runner.test.ts | 44 +++ scripts/dev-runner.ts | 77 ++++ 21 files changed, 801 insertions(+), 69 deletions(-) create mode 100644 .plans/21-agent-dev-auth.html create mode 100644 packages/shared/src/networkHost.ts diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index c3dc1c103d4..be31f219a05 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -1,6 +1,6 @@ --- name: test-t3-app -description: Launch and test the T3 Code web app in isolated development environments, including first-try browser authentication with one-time pairing URLs, pairing-token recovery, worktree-safe state directories, dev server lifecycle, and direct SQLite inspection or fixture seeding. Use when an agent needs to run T3 locally, test UI behavior in a browser, recover from an expired or consumed pairing token, isolate dev state, or prepare test data in state.sqlite. +description: Launch and test the T3 Code web app in isolated development environments, including reusable worktree-scoped dev authentication, real pairing-token recovery, worktree-safe state directories, dev server lifecycle, and direct SQLite inspection or fixture seeding. Use when an agent needs to run T3 locally, test UI behavior in a browser, exercise real pairing behavior, isolate dev state, or prepare test data in state.sqlite. --- # Test T3 App @@ -14,25 +14,25 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi - Use the repository's ignored `.t3` directory for reusable worktree-local state. - Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path. 3. Start the full web stack with `vp run dev --home-dir `. -4. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output. +4. Keep the terminal session alive and read the selected server port, web port, base directory, and explicitly labeled `reusable web pairing URL` from the dev-runner output. Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership. -The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it. +The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing so the controlled browser remains the only client surface. -## Authenticate the browser on the first navigation +## Authenticate the browser -1. Wait for the server log that says authentication is required and includes a URL ending in `/pair#token=...`. +1. Wait for the dev runner's explicitly labeled `reusable web pairing URL`. The server also prints a real one-time pairing URL during startup; do not confuse the two. 2. Use the controlled in-app browser or browser-automation surface available to the agent. Do not use a system-browser launch command during automated testing. -3. Open that complete URL exactly once as the controlled browser's first navigation. Preserve the fragment and token verbatim. +3. Open the complete reusable URL as the controlled browser's first navigation. Preserve the fragment and token verbatim. 4. Wait for the pairing exchange and redirect to finish before navigating elsewhere. 5. Continue in the same browser context so its stored bearer session remains available. -Treat pairing URLs as secrets. Do not copy them into final responses, screenshots, committed files, or durable logs. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it. +The reusable development credential is deterministically derived from the canonical worktree path and is intentionally limited to loopback servers. It may be opened repeatedly by browsers testing the same running worktree. Do not mistake it for a production secret or configure it on a remotely reachable server. -## Recover a consumed or expired pairing token +## Exercise real pairing or recover a one-time token -Create another token against the same database and web URL as the running dev server: +When the behavior under test is pairing itself, create a real one-time token against the same database and web URL as the running dev server: ```bash T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ @@ -47,6 +47,8 @@ Use the `Pair URL` from this command once. Derive `` and ` Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `/userdata`; the `/dev` fallback is only used by an implicit dev home. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets. +Real pairing URLs remain secret, short-lived, and single-use. Do not copy them into final responses, screenshots, committed files, or durable logs. + ## Inspect or seed SQLite state Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) before changing the database. @@ -64,7 +66,8 @@ Stop the dev process with its terminal interrupt. Preserve the isolated base dir ## Troubleshoot predictably -- If the browser shows an unauthenticated pairing screen, issue a new token instead of retrying the consumed URL. +- If the reusable dev URL is rejected, confirm the runner and server use the same worktree and that the backend is loopback-only. +- If a real one-time URL was consumed, issue a new token instead of retrying it. - If the pairing URL is no longer visible, create a replacement token with both `--dev-url` and `--base-url`. - If the replacement token is rejected, verify that the CLI and server use the identical absolute base directory and web URL. - If the UI shows unexpected data, verify that every command uses the identical explicit base directory before editing anything. diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index f3e3dcfd8ce..61a8b015558 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -50,11 +50,21 @@ Running `project add` before the backend starts gives it exclusive offline datab Use direct SQLite mutation only for disposable projection fixtures. Follow `test-t3-app` and stop the backend before writing. -Start a headless backend after seeding: +For an iOS Simulator or Android Emulator, start the backend through the worktree-aware dev runner after seeding: ```bash -node apps/server/src/bin.ts serve \ +vp run dev:server \ --host 127.0.0.1 \ + --home-dir +``` + +Keep the process alive and retain the selected server port plus the explicitly labeled reusable iOS and Android pairing URLs. They contain the worktree-scoped development credential and can be retried against this loopback server. + +For a physical device, use the real-pairing path because the reusable development credential is refused on non-loopback hosts: + +```bash +node apps/server/src/bin.ts serve \ + --host 0.0.0.0 \ --port \ --base-dir \ --no-browser @@ -64,7 +74,7 @@ Use these client origins: - iOS Simulator: `http://127.0.0.1:` - Android Emulator: `http://10.0.2.2:` -- Physical device: bind the backend to `0.0.0.0` and use the host's reachable LAN origin +- Physical device: use the host's reachable LAN origin Always enter the complete `http://` origin; the mobile host field otherwise assumes HTTPS. When testing web and mobile together, run `vp run dev --home-dir --host 127.0.0.1` instead and do not launch a second backend over the same base directory. @@ -123,7 +133,21 @@ adb -s shell am start -W \ Do not start, stop, erase, or reconfigure an emulator owned by another task. Track and later stop only processes owned by this test. -## Pair each client once +## Connect a simulator or emulator + +After the development client is running the correct Metro bundle, open the matching reusable URL printed by `vp run dev:server`: + +```bash +xcrun simctl openurl '' +adb -s shell am start -W \ + -a android.intent.action.VIEW \ + -d '' \ + com.t3tools.t3code.dev +``` + +Run only the command for the selected platform. The `connections/new` route automatically sends the embedded credential through the normal onboarding exchange; verify that it closes and the expected seeded projects appear. The reusable development URL may be reopened after resetting the client connection. + +## Pair a physical device or test real pairing Issue a fresh credential against the running backend's exact base directory: @@ -137,21 +161,9 @@ T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ In PowerShell, set `$env:T3CODE_PORT = ""` first and run the `node ... auth pairing create` command without the leading assignment. -If the visible Add Environment action is not exposed as a semantic target, open the app's registered route instead of guessing coordinates: - -```bash -xcrun simctl openurl 't3code-dev://connections/new' -adb -s shell am start -W \ - -a android.intent.action.VIEW \ - -d 't3code-dev://connections/new' \ - com.t3tools.t3code.dev -``` - -Run only the command for the selected platform. - In T3 Code Dev, open Add Environment and enter the complete `` and newly printed `Token`. Verify the expected seeded projects appear before exercising the affected flow. -Pairing credentials are secret, short-lived, and single-use. Create a different credential for every simulator, emulator, physical device, or browser. If an attempt fails, issue a new credential rather than retrying the old one. Do not expose tokens in screenshots, commits, or final responses. +Real pairing credentials are secret, short-lived, and single-use. Create a different credential for every physical device or real-pairing test client. If an attempt fails, issue a new credential rather than retrying the old one. Do not expose tokens in screenshots, commits, or final responses. ## Drive and observe the affected flow @@ -182,7 +194,8 @@ Keep local verification focused. Do not turn this workflow into a full repositor - **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app. - **The environment remains empty:** verify the platform-specific HTTP origin, use a fresh token, and confirm project seeding used the identical base directory. -- **A second client cannot pair:** pairing tokens are single-use; issue another token. +- **A simulator or emulator cannot reconnect:** reopen the platform-specific reusable URL and confirm the backend is still loopback-only. +- **A physical or real-pairing client cannot pair:** pairing tokens are single-use; issue another token. - **iOS semantic actions fail:** set explicit XcodeBuildMCP defaults and refresh with `snapshot_ui`. - **Android cannot reach Metro:** verify `adb reverse` for the exact Metro port and relaunch the development-client URL. - **Android cannot reach the backend:** use `10.0.2.2`, not `127.0.0.1`, for the Android Emulator. diff --git a/.plans/21-agent-dev-auth.html b/.plans/21-agent-dev-auth.html new file mode 100644 index 00000000000..dd8b44ec662 --- /dev/null +++ b/.plans/21-agent-dev-auth.html @@ -0,0 +1,370 @@ + + + + + + T3 Code — Agent-Friendly Development Authentication + + + +
+
+
Implementation plan · T3 Code
+

Development auth that agents never have to think about.

+

+ Derive a reusable development key from the canonical worktree path, print it in ordinary + pairing URLs, and let the existing browser and mobile pairing flows do the rest. +

+
+ Scope: web, iOS Simulator, Android Emulator + Boundary: explicit, loopback-only development mode + Steady state: existing sessions and WebSocket tickets +
+
+ Core decision: the path-derived value is a development instance key, not a secret. + It may appear in development pairing URLs. Its value is unlimited reuse, not secrecy. +
+
+ +
+
+
Target experience
+

Keep the commands agents already use

+

The runner prepares the server and prints reusable web, iOS, and Android connection URLs; the testing skills continue to own browsers, Metro, simulators, and emulators.

+
+
+
Web stack
vp run dev --home-dir <base-dir>
+
Mobile backend
vp run dev:server --home-dir <base-dir> --host 127.0.0.1
+
Mobile client
Reuse/start Metro and drive the selected device with test-t3-mobile
+
Physical device
Use existing real pairing; the development key is never accepted on a LAN bind.
+
+
+ +
+
+
Architecture
+

A bootstrap, not a bypass

+

The only new server behavior is a reusable, loopback-only grant. Existing pairing screens and token exchanges remain the client implementation.

+
+
+ + Development authentication flow + The dev runner derives a key, seeds it on the server, prints an ordinary reusable pairing URL, and the existing client pairing flow exchanges it for a normal session. + + + + + + + DEV RUNNER + Canonical Git root + realpath → SHA-256 + print reusable pair URLs + + + SERVER + Reusable dev grant + administrative bootstrap scopes + + + CLIENT + Existing pair flow + browser or mobile deep link + + + STEADY STATE + Normal session + cookie / bearer / DPoP + existing WS tickets + + + + + + accept key + issue session + +
+
+ +
+
+
Implementation
+

Four small changes

+
+
+
+

Derive and print one stable development key

+

Resolve the canonical Git worktree root and compute sha256("t3-dev:" + realpath(root)). The runner exports T3CODE_DEV_AUTH=1 and T3CODE_DEV_AUTH_KEY to its server child, then prints a reusable web pairing URL plus separate iOS and Android connection deep links containing the same key.

+
+
+

Seed a reusable server bootstrap grant

+

Read the two environment variables in server configuration. When explicitly enabled on a loopback bind, seed the key with administrative bootstrap scopes and unlimited exchanges for the process lifetime. Refuse dev auth for wildcard or non-loopback hosts.

+
+
+

Use existing clients with one tiny mobile adapter

+

Web requires no changes: opening /pair#token=… already creates its cookie session. Mobile accepts the printed host and credential on the existing connections/new route and forwards them to current connection onboarding.

+
+
+

Simplify the testing skills

+

The web skill opens the reusable URL exactly as it opens today’s one-time URL. The mobile skill keeps responsibility for Metro reuse, device selection, and platform-specific origins, then opens the printed deep link. Real one-time pairing remains the physical-device and auth-test path.

+
+
+
+ +
+
+
Likely touchpoints
+

Keep the change narrow

+
+
+
scripts/dev-runner.tsDerive/export the key and print reusable web/mobile connection information. Do not orchestrate devices.
+
apps/server/src/cli/config.tsRead the explicit dev-auth configuration.
+
apps/server/src/config.tsCarry the dev-auth key and enabled state.
+
apps/server/src/auth/PairingGrantStore.tsSeed the reusable grant using the existing desktop pattern and enforce loopback-only use.
+
apps/server/src/auth/utils.tsNamespace development cookies by server port.
+
apps/mobile/src/Stack.tsxAccept host and credential parameters on the existing connections route.
+
apps/mobile/src/connection/onboarding.tsFeed the development deep link into existing pairing onboarding.
+
.agents/skills/test-t3-app/SKILL.mdOpen the reusable dev pairing URL; keep one-time recovery for real-auth tests.
+
.agents/skills/test-t3-mobile/SKILL.mdUse dev:server output for simulators/emulators; keep real pairing for physical devices.
+
+
+ +
+
+
Guardrails
+

Deliberately unsafe, tightly contained

+
+
+
+

Explicit activation

+

The server accepts the key only when T3CODE_DEV_AUTH=1. Production defaults never infer it from the filesystem.

+
+
+

Loopback only

+

The server refuses this guessable key on wildcard, LAN, Tailnet, or other non-loopback binds. Physical-device verification uses real pairing.

+
+
+

Real auth coexists

+

Existing startup pairing output and auth pairing create remain available. No special --auth real mode is needed.

+
+
+
+ The key is intentionally public. It may appear in terminal output and development URLs. Never substitute T3_MCP_BEARER_TOKEN, which authorizes agent MCP access. +
+
+ +
+
+
Verification
+

Acceptance checklist

+
+
+
+

Focused automated coverage

+
    +
  • Same canonical worktree produces the same key.
  • +
  • Different worktrees produce different keys.
  • +
  • The dev grant supports repeated exchange.
  • +
  • A mismatched or absent key is rejected.
  • +
  • Non-loopback dev auth is rejected.
  • +
  • Production behavior is unchanged.
  • +
  • Parallel dev cookies do not collide.
  • +
  • Mobile deep-link registration is idempotent.
  • +
+
+
+

Integrated proof

+
    +
  • Run one isolated web environment and open its printed reusable pairing URL.
  • +
  • Open that URL again in a fresh browser context and confirm both exchanges succeed.
  • +
  • Run one representative iOS Simulator or Android Emulator environment.
  • +
  • Start/reuse Metro with the mobile skill and open the printed connection deep link.
  • +
  • Confirm seeded projects load from the intended backend.
  • +
  • Confirm a LAN-bound backend falls back to real one-time pairing.
  • +
  • Stop all owned processes and clean disposable state.
  • +
+
+
+
+ +
+
+
Non-goals
+

What this plan avoids

+
+
+
    +
  • No custom authorization header on every request.
  • +
  • No alternate WebSocket authentication protocol.
  • +
  • No persisted development secret or token recovery workflow.
  • +
  • No Vite, web-auth bootstrap, or mobile bundle configuration changes.
  • +
  • No new runner flag for device selection or lifecycle.
  • +
  • No changes to desktop, whose reusable bootstrap already works.
  • +
  • No weakening of production pairing, scopes, sessions, or DPoP.
  • +
  • No automatic native rebuild when a compatible dev client already exists.
  • +
+
+
+ +
+ Proposed implementation order: runner key and URLs → loopback-only reusable server grant → mobile route adapter → skill updates → focused integrated verification. +
+
+ + diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index de3799ac8a8..22440589d6b 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -2,7 +2,7 @@ import { CameraView, useCameraPermissions } from "expo-camera"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -17,6 +17,7 @@ import { buildPairingUrl, parsePairingUrl } from "./pairing"; type ConnectionsNewRouteParams = { readonly mode?: string; + readonly pairingUrl?: string | readonly string[]; }; export function ConnectionsNewRouteScreen({ @@ -37,6 +38,7 @@ export function ConnectionsNewRouteScreen({ const [showScanner, setShowScanner] = useState(params.mode === "scan_qr"); const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const [scannerLocked, setScannerLocked] = useState(false); + const lastAutomaticPairingUrl = useRef(null); const headerIconColor = useThemeColor("--color-icon"); @@ -62,6 +64,40 @@ export function ConnectionsNewRouteScreen({ setCodeInput(value); }, []); + const connectPairingUrl = useCallback( + async (pairingUrl: string) => { + const { host, code } = parsePairingUrl(pairingUrl); + setHostInput(host); + setCodeInput(code); + setIsSubmitting(true); + onChangeConnectionPairingUrl(pairingUrl); + + const result = await onConnectPress(pairingUrl); + if (AsyncResult.isSuccess(result)) { + if (navigation.canGoBack()) { + navigation.goBack(); + } else { + navigation.dispatch(StackActions.replace("Home")); + } + } else { + setIsSubmitting(false); + } + }, + [navigation, onChangeConnectionPairingUrl, onConnectPress], + ); + + useEffect(() => { + const routePairingUrl = Array.isArray(params.pairingUrl) + ? params.pairingUrl[0] + : params.pairingUrl; + if (!routePairingUrl?.trim() || lastAutomaticPairingUrl.current === routePairingUrl) { + return; + } + + lastAutomaticPairingUrl.current = routePairingUrl; + void connectPairingUrl(routePairingUrl); + }, [connectPairingUrl, params.pairingUrl]); + const openScanner = useCallback(async () => { if (cameraPermission?.granted) { setScannerLocked(false); @@ -117,21 +153,8 @@ export function ConnectionsNewRouteScreen({ ); const handleSubmit = useCallback(async () => { - setIsSubmitting(true); - - const pairingUrl = buildPairingUrl(hostInput, codeInput); - onChangeConnectionPairingUrl(pairingUrl); - const result = await onConnectPress(pairingUrl); - if (AsyncResult.isSuccess(result)) { - if (navigation.canGoBack()) { - navigation.goBack(); - } else { - navigation.dispatch(StackActions.replace("Home")); - } - } else { - setIsSubmitting(false); - } - }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]); + await connectPairingUrl(buildPairingUrl(hostInput, codeInput)); + }, [codeInput, connectPairingUrl, hostInput]); return ( diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 95269fb6c37..2eec2f34cb4 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -80,6 +80,24 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { ), ); + it.effect("namespaces loopback web cookies by port when development auth is enabled", () => + Effect.gen(function* () { + const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.sessionCookieName).toBe("t3_session_13773"); + }).pipe( + Effect.provide( + makeEnvironmentAuthPolicyLayer({ + mode: "web", + host: "127.0.0.1", + port: 13_773, + devAuthKey: "development-auth-key", + }), + ), + ), + ); + it.effect("uses remote-reachable policy for wildcard web hosts", () => Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 7ffef0ff0a5..9e90f1cd172 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -1,11 +1,11 @@ import type { ServerAuthDescriptor } from "@t3tools/contracts"; +import { isLoopbackHost, isWildcardHost } from "@t3tools/shared/networkHost"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; import { resolveSessionCookieName } from "./utils.ts"; -import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; export class EnvironmentAuthPolicy extends Context.Service< EnvironmentAuthPolicy, @@ -41,6 +41,7 @@ export const make = Effect.gen(function* () { sessionCookieName: resolveSessionCookieName({ mode: config.mode, port: config.port, + devAuthEnabled: config.devAuthKey !== undefined, }), }; diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 5242dd738b8..f1d93cfa3d3 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -13,7 +13,9 @@ import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; const makeServerConfigLayer = ( - overrides?: Partial>, + overrides?: Partial< + Pick + >, ) => Layer.effect( ServerConfig.ServerConfig, @@ -29,7 +31,9 @@ const makeServerConfigLayer = ( ); const makePairingGrantStoreLayer = ( - overrides?: Partial>, + overrides?: Partial< + Pick + >, ) => PairingGrantStore.layer.pipe( Layer.provide(SqlitePersistenceMemory), @@ -194,6 +198,38 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { ), ); + it.effect("seeds the development credential as a reusable administrative grant", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; + const first = yield* bootstrapCredentials.consume("development-auth-key"); + yield* TestClock.adjust(Duration.days(2)); + const second = yield* bootstrapCredentials.consume("development-auth-key"); + + expect(first.method).toBe("desktop-bootstrap"); + expect(first.subject).toBe("development-bootstrap"); + expect(first.scopes).toEqual([ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "review:write", + "relay:read", + "access:read", + "access:write", + "relay:write", + ]); + expect(second.subject).toBe("development-bootstrap"); + }).pipe( + Effect.provide( + Layer.merge( + makePairingGrantStoreLayer({ + devAuthKey: "development-auth-key", + }), + TestClock.layer(), + ), + ), + ), + ); + it.effect("lists and revokes active pairing links", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 588d5e3775f..baaca33ce1b 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -242,7 +242,8 @@ const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); // gets logged anywhere by accident) means a page reload past the 5-min // window can still recover by re-bootstrapping rather than locking // the user out of the backend. -const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24); +const DESKTOP_BOOTSTRAP_TTL = Duration.hours(24); +const DEVELOPMENT_BOOTSTRAP_EXPIRES_AT = DateTime.makeUnsafe("9999-12-31T23:59:59.999Z"); const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = @@ -304,7 +305,7 @@ export const make = Effect.gen(function* () { scopes: AuthAdministrativeScopes, subject: "desktop-bootstrap", expiresAt: DateTime.add(now, { - milliseconds: Duration.toMillis(DESKTOP_BOOTSTRAP_TTL_HOURS), + milliseconds: Duration.toMillis(DESKTOP_BOOTSTRAP_TTL), }), // Unbounded uses so the renderer can re-exchange the seed for a // fresh bearer session after a page reload (or after the prior @@ -315,6 +316,16 @@ export const make = Effect.gen(function* () { }); } + if (config.devAuthKey) { + yield* seedGrant(config.devAuthKey, { + method: "desktop-bootstrap", + scopes: AuthAdministrativeScopes, + subject: "development-bootstrap", + expiresAt: DEVELOPMENT_BOOTSTRAP_EXPIRES_AT, + remainingUses: "unbounded", + }); + } + const listActive: PairingGrantStore["Service"]["listActive"] = Effect.fn( "PairingGrantStore.listActive", )( diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 12ecb7dba4d..fbfbcc97416 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -470,6 +470,7 @@ export const make = Effect.gen(function* () { const cookieName = resolveSessionCookieName({ mode: serverConfig.mode, port: serverConfig.port, + devAuthEnabled: serverConfig.devAuthKey !== undefined, }); const emitUpsert = (clientSession: AuthClientSession) => diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 39f04988ac5..1960824ab0e 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -13,8 +13,9 @@ const SESSION_COOKIE_NAME = "t3_session"; export function resolveSessionCookieName(input: { readonly mode: "web" | "desktop"; readonly port: number; + readonly devAuthEnabled: boolean; }): string { - if (input.mode !== "desktop") { + if (input.mode !== "desktop" && !input.devAuthEnabled) { return SESSION_COOKIE_NAME; } diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 999547b71d8..cb2a6a89fff 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -87,6 +87,7 @@ const makeCliTestServerConfig = (baseDir: string) => noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, + devAuthKey: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index f6c2a63e192..74b0941d96c 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -120,6 +120,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, + devAuthKey: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, tailscaleServeEnabled: false, @@ -190,6 +191,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, + devAuthKey: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, tailscaleServeEnabled: true, @@ -263,6 +265,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: false, startupPresentation: "browser", desktopBootstrapToken: "desktop-bootstrap-token", + devAuthKey: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, @@ -337,6 +340,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: "desktop-token", + devAuthKey: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, @@ -465,6 +469,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: "desktop-token", + devAuthKey: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, tailscaleServeEnabled: false, @@ -534,6 +539,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, + devAuthKey: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, @@ -597,6 +603,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, startupPresentation: "headless", desktopBootstrapToken: undefined, + devAuthKey: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, @@ -604,4 +611,89 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }); }), ); + + it.effect("enables the reusable development credential on loopback", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-dev-auth-" }); + const resolved = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(13_773), + host: Option.some("127.0.0.1"), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.some(new URL("http://127.0.0.1:5733")), + noBrowser: Option.some(true), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_DEV_AUTH: "true", + T3CODE_DEV_AUTH_KEY: "development-auth-key", + }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved.devAuthKey).toBe("development-auth-key"); + }), + ); + + it.effect("rejects reusable development credentials on non-loopback hosts", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-dev-auth-lan-" }); + const error = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(13_773), + host: Option.some("0.0.0.0"), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.some(true), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_DEV_AUTH: "true", + T3CODE_DEV_AUTH_KEY: "development-auth-key", + }, + }), + ), + NetService.layer, + ), + ), + Effect.flip, + ); + + expect(error._tag).toBe("DevAuthConfigurationError"); + if (error._tag === "DevAuthConfigurationError") { + expect(error.reason).toBe("non-loopback-host"); + expect(error.message).toContain("only available on loopback hosts"); + } + }), + ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 5a4cde0a6fd..d0b74d6f5cd 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -1,4 +1,5 @@ import * as NetService from "@t3tools/shared/Net"; +import { isLoopbackHost } from "@t3tools/shared/networkHost"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; @@ -17,6 +18,20 @@ import { readBootstrapEnvelope } from "../bootstrap.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; +export class DevAuthConfigurationError extends Schema.TaggedErrorClass()( + "DevAuthConfigurationError", + { + reason: Schema.Literals(["missing-key", "non-loopback-host"]), + host: Schema.optional(Schema.String), + }, +) { + override get message(): string { + return this.reason === "missing-key" + ? "T3CODE_DEV_AUTH_KEY is required when T3CODE_DEV_AUTH is enabled." + : `T3CODE_DEV_AUTH is only available on loopback hosts; received ${this.host ?? "unknown"}.`; + } +} + export const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), Flag.optional, @@ -104,6 +119,11 @@ const EnvServerConfig = Config.all({ ), port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), + devAuthEnabled: Config.boolean("T3CODE_DEV_AUTH").pipe(Config.withDefault(false)), + devAuthKey: Config.string("T3CODE_DEV_AUTH_KEY").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( @@ -335,6 +355,16 @@ export const resolveServerConfig = ( ), () => (mode === "desktop" ? "127.0.0.1" : undefined), ); + const devAuthKey = env.devAuthEnabled ? env.devAuthKey?.trim() : undefined; + if (env.devAuthEnabled && !devAuthKey) { + return yield* new DevAuthConfigurationError({ reason: "missing-key" }); + } + if (devAuthKey && !isLoopbackHost(host)) { + return yield* new DevAuthConfigurationError({ + reason: "non-loopback-host", + ...(host ? { host } : {}), + }); + } const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); const config: ServerConfig.ServerConfig["Service"] = { @@ -366,6 +396,7 @@ export const resolveServerConfig = ( noBrowser, startupPresentation, desktopBootstrapToken, + devAuthKey, autoBootstrapProjectFromCwd, logWebSocketEvents, tailscaleServeEnabled, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 3b081c95c34..1c2de41a4bb 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -75,6 +75,7 @@ export class ServerConfig extends Context.Service< readonly noBrowser: boolean; readonly startupPresentation: StartupPresentation; readonly desktopBootstrapToken: string | undefined; + readonly devAuthKey: string | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; @@ -185,6 +186,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( port: 0, host: undefined, desktopBootstrapToken: undefined, + devAuthKey: undefined, staticDir: undefined, devUrl, noBrowser: false, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 61892d53d63..3f678b703ac 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -41,6 +41,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { port: 0, host: undefined, desktopBootstrapToken: undefined, + devAuthKey: undefined, staticDir: undefined, devUrl: undefined, noBrowser: false, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 6122c498145..5ed39b64fa1 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -378,6 +378,7 @@ const buildAppUnderTest = (options?: { noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: defaultDesktopBootstrapToken, + devAuthKey: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 7df131669ba..c348850c291 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,6 +1,7 @@ import * as NodeOS from "node:os"; import { QrCode } from "@t3tools/shared/qrCode"; +import { formatHostForUrl, isLoopbackHost, isWildcardHost } from "@t3tools/shared/networkHost"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/unstable/http"; @@ -15,25 +16,7 @@ export interface HeadlessServeAccessInfo { type NetworkInterfacesMap = ReturnType; -export const isLoopbackHost = (host: string | undefined): boolean => { - if (!host || host.length === 0) { - return true; - } - - return ( - host === "localhost" || - host === "127.0.0.1" || - host === "::1" || - host === "[::1]" || - host.startsWith("127.") - ); -}; - -export const isWildcardHost = (host: string | undefined): boolean => - host === "0.0.0.0" || host === "::" || host === "[::]"; - -export const formatHostForUrl = (host: string): string => - host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +export { formatHostForUrl, isLoopbackHost, isWildcardHost }; const normalizeHost = (host: string): string => host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; diff --git a/packages/shared/package.json b/packages/shared/package.json index 9c45753cd8e..087646aa1c9 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -51,6 +51,10 @@ "types": "./src/Net.ts", "import": "./src/Net.ts" }, + "./networkHost": { + "types": "./src/networkHost.ts", + "import": "./src/networkHost.ts" + }, "./DrainableWorker": { "types": "./src/DrainableWorker.ts", "import": "./src/DrainableWorker.ts" diff --git a/packages/shared/src/networkHost.ts b/packages/shared/src/networkHost.ts new file mode 100644 index 00000000000..044a6523c0f --- /dev/null +++ b/packages/shared/src/networkHost.ts @@ -0,0 +1,19 @@ +export const isLoopbackHost = (host: string | undefined): boolean => { + if (!host || host.length === 0) { + return true; + } + + return ( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host === "[::1]" || + host.startsWith("127.") + ); +}; + +export const isWildcardHost = (host: string | undefined): boolean => + host === "0.0.0.0" || host === "::" || host === "[::]"; + +export const formatHostForUrl = (host: string): string => + host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 3b79db49f5b..44d840b6c50 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -13,7 +13,9 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { checkPortAvailabilityOnHosts, + createDevAccessUrls, createDevRunnerEnv, + deriveDevAuthKey, findFirstAvailableOffset, getDevRunnerModeArgs, resolveModePortOffsets, @@ -125,6 +127,42 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { ); }); + describe("development authentication", () => { + it.effect("derives a stable opaque key from the canonical worktree root", () => + Effect.sync(() => { + const first = deriveDevAuthKey("/tmp/t3-worktree-a"); + const repeated = deriveDevAuthKey("/tmp/t3-worktree-a"); + const other = deriveDevAuthKey("/tmp/t3-worktree-b"); + + assert.match(first, /^[a-f0-9]{64}$/); + assert.equal(first, repeated); + assert.notEqual(first, other); + assert.notInclude(first, "t3-worktree-a"); + }), + ); + + it.effect("prints reusable web and platform-specific mobile pairing urls", () => + Effect.sync(() => { + const urls = createDevAccessUrls({ + mode: "dev", + serverPort: 13_773, + webUrl: "http://localhost:5733", + credential: "development-key", + }); + + assert.equal(urls.web, "http://localhost:5733/pair#token=development-key"); + assert.include( + decodeURIComponent(urls.ios), + "pairingUrl=http://127.0.0.1:13773/pair#token=development-key", + ); + assert.include( + decodeURIComponent(urls.android), + "pairingUrl=http://10.0.2.2:13773/pair#token=development-key", + ); + }), + ); + }); + describe("createDevRunnerEnv", () => { it.effect("leaves the shared home implicit and disables browser auto-open", () => Effect.gen(function* () { @@ -144,6 +182,8 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.T3CODE_HOME, undefined); assert.equal(env.T3CODE_NO_BROWSER, "1"); + assert.equal(env.T3CODE_DEV_AUTH, "1"); + assert.match(env.T3CODE_DEV_AUTH_KEY ?? "", /^[a-f0-9]{64}$/); }), ); @@ -213,6 +253,8 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.T3CODE_LOG_WS_EVENTS, "1"); assert.equal(env.T3CODE_HOST, "0.0.0.0"); assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:7331/"); + assert.equal(env.T3CODE_DEV_AUTH, undefined); + assert.equal(env.T3CODE_DEV_AUTH_KEY, undefined); }), ); @@ -316,6 +358,8 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.T3CODE_NO_BROWSER, undefined); assert.equal(env.T3CODE_HOST, undefined); assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222"); + assert.equal(env.T3CODE_DEV_AUTH, undefined); + assert.equal(env.T3CODE_DEV_AUTH_KEY, undefined); }), ); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 1938232300f..62d0870106c 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -1,11 +1,14 @@ #!/usr/bin/env node +import * as NodeCrypto from "node:crypto"; import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { isLoopbackHost } from "@t3tools/shared/networkHost"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; @@ -56,6 +59,48 @@ export function getDevRunnerModeArgs(mode: DevMode): ReadonlyArray { return MODE_ARGS[mode]; } +export function deriveDevAuthKey(worktreeRoot: string): string { + // This is a stable public identity, not a secret. Dev auth deliberately assumes a + // single-user development machine and is rejected unless the server binds to loopback. + return NodeCrypto.createHash("sha256").update(`t3-dev:${worktreeRoot}`).digest("hex"); +} + +export function createDevAccessUrls(input: { + readonly mode: DevMode; + readonly serverPort: number; + readonly webUrl: string; + readonly credential: string; +}): { + readonly web: string | undefined; + readonly ios: string; + readonly android: string; +} { + const pairingUrl = (host: string) => { + const url = new URL(`http://${host}:${input.serverPort}/pair`); + url.hash = new URLSearchParams([["token", input.credential]]).toString(); + return url.toString(); + }; + const mobileUrl = (pairing: string) => { + const url = new URL("t3code-dev://connections/new"); + url.searchParams.set("pairingUrl", pairing); + return url.toString(); + }; + const web = + input.mode === "dev" + ? (() => { + const url = new URL("/pair", input.webUrl); + url.hash = new URLSearchParams([["token", input.credential]]).toString(); + return url.toString(); + })() + : undefined; + + return { + web, + ios: mobileUrl(pairingUrl("127.0.0.1")), + android: mobileUrl(pairingUrl("10.0.2.2")), + }; +} + export class DevRunnerConfigurationError extends Schema.TaggedErrorClass()( "DevRunnerConfigurationError", { @@ -243,10 +288,12 @@ export function createDevRunnerEnv({ devUrl, }: CreateDevRunnerEnvInput): Effect.Effect { return Effect.gen(function* () { + const path = yield* Path.Path; const serverPort = port ?? BASE_SERVER_PORT + serverOffset; const webPort = BASE_WEB_PORT + webOffset; const configuredBaseDir = t3Home?.trim() || baseEnv.T3CODE_HOME?.trim() || undefined; const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); + const worktreeRoot = path.resolve(NodeURL.fileURLToPath(new URL("..", import.meta.url))); const isDesktopMode = mode === "dev:desktop"; const output: NodeJS.ProcessEnv = { @@ -276,6 +323,15 @@ export function createDevRunnerEnv({ delete output.T3CODE_HOST; } + const devAuthEnabled = (mode === "dev" || mode === "dev:server") && isLoopbackHost(host); + if (devAuthEnabled) { + output.T3CODE_DEV_AUTH = "1"; + output.T3CODE_DEV_AUTH_KEY = deriveDevAuthKey(worktreeRoot); + } else { + delete output.T3CODE_DEV_AUTH; + delete output.T3CODE_DEV_AUTH_KEY; + } + if (!isDesktopMode && host !== undefined) { output.T3CODE_HOST = host; } @@ -529,6 +585,27 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, ); + if (env.T3CODE_DEV_AUTH === "1" && env.T3CODE_DEV_AUTH_KEY) { + const urls = createDevAccessUrls({ + mode: input.mode, + serverPort: Number(env.T3CODE_PORT), + webUrl: env.VITE_DEV_SERVER_URL!, + credential: env.T3CODE_DEV_AUTH_KEY, + }); + if (urls.web) { + yield* Effect.logInfo(`[dev-runner] reusable web pairing URL: ${urls.web}`); + } + yield* Effect.logInfo(`[dev-runner] reusable iOS pairing URL: ${urls.ios}`); + yield* Effect.logInfo(`[dev-runner] reusable Android pairing URL: ${urls.android}`); + } else if ( + (input.mode === "dev" || input.mode === "dev:server") && + !isLoopbackHost(input.host) + ) { + yield* Effect.logInfo( + `[dev-runner] reusable dev authentication disabled for non-loopback host ${input.host}; use a regular pairing credential`, + ); + } + if (input.dryRun) { return; } From 6ddeaf0fbb51b83a85d001786b8a9a5b547f06ba Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 04:07:01 -0700 Subject: [PATCH 2/3] fix: split dev auth configuration errors --- apps/server/src/cli/config.test.ts | 45 ++++++++++++++++++++++++++++-- apps/server/src/cli/config.ts | 29 +++++++++---------- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 74b0941d96c..f94c41f56d8 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -652,6 +652,45 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); + it.effect("requires a reusable development credential when dev auth is enabled", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-dev-auth-missing-" }); + const error = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(13_773), + host: Option.some("127.0.0.1"), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.some(true), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { T3CODE_DEV_AUTH: "true" }, + }), + ), + NetService.layer, + ), + ), + Effect.flip, + ); + + expect(error._tag).toBe("MissingDevAuthKeyError"); + expect(error.message).toContain("T3CODE_DEV_AUTH_KEY is required"); + }), + ); + it.effect("rejects reusable development credentials on non-loopback hosts", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -689,9 +728,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { Effect.flip, ); - expect(error._tag).toBe("DevAuthConfigurationError"); - if (error._tag === "DevAuthConfigurationError") { - expect(error.reason).toBe("non-loopback-host"); + expect(error._tag).toBe("NonLoopbackDevAuthHostError"); + if (error._tag === "NonLoopbackDevAuthHostError") { + expect(error.host).toBe("0.0.0.0"); expect(error.message).toContain("only available on loopback hosts"); } }), diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index d0b74d6f5cd..f2fc37255bd 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -18,17 +18,21 @@ import { readBootstrapEnvelope } from "../bootstrap.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; -export class DevAuthConfigurationError extends Schema.TaggedErrorClass()( - "DevAuthConfigurationError", - { - reason: Schema.Literals(["missing-key", "non-loopback-host"]), - host: Schema.optional(Schema.String), - }, +export class MissingDevAuthKeyError extends Schema.TaggedErrorClass()( + "MissingDevAuthKeyError", + {}, +) { + override get message(): string { + return "T3CODE_DEV_AUTH_KEY is required when T3CODE_DEV_AUTH is enabled."; + } +} + +export class NonLoopbackDevAuthHostError extends Schema.TaggedErrorClass()( + "NonLoopbackDevAuthHostError", + { host: Schema.String }, ) { override get message(): string { - return this.reason === "missing-key" - ? "T3CODE_DEV_AUTH_KEY is required when T3CODE_DEV_AUTH is enabled." - : `T3CODE_DEV_AUTH is only available on loopback hosts; received ${this.host ?? "unknown"}.`; + return `T3CODE_DEV_AUTH is only available on loopback hosts; received ${this.host}.`; } } @@ -357,13 +361,10 @@ export const resolveServerConfig = ( ); const devAuthKey = env.devAuthEnabled ? env.devAuthKey?.trim() : undefined; if (env.devAuthEnabled && !devAuthKey) { - return yield* new DevAuthConfigurationError({ reason: "missing-key" }); + return yield* new MissingDevAuthKeyError(); } if (devAuthKey && !isLoopbackHost(host)) { - return yield* new DevAuthConfigurationError({ - reason: "non-loopback-host", - ...(host ? { host } : {}), - }); + return yield* new NonLoopbackDevAuthHostError({ host: host ?? "unknown" }); } const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); From 7c9a29859b5e38c02d7fc8120932f4169bbf9573 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 13:57:18 -0700 Subject: [PATCH 3/3] fix: harden dev auth loopback gate --- apps/server/src/auth/EnvironmentAuthPolicy.ts | 3 +- .../server/src/auth/PairingGrantStore.test.ts | 2 + apps/server/src/cli/config.test.ts | 60 ++++++++++++++++--- apps/server/src/cli/config.ts | 22 +++++-- apps/web/src/browser/browserTargetResolver.ts | 4 +- .../preview/useDiscoveredLocalServers.ts | 4 +- packages/shared/src/networkHost.test.ts | 31 ++++++++++ packages/shared/src/networkHost.ts | 21 ++++--- packages/shared/src/preview.test.ts | 8 +-- packages/shared/src/preview.ts | 19 +++--- scripts/dev-runner.test.ts | 28 +++++++++ scripts/dev-runner.ts | 26 ++++---- 12 files changed, 177 insertions(+), 51 deletions(-) create mode 100644 packages/shared/src/networkHost.test.ts diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 9e90f1cd172..09472b07874 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -16,7 +16,8 @@ export class EnvironmentAuthPolicy extends Context.Service< export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); + const effectiveHost = config.host ?? "127.0.0.1"; + const isRemoteReachable = isWildcardHost(effectiveHost) || !isLoopbackHost(effectiveHost); const policy = config.mode === "desktop" diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index f1d93cfa3d3..e6ba7d374d3 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -217,7 +217,9 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { "access:write", "relay:write", ]); + expect(second.method).toBe("desktop-bootstrap"); expect(second.subject).toBe("development-bootstrap"); + expect(second.scopes).toEqual(first.scopes); }).pipe( Effect.provide( Layer.merge( diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index f94c41f56d8..dcaee547ede 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -695,11 +695,60 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-dev-auth-lan-" }); + for (const host of [undefined, "0.0.0.0", "127.attacker.example"]) { + const error = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(13_773), + host: Option.fromUndefinedOr(host), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.some(true), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_DEV_AUTH: "true", + T3CODE_DEV_AUTH_KEY: "development-auth-key", + }, + }), + ), + NetService.layer, + ), + ), + Effect.flip, + ); + + expect(error._tag).toBe("NonLoopbackDevAuthHostError"); + if (error._tag === "NonLoopbackDevAuthHostError") { + expect(error.host).toBe(host); + expect(error.message).toContain("requires an explicit loopback address"); + } + } + }), + ); + + it.effect("rejects reusable development credentials with Tailscale Serve", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-cli-dev-auth-tailscale-", + }); const error = yield* resolveServerConfig( { mode: Option.some("web"), port: Option.some(13_773), - host: Option.some("0.0.0.0"), + host: Option.some("127.0.0.1"), baseDir: Option.some(baseDir), cwd: Option.none(), devUrl: Option.none(), @@ -707,7 +756,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), - tailscaleServeEnabled: Option.none(), + tailscaleServeEnabled: Option.some(true), tailscaleServePort: Option.none(), }, Option.none(), @@ -728,11 +777,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { Effect.flip, ); - expect(error._tag).toBe("NonLoopbackDevAuthHostError"); - if (error._tag === "NonLoopbackDevAuthHostError") { - expect(error.host).toBe("0.0.0.0"); - expect(error.message).toContain("only available on loopback hosts"); - } + expect(error._tag).toBe("TailscaleServeDevAuthError"); + expect(error.message).toContain("cannot be enabled with Tailscale Serve"); }), ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index f2fc37255bd..84abaaa4b3d 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -1,5 +1,5 @@ import * as NetService from "@t3tools/shared/Net"; -import { isLoopbackHost } from "@t3tools/shared/networkHost"; +import { isLoopbackAddress } from "@t3tools/shared/networkHost"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; @@ -29,10 +29,19 @@ export class MissingDevAuthKeyError extends Schema.TaggedErrorClass()( "NonLoopbackDevAuthHostError", - { host: Schema.String }, + { host: Schema.optional(Schema.String) }, ) { override get message(): string { - return `T3CODE_DEV_AUTH is only available on loopback hosts; received ${this.host}.`; + return `T3CODE_DEV_AUTH requires an explicit loopback address; received ${this.host ?? "an omitted host"}.`; + } +} + +export class TailscaleServeDevAuthError extends Schema.TaggedErrorClass()( + "TailscaleServeDevAuthError", + {}, +) { + override get message(): string { + return "T3CODE_DEV_AUTH cannot be enabled with Tailscale Serve."; } } @@ -363,8 +372,11 @@ export const resolveServerConfig = ( if (env.devAuthEnabled && !devAuthKey) { return yield* new MissingDevAuthKeyError(); } - if (devAuthKey && !isLoopbackHost(host)) { - return yield* new NonLoopbackDevAuthHostError({ host: host ?? "unknown" }); + if (devAuthKey && !isLoopbackAddress(host)) { + return yield* new NonLoopbackDevAuthHostError(host === undefined ? {} : { host }); + } + if (devAuthKey && tailscaleServeEnabled) { + return yield* new TailscaleServeDevAuthError(); } const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 9b201dbdbae..dc97f07b405 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -3,7 +3,7 @@ import type { EnvironmentId, PreviewUrlResolution, } from "@t3tools/contracts"; -import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; +import { isPreviewLocalHost, normalizePreviewUrl } from "@t3tools/shared/preview"; import { readPreparedConnection } from "~/state/session"; @@ -101,7 +101,7 @@ export function resolveBrowserNavigationTarget( // Preserve the existing direct-navigation behavior so the preview host // reports malformed URL errors through its normal navigation path. } - if (parsed && isLoopbackHost(parsed.hostname)) { + if (parsed && isPreviewLocalHost(parsed.hostname)) { const environmentUrl = readEnvironmentUrl(environmentId); if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) { return resolveEnvironmentPortTarget( diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.ts index 118a56b9068..c5d2ad6ce87 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.ts @@ -1,5 +1,5 @@ import type { DiscoveredLocalServer } from "@t3tools/contracts"; -import { isLoopbackHost } from "@t3tools/shared/preview"; +import { isPreviewLocalHost } from "@t3tools/shared/preview"; import { useMemo } from "react"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -124,7 +124,7 @@ function parseLocalUrl(raw: string): { host: string; port: number; url: string } try { const parsed = new URL(raw); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; - if (!isLoopbackHost(parsed.hostname)) return null; + if (!isPreviewLocalHost(parsed.hostname)) return null; const port = parsed.port ? Number.parseInt(parsed.port, 10) : parsed.protocol === "http:" diff --git a/packages/shared/src/networkHost.test.ts b/packages/shared/src/networkHost.test.ts new file mode 100644 index 00000000000..b17cbc08afc --- /dev/null +++ b/packages/shared/src/networkHost.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isLoopbackAddress, isLoopbackHost } from "./networkHost.ts"; + +describe("isLoopbackAddress", () => { + it.each(["127.0.0.1", "127.0.0.2", "::1", "[::1]"])( + "accepts the loopback address literal %s", + (host) => { + expect(isLoopbackAddress(host)).toBe(true); + }, + ); + + it.each([ + undefined, + "", + "localhost", + "127.attacker.example", + "127.0.0.256", + "127.00.0.1", + "0.0.0.0", + ])("rejects the unsafe or non-literal host %s", (host) => { + expect(isLoopbackAddress(host)).toBe(false); + }); +}); + +describe("isLoopbackHost", () => { + it("retains localhost for callers that model effective server binds", () => { + expect(isLoopbackHost("localhost")).toBe(true); + expect(isLoopbackHost(undefined)).toBe(false); + }); +}); diff --git a/packages/shared/src/networkHost.ts b/packages/shared/src/networkHost.ts index 044a6523c0f..933d0df2d3f 100644 --- a/packages/shared/src/networkHost.ts +++ b/packages/shared/src/networkHost.ts @@ -1,17 +1,20 @@ -export const isLoopbackHost = (host: string | undefined): boolean => { - if (!host || host.length === 0) { - return true; - } +export const isLoopbackAddress = (host: string | undefined): boolean => { + if (!host) return false; + if (host === "::1" || host === "[::1]") return true; + const octets = host.split("."); return ( - host === "localhost" || - host === "127.0.0.1" || - host === "::1" || - host === "[::1]" || - host.startsWith("127.") + octets.length === 4 && + octets[0] === "127" && + octets.every( + (octet) => /^(?:0|[1-9]\d{0,2})$/u.test(octet) && Number.parseInt(octet, 10) <= 255, + ) ); }; +export const isLoopbackHost = (host: string | undefined): boolean => + host === "localhost" || isLoopbackAddress(host); + export const isWildcardHost = (host: string | undefined): boolean => host === "0.0.0.0" || host === "::" || host === "[::]"; diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index fec4203c533..f877fbdce76 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; import { - isLoopbackHost, isPreviewableUrl, + isPreviewLocalHost, newPreviewTabId, normalizePreviewUrl, PreviewUrlNormalizationError, @@ -17,13 +17,13 @@ describe("newPreviewTabId", () => { }); }); -describe("isLoopbackHost", () => { +describe("isPreviewLocalHost", () => { it.each(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"])("%s is loopback", (host) => { - expect(isLoopbackHost(host)).toBe(true); + expect(isPreviewLocalHost(host)).toBe(true); }); it.each(["example.com", "192.168.1.10", "10.0.0.1", ""])("%s is not loopback", (host) => { - expect(isLoopbackHost(host)).toBe(false); + expect(isPreviewLocalHost(host)).toBe(false); }); }); diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index 926b30966e5..781fe0c064a 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -18,20 +18,25 @@ export function newPreviewTabId(): string { return `${TAB_ID_PREFIX}${nextPreviewTabSequence.toString(36)}`; } -const LOOPBACK_HOSTS: ReadonlySet = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); +const PREVIEW_LOCAL_HOSTS: ReadonlySet = new Set([ + "localhost", + "127.0.0.1", + "0.0.0.0", + "::1", +]); /** Internal — used by `lsof` parsing where the host string is wire-formatted. */ export const LSOF_LOCAL_HOST_TOKENS: ReadonlySet = new Set([ - ...LOOPBACK_HOSTS, + ...PREVIEW_LOCAL_HOSTS, "*", "[::]", "[::1]", ]); -const LOOPBACK_PREFIX_PATTERN = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::|\/|$)/i; +const PREVIEW_LOCAL_PREFIX_PATTERN = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::|\/|$)/i; -export function isLoopbackHost(host: string): boolean { - if (LOOPBACK_HOSTS.has(host)) return true; +export function isPreviewLocalHost(host: string): boolean { + if (PREVIEW_LOCAL_HOSTS.has(host)) return true; if (host === "[::1]") return true; return false; } @@ -41,7 +46,7 @@ export function isPreviewableUrl(rawUrl: string): boolean { try { const parsed = new URL(rawUrl); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; - return isLoopbackHost(parsed.hostname); + return isPreviewLocalHost(parsed.hostname); } catch { return false; } @@ -83,7 +88,7 @@ export function normalizePreviewUrl(rawUrl: string): string { if (trimmed.length === 0) { throw new PreviewUrlNormalizationError({ inputLength: rawUrl.length, reason: "empty" }); } - const useHttp = LOOPBACK_PREFIX_PATTERN.test(trimmed); + const useHttp = PREVIEW_LOCAL_PREFIX_PATTERN.test(trimmed); const candidate = trimmed.includes("://") ? trimmed : `${useHttp ? "http" : "https"}://${trimmed}`; diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 44d840b6c50..7747708e910 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -182,11 +182,39 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.T3CODE_HOME, undefined); assert.equal(env.T3CODE_NO_BROWSER, "1"); + assert.equal(env.T3CODE_HOST, "127.0.0.1"); assert.equal(env.T3CODE_DEV_AUTH, "1"); assert.match(env.T3CODE_DEV_AUTH_KEY ?? "", /^[a-f0-9]{64}$/); }), ); + it.effect("only provisions reusable auth on the Android-compatible loopback bind", () => + Effect.gen(function* () { + for (const host of ["127.0.0.2", "::1", "127.attacker.example"]) { + const env = yield* createDevRunnerEnv({ + mode: "dev:server", + baseEnv: { + T3CODE_DEV_AUTH: "1", + T3CODE_DEV_AUTH_KEY: "inherited-key", + }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_HOST, host); + assert.equal(env.T3CODE_DEV_AUTH, undefined); + assert.equal(env.T3CODE_DEV_AUTH_KEY, undefined); + } + }), + ); + it.effect("allows browser auto-open to be explicitly enabled", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 62d0870106c..24a20740f7e 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -8,7 +8,6 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { isLoopbackHost } from "@t3tools/shared/networkHost"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; @@ -29,7 +28,7 @@ const BASE_SERVER_PORT = 13773; const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; -const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; +const DEV_LOOPBACK_HOST = "127.0.0.1"; const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const; export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => @@ -295,13 +294,14 @@ export function createDevRunnerEnv({ const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); const worktreeRoot = path.resolve(NodeURL.fileURLToPath(new URL("..", import.meta.url))); const isDesktopMode = mode === "dev:desktop"; + const serverHost = mode === "dev" || mode === "dev:server" ? (host ?? DEV_LOOPBACK_HOST) : host; const output: NodeJS.ProcessEnv = { ...baseEnv, PORT: String(webPort), VITE_DEV_SERVER_URL: devUrl?.toString() ?? - `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, + `http://${isDesktopMode ? DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, }; if (configuredBaseDir !== undefined) { @@ -316,14 +316,15 @@ export function createDevRunnerEnv({ output.VITE_WS_URL = `ws://localhost:${serverPort}`; } else { output.T3CODE_PORT = String(serverPort); - output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; - output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; + output.VITE_HTTP_URL = `http://${DEV_LOOPBACK_HOST}:${serverPort}`; + output.VITE_WS_URL = `ws://${DEV_LOOPBACK_HOST}:${serverPort}`; delete output.T3CODE_MODE; delete output.T3CODE_NO_BROWSER; delete output.T3CODE_HOST; } - const devAuthEnabled = (mode === "dev" || mode === "dev:server") && isLoopbackHost(host); + const devAuthEnabled = + (mode === "dev" || mode === "dev:server") && serverHost === DEV_LOOPBACK_HOST; if (devAuthEnabled) { output.T3CODE_DEV_AUTH = "1"; output.T3CODE_DEV_AUTH_KEY = deriveDevAuthKey(worktreeRoot); @@ -332,8 +333,8 @@ export function createDevRunnerEnv({ delete output.T3CODE_DEV_AUTH_KEY; } - if (!isDesktopMode && host !== undefined) { - output.T3CODE_HOST = host; + if (!isDesktopMode && serverHost !== undefined) { + output.T3CODE_HOST = serverHost; } if (!isDesktopMode) { @@ -363,7 +364,7 @@ export function createDevRunnerEnv({ } if (isDesktopMode) { - output.HOST = DESKTOP_DEV_LOOPBACK_HOST; + output.HOST = DEV_LOOPBACK_HOST; delete output.T3CODE_DESKTOP_WS_URL; } @@ -597,12 +598,9 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { } yield* Effect.logInfo(`[dev-runner] reusable iOS pairing URL: ${urls.ios}`); yield* Effect.logInfo(`[dev-runner] reusable Android pairing URL: ${urls.android}`); - } else if ( - (input.mode === "dev" || input.mode === "dev:server") && - !isLoopbackHost(input.host) - ) { + } else if (input.mode === "dev" || input.mode === "dev:server") { yield* Effect.logInfo( - `[dev-runner] reusable dev authentication disabled for non-loopback host ${input.host}; use a regular pairing credential`, + `[dev-runner] reusable dev authentication disabled for host ${input.host ?? "unknown"}; use --host ${DEV_LOOPBACK_HOST} or a regular pairing credential`, ); }