diff --git a/AGENTS.md b/AGENTS.md index 66991a904546..26f66c32234a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ The most common defect in this repo is a change that works on the path you teste - `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run. - `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins. -- Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. +- Ports derive from the worktree path and are stable across restarts, and the host is `.localhost` so every dev server keeps its own cookie jar; read the real origin from the `[dev-runner]` line since occupied ports shift. - Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, paste that full URL (token included) in your reply. Do not wire up `tailscale serve` by hand for this, and do not open the URL yourself. - The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. If the token got consumed, mint a fresh one with `node apps/server/src/bin.ts pair` — note it carries standard scopes, while the startup URL carries admin scopes (needed for Settings → Connections management). - Stop what you started, by the PID you tracked. See rule 1. diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index c23298ba14d8..780aaabde251 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -27,10 +27,8 @@ import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; -import * as Result from "effect/Result"; import * as Cookies from "effect/unstable/http/Cookies"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; @@ -39,12 +37,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; -import * as ServerConfig from "../config.ts"; -import { - decodeDevelopmentSessionCookieName, - deriveAuthClientMetadata, - planStaleDevelopmentSessionCookieSweep, -} from "./utils.ts"; +import { deriveAuthClientMetadata } from "./utils.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; const CREDENTIAL_RESPONSE_HEADERS = { @@ -210,8 +203,6 @@ export const authHttpApiLayer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; return handlers .handle( @@ -237,52 +228,13 @@ export const authHttpApiLayer = HttpApiBuilder.group( args.payload.credential, deriveAuthClientMetadata({ request }), ); - const requestCookieNames = Object.keys(request.cookies); - const cookieNamesToExpire = - serverConfig.devUrl === undefined - ? [] - : yield* Effect.gen(function* () { - const stateDirs = new Set( - requestCookieNames.flatMap((name) => { - const decoded = decodeDevelopmentSessionCookieName(name); - return decoded !== null && "stateDir" in decoded ? [decoded.stateDir] : []; - }), - ); - const stateDirExistence = new Map( - yield* Effect.all( - Array.from(stateDirs, (stateDir) => - fileSystem.exists(stateDir).pipe( - Effect.orElseSucceed(() => true), - Effect.map((exists) => [stateDir, exists] as const), - ), - ), - { concurrency: "unbounded" }, - ), - ); - return planStaleDevelopmentSessionCookieSweep({ - ownCookieName: sessions.cookieName, - ownPort: serverConfig.port, - requestCookieNames, - stateDirExists: (stateDir) => stateDirExistence.get(stateDir) ?? true, - }); - }); const sessionCookies = yield* Effect.fromResult( - cookieNamesToExpire.reduce( - (cookies, name) => - Result.flatMap(cookies, (current) => - Cookies.expireCookie(current, name, { - httpOnly: true, - path: "/", - sameSite: "lax", - }), - ), - Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, { - expires: DateTime.toDate(result.response.expiresAt), - httpOnly: true, - path: "/", - sameSite: "lax", - }), - ), + Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, { + expires: DateTime.toDate(result.response.expiresAt), + httpOnly: true, + path: "/", + sameSite: "lax", + }), ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); yield* HttpEffect.appendPreResponseHandler((_request, response) => diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index 3ad8fd7d9cb0..edc58f71131f 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; import { - base64UrlEncode, - decodeDevelopmentSessionCookieName, deriveAuthClientMetadata, isRemoteReachableHost, - planStaleDevelopmentSessionCookieSweep, resolveSessionCookieName, } from "./utils.ts"; @@ -61,34 +58,24 @@ describe("deriveAuthClientMetadata", () => { }); describe("session cookie isolation", () => { - it("isolates loopback web servers by port and encoded server state", () => { - const firstStateDir = "/tmp/t3-agent-one"; - const secondStateDir = "/tmp/t3-agent-two"; + it("isolates loopback web servers by port and server state", () => { const first = resolveSessionCookieName({ mode: "web", port: 5775, host: "127.0.0.1", - instanceKey: firstStateDir, + instanceKey: "/tmp/t3-agent-one", development: true, }); const second = resolveSessionCookieName({ mode: "web", port: 5775, host: "127.0.0.1", - instanceKey: secondStateDir, + instanceKey: "/tmp/t3-agent-two", development: true, }); - expect(first).toBe(`t3_session_5775_${base64UrlEncode(firstStateDir)}`); - expect(second).toBe(`t3_session_5775_${base64UrlEncode(secondStateDir)}`); - expect(decodeDevelopmentSessionCookieName(first)).toEqual({ - port: 5775, - stateDir: firstStateDir, - }); - expect(decodeDevelopmentSessionCookieName(second)).toEqual({ - port: 5775, - stateDir: secondStateDir, - }); + expect(first).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); + expect(second).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); expect(first).not.toBe(second); }); @@ -126,16 +113,15 @@ describe("session cookie isolation", () => { }); it("isolates development servers even when they bind a wildcard host", () => { - const stateDir = "/tmp/t3-wildcard-dev"; expect( resolveSessionCookieName({ mode: "web", port: 5775, host: "0.0.0.0", - instanceKey: stateDir, + instanceKey: "/tmp/t3-wildcard-dev", development: true, }), - ).toBe(`t3_session_5775_${base64UrlEncode(stateDir)}`); + ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); }); it("classifies loopback aliases separately from remotely reachable hosts", () => { @@ -147,50 +133,3 @@ describe("session cookie isolation", () => { expect(isRemoteReachableHost("192.168.1.50")).toBe(true); }); }); - -describe("development session cookie decoding", () => { - it("classifies encoded state directories, legacy hashes, and unrelated names", () => { - const stateDir = "/tmp/t3-agent-state"; - - expect( - decodeDevelopmentSessionCookieName(`t3_session_5775_${base64UrlEncode(stateDir)}`), - ).toEqual({ port: 5775, stateDir }); - expect(decodeDevelopmentSessionCookieName("t3_session_5775_0123456789ab")).toEqual({ - port: 5775, - legacyHash: "0123456789ab", - }); - expect(decodeDevelopmentSessionCookieName("t3_session")).toBeNull(); - expect(decodeDevelopmentSessionCookieName("t3_session_5775")).toBeNull(); - expect( - decodeDevelopmentSessionCookieName(`t3_session_5775_${base64UrlEncode("relative/state")}`), - ).toBeNull(); - expect(decodeDevelopmentSessionCookieName("other_5775_0123456789ab")).toBeNull(); - }); -}); - -describe("stale development session cookie sweep", () => { - it("expires only dead encoded siblings and same-port legacy cookies", () => { - const ownCookieName = `t3_session_5775_${base64UrlEncode("/tmp/own")}`; - const liveSibling = `t3_session_5776_${base64UrlEncode("/tmp/live")}`; - const staleSibling = `t3_session_5777_${base64UrlEncode("/tmp/stale")}`; - const samePortLegacy = "t3_session_5775_0123456789ab"; - const otherPortLegacy = "t3_session_5778_abcdef012345"; - - expect( - planStaleDevelopmentSessionCookieSweep({ - ownCookieName, - ownPort: 5775, - requestCookieNames: [ - ownCookieName, - liveSibling, - staleSibling, - samePortLegacy, - otherPortLegacy, - "t3_session", - "t3_session_3773", - ], - stateDirExists: (stateDir) => stateDir === "/tmp/live", - }), - ).toEqual([staleSibling, samePortLegacy]); - }); -}); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index fd6da64f6b88..32a6799b01f4 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -22,9 +22,6 @@ const SESSION_COOKIE_NAME = "t3_session"; * - **Desktop**, which scans upward from 3773 for a free port and binds * 127.0.0.1, so a second instance lands on a different port and the same host. * - * Dev names encode the state directory so a server can recognize and expire - * cookies belonging to worktrees whose state directories no longer exist. - * * Hosted deployments keep the stable production name: their public port can * change between releases, and scoping it would log every user out. */ @@ -43,68 +40,14 @@ export function resolveSessionCookieName(input: { return SESSION_COOKIE_NAME; } - return `${SESSION_COOKIE_NAME}_${input.port}_${base64UrlEncode(input.instanceKey)}`; -} - -export type DevelopmentSessionCookieName = - | { readonly port: number; readonly stateDir: string } - | { readonly port: number; readonly legacyHash: string }; - -export function decodeDevelopmentSessionCookieName( - name: string, -): DevelopmentSessionCookieName | null { - const match = /^t3_session_(\d+)_([A-Za-z0-9_-]+)$/.exec(name); - const port = Number(match?.[1]); - const suffix = match?.[2]; - if (!Number.isSafeInteger(port) || suffix === undefined) { - return null; - } - - if (/^[0-9a-f]{12}$/.test(suffix)) { - return { port, legacyHash: suffix }; - } - - const decoded = Encoding.decodeBase64UrlString(suffix); - if (Result.isFailure(decoded) || !isAbsolutePathLike(decoded.success)) { - return null; - } - return { port, stateDir: decoded.success }; -} - -// POSIX root, Windows drive, or UNC. Format only: the sweep decides existence. -function isAbsolutePathLike(value: string): boolean { - return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); -} - -/** - * Plans cookie expiry from already-observed directory existence. Encoding the - * path creates a localhost-only path-existence oracle for pages that craft - * cookie names and observe expiry. This is accepted for development servers, - * where such a page already runs code on the same machine. - */ -export function planStaleDevelopmentSessionCookieSweep(input: { - readonly ownCookieName: string; - readonly ownPort: number; - readonly requestCookieNames: Iterable; - readonly stateDirExists: (stateDir: string) => boolean; -}): ReadonlyArray { - const namesToExpire: Array = []; - for (const name of input.requestCookieNames) { - if (name === input.ownCookieName) { - continue; - } - const decoded = decodeDevelopmentSessionCookieName(name); - if (decoded === null) { - continue; - } - if ( - ("legacyHash" in decoded && decoded.port === input.ownPort) || - ("stateDir" in decoded && !input.stateDirExists(decoded.stateDir)) - ) { - namesToExpire.push(name); - } - } - return namesToExpire; + // Cookies are scoped by host, not port. Loopback development servers need an + // instance-specific name or parallel agents overwrite each other's session, + // and a server that later reuses the port receives a token signed elsewhere. + const instanceHash = NodeCrypto.createHash("sha256") + .update(input.instanceKey) + .digest("hex") + .slice(0, 12); + return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`; } export function isRemoteReachableHost(host: string | undefined): boolean { diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index c4f321a5a61e..93664db978fb 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -54,6 +54,12 @@ describe("pair tailscale local target", () => { expect(resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://localhost:5733/" })).toEqual( { localPort: 5_733 }, ); + expect( + resolveTailscaleLocalTarget({ + ...baseState, + devUrl: "http://feature-worktree.localhost:5733/", + }), + ).toEqual({ localPort: 5_733 }); // A dev server on a non-loopback interface must be proxied at that // interface; tailscale serve defaults to 127.0.0.1 otherwise. expect( diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index ec4d2aae16e6..d2937a97f088 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -9,12 +9,15 @@ describe("http dev routing", () => { expect(isLoopbackHostname("localhost")).toBe(true); expect(isLoopbackHostname("::1")).toBe(true); expect(isLoopbackHostname("[::1]")).toBe(true); + expect(isLoopbackHostname("feature-worktree.localhost")).toBe(true); + expect(isLoopbackHostname("FEATURE-WORKTREE.LOCALHOST")).toBe(true); }); it("does not treat LAN addresses as local", () => { expect(isLoopbackHostname("192.168.86.35")).toBe(false); expect(isLoopbackHostname("10.0.0.24")).toBe(false); expect(isLoopbackHostname("example.local")).toBe(false); + expect(isLoopbackHostname("localhost.example.com")).toBe(false); }); it("preserves path and query when redirecting to the dev server", () => { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 0da55686b92f..214134b51b51 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -89,7 +89,7 @@ export function isLoopbackHostname(hostname: string): boolean { .trim() .toLowerCase() .replace(/^\[(.*)\]$/, "$1"); - return LOOPBACK_HOSTNAMES.has(normalizedHostname); + return LOOPBACK_HOSTNAMES.has(normalizedHostname) || normalizedHostname.endsWith(".localhost"); } export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b1a351d092ac..f339ff84fae9 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -60,7 +60,6 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; -import * as Cookies from "effect/unstable/http/Cookies"; import { FetchHttpClient, HttpBody, @@ -142,7 +141,6 @@ import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import { resolveSessionCookieName } from "./auth/utils.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; @@ -1596,80 +1594,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("sweeps stale development session cookies when pairing", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const liveStateDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-live-dev-cookie-", - }); - const staleStateDir = yield* fileSystem.makeTempDirectory({ - prefix: "t3-stale-dev-cookie-", - }); - yield* fileSystem.remove(staleStateDir, { recursive: true }); - - const config = yield* buildAppUnderTest({ - config: { - mode: "web", - port: 5775, - devUrl: new URL("http://127.0.0.1:5173"), - }, - }); - const ownCookieName = resolveSessionCookieName({ - mode: config.mode, - port: config.port, - host: config.host, - instanceKey: config.stateDir, - development: true, - }); - const staleCookieName = resolveSessionCookieName({ - mode: "web", - port: 5776, - host: "127.0.0.1", - instanceKey: staleStateDir, - development: true, - }); - const liveCookieName = resolveSessionCookieName({ - mode: "web", - port: 5777, - host: "127.0.0.1", - instanceKey: liveStateDir, - development: true, - }); - const legacyCookieName = `t3_session_${config.port}_0123456789ab`; - - const { response } = yield* bootstrapBrowserSession(defaultDesktopBootstrapToken, { - headers: { - cookie: [ - `${staleCookieName}=stale-token`, - `${liveCookieName}=live-token`, - `${legacyCookieName}=legacy-token`, - ].join("; "), - }, - }); - const setCookieHeaders = Cookies.toSetCookieHeaders(response.cookies); - const ownSetCookie = setCookieHeaders.find((header) => - header.startsWith(`${ownCookieName}=`), - ); - const staleSetCookie = setCookieHeaders.find((header) => - header.startsWith(`${staleCookieName}=`), - ); - const legacySetCookie = setCookieHeaders.find((header) => - header.startsWith(`${legacyCookieName}=`), - ); - - assert.equal(response.status, 200); - assert.isDefined(ownSetCookie); - assert.notInclude(ownSetCookie ?? "", "Max-Age=0"); - for (const expiredCookie of [staleSetCookie, legacySetCookie]) { - assert.isDefined(expiredCookie); - assert.include(expiredCookie ?? "", "Max-Age=0"); - assert.include(expiredCookie ?? "", "Path=/"); - assert.include(expiredCookie ?? "", "HttpOnly"); - } - assert.notInclude(setCookieHeaders.join("\n"), liveCookieName); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("exchanges a bootstrap grant for a scoped bearer access token", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 03c01170f158..f58b3b3509eb 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -3,12 +3,19 @@ import { assert, expect, it } from "@effect/vitest"; import { buildPairingUrl, formatHeadlessServeOutput, + isLoopbackHost, renderTerminalQrCode, resolveHeadlessConnectionHost, resolveHeadlessConnectionString, resolveListeningPort, } from "./startupAccess.ts"; +it("recognizes localhost subdomains as loopback hosts", () => { + expect(isLoopbackHost("feature-worktree.localhost")).toBe(true); + expect(isLoopbackHost("FEATURE-WORKTREE.LOCALHOST")).toBe(true); + expect(isLoopbackHost("localhost.example.com")).toBe(false); +}); + it("prefers localhost when no explicit host is configured", () => { expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost"); expect(resolveHeadlessConnectionString(undefined, 3773)).toBe("http://localhost:3773"); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 7df131669bab..4a707a583984 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -20,12 +20,14 @@ export const isLoopbackHost = (host: string | undefined): boolean => { return true; } + const normalizedHost = host.toLowerCase(); return ( - host === "localhost" || - host === "127.0.0.1" || - host === "::1" || - host === "[::1]" || - host.startsWith("127.") + normalizedHost === "localhost" || + normalizedHost.endsWith(".localhost") || + normalizedHost === "127.0.0.1" || + normalizedHost === "::1" || + normalizedHost === "[::1]" || + normalizedHost.startsWith("127.") ); }; diff --git a/apps/web/src/environments/primary/target.test.ts b/apps/web/src/environments/primary/target.test.ts new file mode 100644 index 000000000000..d48167b31fd9 --- /dev/null +++ b/apps/web/src/environments/primary/target.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isLoopbackHostname } from "./target"; + +describe("isLoopbackHostname", () => { + it.each([ + "localhost", + "feature-worktree.localhost", + "FEATURE-WORKTREE.LOCALHOST", + "127.0.0.1", + "::1", + "[::1]", + ])("treats %s as loopback", (hostname) => { + expect(isLoopbackHostname(hostname)).toBe(true); + }); + + it.each(["example.com", "localhost.example.com", "example.local"])( + "does not treat %s as loopback", + (hostname) => { + expect(isLoopbackHostname(hostname)).toBe(false); + }, + ); +}); diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index cc002419fe78..f696f5563efc 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -138,7 +138,8 @@ function normalizeHostname(hostname: string): string { } export function isLoopbackHostname(hostname: string): boolean { - return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname)); + const normalizedHostname = normalizeHostname(hostname); + return LOOPBACK_HOSTNAMES.has(normalizedHostname) || normalizedHostname.endsWith(".localhost"); } function resolveHttpRequestBaseUrl(primaryTarget: PrimaryEnvironmentTarget): string { diff --git a/apps/web/src/hostFonts.test.ts b/apps/web/src/hostFonts.test.ts new file mode 100644 index 000000000000..d673a8983743 --- /dev/null +++ b/apps/web/src/hostFonts.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { canUseHostFontEnumeration } from "./hostFonts"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("canUseHostFontEnumeration", () => { + it.each(["localhost", "feature-worktree.localhost", "FEATURE-WORKTREE.LOCALHOST"])( + "allows host font enumeration at %s", + (hostname) => { + vi.stubGlobal("window", { desktopBridge: undefined, location: { hostname } }); + + expect(canUseHostFontEnumeration()).toBe(true); + }, + ); + + it("does not enumerate host fonts for a remote browser", () => { + vi.stubGlobal("window", { + desktopBridge: undefined, + location: { hostname: "example.com" }, + }); + + expect(canUseHostFontEnumeration()).toBe(false); + }); +}); diff --git a/apps/web/src/hostFonts.ts b/apps/web/src/hostFonts.ts index 011e21305c7b..2374557a5901 100644 --- a/apps/web/src/hostFonts.ts +++ b/apps/web/src/hostFonts.ts @@ -8,7 +8,8 @@ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); export function canUseHostFontEnumeration(): boolean { if (typeof window === "undefined") return false; if (window.desktopBridge !== undefined) return true; - return LOOPBACK_HOSTNAMES.has(window.location.hostname.toLowerCase()); + const hostname = window.location.hostname.toLowerCase(); + return LOOPBACK_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost"); } /** diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 430cdd143921..bbf6d47d00ba 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -37,10 +37,9 @@ browser session cookie. The cookie is an HTTP transport adapter for the same scoped session model; the response never exposes the session secret to browser JavaScript. -Loopback development servers use a per-instance cookie name containing the -server port and a base64url-encoded state directory, so parallel worktrees do -not overwrite each other's sessions. Pairing also expires cookies whose encoded -state directories no longer exist, while preserving live sibling worktrees. +Dev servers are served at `.localhost`, so each worktree keeps its own +browser cookie jar. Hosted and daily-driver servers use plain hosts and the +stable `t3_session` cookie name. ### Bearer Access Token diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index fec4203c5334..999110bd669e 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -18,13 +18,24 @@ describe("newPreviewTabId", () => { }); describe("isLoopbackHost", () => { - it.each(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"])("%s is loopback", (host) => { + it.each([ + "localhost", + "feature-worktree.localhost", + "FEATURE-WORKTREE.LOCALHOST", + "127.0.0.1", + "0.0.0.0", + "::1", + "[::1]", + ])("%s is loopback", (host) => { expect(isLoopbackHost(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); - }); + it.each(["example.com", "localhost.example.com", "192.168.1.10", "10.0.0.1", ""])( + "%s is not loopback", + (host) => { + expect(isLoopbackHost(host)).toBe(false); + }, + ); }); describe("isPreviewableUrl", () => { diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index 926b30966e52..3958572b8eb3 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -31,8 +31,9 @@ export const LSOF_LOCAL_HOST_TOKENS: ReadonlySet = new Set([ const LOOPBACK_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; - if (host === "[::1]") return true; + const normalizedHost = host.toLowerCase(); + if (LOOPBACK_HOSTS.has(normalizedHost)) return true; + if (normalizedHost === "[::1]" || normalizedHost.endsWith(".localhost")) return true; return false; } diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 9b4f44475d95..80d3bffc02b6 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -26,6 +26,7 @@ import { findFirstAvailableOffset, getDevRunnerModeArgs, isBrowserAllowedPort, + resolveDevHostSlug, resolveModePortOffsets, resolveOffset, runDevRunnerWithInput, @@ -137,7 +138,47 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { ); }); + describe("resolveDevHostSlug", () => { + it.each([ + [ + "/home/adam/Code/t3code-worktrees/t3code-fork-migration-ledger", + "t3code-fork-migration-ledger", + ], + ["/home/adam/Code/t3code", "t3code"], + ["~/.t3/userdata/worktrees/t3code/Fix_Thing", "fix-thing"], + ["/tmp/---", "dev"], + ["C:\\Code\\T3 Code\\", "t3-code"], + ])("derives %s as %s", (directoryPath, expected) => { + assert.equal(resolveDevHostSlug(directoryPath), expected); + }); + + it("caps the hostname label at 63 characters", () => { + assert.equal(resolveDevHostSlug(`/tmp/${"A".repeat(70)}`), "a".repeat(63)); + }); + }); + describe("createDevRunnerEnv", () => { + it.effect("uses the worktree slug as the default browser dev host", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 4, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + devHostSlug: "feature-worktree", + }); + + assert.equal(env.VITE_DEV_SERVER_URL, "http://feature-worktree.localhost:5737"); + }), + ); + it.effect("leaves the shared home implicit and disables browser auto-open", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ @@ -214,6 +255,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { host: "0.0.0.0", port: 4222, devUrl: new URL("http://localhost:7331"), + devHostSlug: "ignored-worktree", }); assert.equal(env.T3CODE_HOME, path.resolve("/tmp/custom-t3")); @@ -340,6 +382,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { host: "127.0.0.1", port: 4222, devUrl: undefined, + devHostSlug: "ignored-worktree", }); assert.equal(env.T3CODE_HOME, path.resolve("/tmp/my-t3")); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index d426cc7829b1..809f977c0f26 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -277,6 +277,21 @@ export function resolveOffset(config: { return Effect.succeed({ offset: 0, source: "default ports" }); } +export function resolveDevHostSlug(directoryPath: string): string { + const basename = + directoryPath + .replace(/[\\/]+$/, "") + .split(/[\\/]/) + .at(-1) ?? ""; + const slug = basename + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 63) + .replace(/-+$/, ""); + return slug || "dev"; +} + function resolveBaseDir(baseDir: string | undefined): Effect.Effect { return Effect.gen(function* () { const path = yield* Path.Path; @@ -302,6 +317,7 @@ interface CreateDevRunnerEnvInput { readonly host: string | undefined; readonly port: number | undefined; readonly devUrl: URL | undefined; + readonly devHostSlug?: string | undefined; } export function createDevRunnerEnv({ @@ -316,6 +332,7 @@ export function createDevRunnerEnv({ host, port, devUrl, + devHostSlug = "dev", }: CreateDevRunnerEnvInput): Effect.Effect { return Effect.gen(function* () { const serverPort = port ?? BASE_SERVER_PORT + serverOffset; @@ -331,7 +348,7 @@ export function createDevRunnerEnv({ PORT: String(webPort), VITE_DEV_SERVER_URL: devUrl?.toString() ?? - `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, + `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : `${devHostSlug}.localhost`}:${webPort}`, }; if (configuredBaseDir !== undefined) { @@ -655,7 +672,8 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { return yield* new DevRunnerHostNotProxiableError({ mode: input.mode, host: input.host }); } - const worktreePath = yield* resolveGitWorktreePath(yield* HostProcessWorkingDirectory); + const workingDirectory = yield* HostProcessWorkingDirectory; + const worktreePath = yield* resolveGitWorktreePath(workingDirectory); const { offset, source } = yield* resolveOffset({ portOffset, @@ -677,7 +695,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { // A dev server started inside a worktree defaults to that worktree's own // (gitignored) `.t3` — see @t3tools/shared/devHome for why this must // outrank an ambient T3CODE_HOME. `--home-dir` still wins. - const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcessWorkingDirectory); + const worktreeHome = yield* resolveWorktreeT3Home(workingDirectory); // Trim before choosing: `--home-dir ""` is not a selection, and treating it // as one would skip the worktree default and land on the shared home — // exactly the outcome this precedence exists to prevent. @@ -697,6 +715,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { host: input.host, port: input.port, devUrl: input.devUrl, + devHostSlug: resolveDevHostSlug(worktreePath ?? workingDirectory), }); const selectionSuffix = @@ -706,7 +725,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { const baseDir = env.T3CODE_HOME ?? (yield* DEFAULT_T3_HOME); yield* Effect.logInfo( - `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, + `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} devUrl=${env.VITE_DEV_SERVER_URL ?? "unset"} baseDir=${baseDir}`, ); // Before the share block: --dry-run only resolves and prints. Sharing would