From 9e4ae6f1e9d79c8a4699e98ba85de5b09c2d8a8d Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Tue, 25 Aug 2026 14:12:31 -0700 Subject: [PATCH 1/7] fix(tailscale): preserve occupied serve handlers --- apps/server/src/server.ts | 6 +- packages/tailscale/src/tailscale.test.ts | 105 ++++++++++++++- packages/tailscale/src/tailscale.ts | 156 ++++++++++++++++++++--- scripts/lib/dev-share.test.ts | 92 +++++++------ scripts/lib/dev-share.ts | 49 ++----- 5 files changed, 311 insertions(+), 97 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d5bebe3d5000..8ff2dfc5c53e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -569,7 +569,11 @@ export const makeServerLayer = Layer.unwrap( }), (configured) => configured - ? disableTailscaleServe({ servePort: configured.servePort }).pipe( + ? disableTailscaleServe({ + localPort: configured.localPort, + servePort: configured.servePort, + localHost: "127.0.0.1", + }).pipe( Effect.tap(() => Effect.logInfo("Tailscale Serve disabled", { servePort: configured.servePort, diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 09d9def21066..7957b648d600 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -21,6 +21,7 @@ import { TailscaleCommandExitError, TailscaleCommandSpawnError, TailscaleCommandTimeoutError, + TailscaleServePortOccupiedError, TailscaleStatusParseError, } from "./tailscale.ts"; @@ -65,6 +66,14 @@ function assertCarriesNoSecret(error: object, secret: string): void { } const tailscaleStatusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100","fd7a:115c:a1e0::1","192.168.1.20"]}}`; const tailscaleStatusWithSingleIpJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.90.1.2"]}}`; +const emptyServeStatusJson = `{"TCP":{},"Web":{}}`; +const serveStatusJson = (servePort: number, proxy: string) => + JSON.stringify({ + TCP: { [servePort]: { HTTPS: true } }, + Web: { + [`desktop.tail.ts.net:${String(servePort)}`]: { Handlers: { "/": { Proxy: proxy } } }, + }, + }); function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) { return ChildProcessSpawner.makeHandle({ @@ -320,21 +329,81 @@ describe("tailscale", () => { }); it.effect("configures tailscale serve through the process spawner service", () => { + const commands: ReadonlyArray[] = []; const layer = mockSpawnerLayer((command, args) => { assert.equal(command, "tailscale"); - assert.deepEqual(args, ["serve", "--bg", "--https=8443", "http://127.0.0.1:13773"]); + commands.push(args); + if (args[1] === "status") { + return { stdout: emptyServeStatusJson }; + } return {}; }); - return ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(Effect.provide(layer)); + return Effect.gen(function* () { + yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( + Effect.provide(layer), + ); + assert.deepEqual(commands, [ + ["serve", "status", "--json"], + ["serve", "--bg", "--https=8443", "http://127.0.0.1:13773"], + ]); + }); }); - it.effect("retains tailscale serve exit diagnostics", () => { + it.effect("reuses only the exact existing handler", () => { + const commands: ReadonlyArray[] = []; + const layer = mockSpawnerLayer((_command, args) => { + commands.push(args); + return { stdout: serveStatusJson(8443, "http://127.0.0.1:13773") }; + }); + + return Effect.gen(function* () { + yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( + Effect.provide(layer), + ); + assert.deepEqual(commands, [["serve", "status", "--json"]]); + }); + }); + + it.effect("refuses to replace a configured port even when its backend differs", () => { const layer = mockSpawnerLayer(() => ({ - code: 1, - stderr: "serve permission denied tskey-auth-secret-token-value", + stdout: serveStatusJson(8443, "http://127.0.0.1:39831"), })); + return Effect.gen(function* () { + const error = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( + Effect.flip, + Effect.provide(layer), + ); + assert.instanceOf(error, TailscaleServePortOccupiedError); + assert.equal(error.servePort, 8443); + }); + }); + + it.effect("treats a non-web listener on the selected port as occupied", () => { + const layer = mockSpawnerLayer(() => ({ + stdout: JSON.stringify({ TCP: { 8443: { TCPForward: "127.0.0.1:39831" } }, Web: {} }), + })); + + return Effect.gen(function* () { + const error = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( + Effect.flip, + Effect.provide(layer), + ); + assert.instanceOf(error, TailscaleServePortOccupiedError); + }); + }); + + it.effect("retains tailscale serve exit diagnostics", () => { + const layer = mockSpawnerLayer((_command, args) => + args[1] === "status" + ? { stdout: emptyServeStatusJson } + : { + code: 1, + stderr: "serve permission denied tskey-auth-secret-token-value", + }, + ); + return Effect.gen(function* () { const error = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( Effect.flip, @@ -365,15 +434,37 @@ describe("tailscale", () => { const layer = mockSpawnerLayer((command, args) => { commands.push({ command, args }); assert.equal(command, "tailscale"); - assert.deepEqual(args, ["serve", "--https=8443", "off"]); + if (args[1] === "status") { + return { stdout: serveStatusJson(8443, "http://127.0.0.1:13773") }; + } return {}; }); return Effect.gen(function* () { - yield* disableTailscaleServe({ servePort: 8443 }).pipe(Effect.provide(layer)); + yield* disableTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( + Effect.provide(layer), + ); assert.deepEqual(commands, [ + { command: "tailscale", args: ["serve", "status", "--json"] }, { command: "tailscale", args: ["serve", "--https=8443", "off"] }, ]); }); }); + + it.effect("refuses to disable a handler owned by another service", () => { + const commands: ReadonlyArray[] = []; + const layer = mockSpawnerLayer((_command, args) => { + commands.push(args); + return { stdout: serveStatusJson(8443, "http://127.0.0.1:39831") }; + }); + + return Effect.gen(function* () { + const error = yield* disableTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( + Effect.flip, + Effect.provide(layer), + ); + assert.equal(error._tag, "TailscaleServePortOccupiedError"); + assert.deepEqual(commands, [["serve", "status", "--json"]]); + }); + }); }); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index fedde02ee76f..6813aa5499c6 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -128,6 +128,22 @@ export class TailscaleStatusParseError extends Schema.TaggedErrorClass()( + "TailscaleServePortOccupiedError", + { servePort: Schema.Number }, +) { + override get message(): string { + return `Tailscale Serve port ${this.servePort} is already configured by another handler.`; + } +} + +export const TailscaleServeError = Schema.Union([ + TailscaleCommandError, + TailscaleStatusParseError, + TailscaleServePortOccupiedError, +]); +export type TailscaleServeError = typeof TailscaleServeError.Type; + const TailscaleStatusSelf = Schema.Struct({ DNSName: Schema.optional(Schema.Unknown), TailscaleIPs: Schema.optional(Schema.Unknown), @@ -137,6 +153,28 @@ const TailscaleStatusJson = Schema.Struct({ Self: Schema.optional(TailscaleStatusSelf), }); +const TailscaleServeStatusJson = Schema.Struct({ + TCP: Schema.optional( + Schema.Record( + Schema.String, + Schema.Struct({ + HTTPS: Schema.optional(Schema.Unknown), + Funnel: Schema.optional(Schema.Unknown), + }), + ), + ), + Web: Schema.optional( + Schema.Record( + Schema.String, + Schema.Struct({ + Handlers: Schema.Record(Schema.String, Schema.Unknown), + }), + ), + ), +}); + +type TailscaleServeStatusJson = typeof TailscaleServeStatusJson.Type; + export type TailscaleStatusSelf = typeof TailscaleStatusSelf.Type; export type TailscaleStatusJson = typeof TailscaleStatusJson.Type; @@ -157,6 +195,9 @@ const collectStdout = (stream: Stream.Stream): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; @@ -309,8 +350,12 @@ const runTailscaleCommand = ( Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), ), ); - const [stderr, exitCode] = yield* Effect.all( - [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStdout(child.stdout), + collectStderr(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], { concurrency: "unbounded" }, ).pipe( Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), @@ -319,12 +364,14 @@ const runTailscaleCommand = ( return yield* new TailscaleCommandExitError({ ...commandContext, exitCode, + stdoutLength: stdout.length, stderrLength: stderr.length, ...(stderrDiagnosticOf(stderr) !== undefined ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } : {}), }); } + return stdout; }).pipe( Effect.scoped, Effect.timeout(timeout), @@ -341,28 +388,107 @@ const runTailscaleCommand = ( ); }); +type TailscaleServePortState = "absent" | "exact" | "occupied"; + +function authorityPort(authority: string): number | null { + try { + const url = new URL(`https://${authority}`); + return url.port.length === 0 ? DEFAULT_TAILSCALE_SERVE_PORT : Number(url.port); + } catch { + return null; + } +} + +function servePortState( + status: TailscaleServeStatusJson, + input: { readonly servePort: number; readonly proxy: string }, +): TailscaleServePortState { + const tcpEntries = Object.entries(status.TCP ?? {}).filter( + ([port]) => Number(port) === input.servePort, + ); + const entries = Object.entries(status.Web ?? {}).filter( + ([authority]) => authorityPort(authority) === input.servePort, + ); + if (entries.length === 0 && tcpEntries.length === 0) { + return "absent"; + } + if (entries.length !== 1 || tcpEntries.length !== 1) { + return "occupied"; + } + const tcp = tcpEntries[0]?.[1]; + if (tcp?.HTTPS !== true || tcp.Funnel === true) { + return "occupied"; + } + + const handlers = entries[0]?.[1].Handlers; + if (handlers === undefined || Object.keys(handlers).length !== 1) { + return "occupied"; + } + const root = handlers["/"]; + if (typeof root !== "object" || root === null || Array.isArray(root)) { + return "occupied"; + } + const proxy = Reflect.get(root, "Proxy"); + return proxy === input.proxy ? "exact" : "occupied"; +} + +const readTailscaleServePortState = (input: { + readonly servePort: number; + readonly proxy: string; +}): Effect.Effect< + TailscaleServePortState, + TailscaleCommandError | TailscaleStatusParseError, + ChildProcessSpawner.ChildProcessSpawner +> => + runTailscaleCommand(["serve", "status", "--json"], TAILSCALE_STATUS_TIMEOUT).pipe( + Effect.flatMap((stdout) => + decodeTailscaleServeStatusJson(stdout).pipe( + Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), + ), + ), + Effect.map((status) => servePortState(status, input)), + ); + export const ensureTailscaleServe = (input: { readonly localPort: number; readonly servePort?: number; readonly localHost?: string; -}): Effect.Effect => { +}): Effect.Effect => { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; const localHost = input.localHost ?? "127.0.0.1"; - const args = ["serve", "--bg", `--https=${servePort}`, `http://${localHost}:${input.localPort}`]; - return runTailscaleCommand(args, TAILSCALE_SERVE_TIMEOUT); + const proxy = `http://${localHost}:${input.localPort}`; + return Effect.gen(function* () { + const state = yield* readTailscaleServePortState({ servePort, proxy }); + if (state === "exact") { + return; + } + if (state === "occupied") { + return yield* new TailscaleServePortOccupiedError({ servePort }); + } + yield* runTailscaleCommand( + ["serve", "--bg", `--https=${servePort}`, proxy], + TAILSCALE_SERVE_TIMEOUT, + ); + }); }; -export const disableTailscaleServe = ( - input: { - readonly servePort?: number; - } = {}, -): Effect.Effect => +export const disableTailscaleServe = (input: { + readonly servePort?: number; + readonly localPort: number; + readonly localHost?: string; +}): Effect.Effect => Effect.gen(function* () { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; - return yield* runTailscaleCommand( - ["serve", `--https=${servePort}`, "off"], - TAILSCALE_SERVE_TIMEOUT, - ); + const localHost = input.localHost ?? "127.0.0.1"; + const proxy = `http://${localHost}:${input.localPort}`; + const state = yield* readTailscaleServePortState({ servePort, proxy }); + if (state === "absent") { + return; + } + if (state === "occupied") { + return yield* new TailscaleServePortOccupiedError({ servePort }); + } + yield* runTailscaleCommand(["serve", `--https=${servePort}`, "off"], TAILSCALE_SERVE_TIMEOUT); }); export const probeTailscaleHttpsEndpoint = (input: { diff --git a/scripts/lib/dev-share.test.ts b/scripts/lib/dev-share.test.ts index b2dfba3585eb..c6233681b8d1 100644 --- a/scripts/lib/dev-share.test.ts +++ b/scripts/lib/dev-share.test.ts @@ -13,7 +13,12 @@ import { } from "./dev-share.ts"; const TAILNET_STATUS = JSON.stringify({ Self: { DNSName: "host.example.ts.net." } }); -const NO_HANDLER_STDERR = "error: failed to remove web serve: handler does not exist"; +const EMPTY_SERVE_STATUS = JSON.stringify({ TCP: {}, Web: {} }); +const serveStatus = (proxy: string) => + JSON.stringify({ + TCP: { 5788: { HTTPS: true } }, + Web: { "host.example.ts.net:5788": { Handlers: { "/": { Proxy: proxy } } } }, + }); interface CallResult { readonly exitCode: number; @@ -23,20 +28,26 @@ interface CallResult { const encode = (value: string) => Stream.make(new TextEncoder().encode(value)); /** - * Answers `tailscale status --json` with a valid tailnet name, and lets each - * test set the outcome of the `off` (pre-clear) and `serve` calls separately — - * they are the same subcommand and are told apart by the trailing `off`. + * Answers the two status commands independently and lets each test set the + * outcome of the mutating `off` and `serve` calls. */ -const spawnerLayer = (input: { readonly off?: CallResult; readonly serve?: CallResult }) => +const spawnerLayer = (input: { + readonly off?: CallResult; + readonly serve?: CallResult; + readonly serveStatus?: string; +}) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { const args = "args" in command ? (command.args as ReadonlyArray) : []; - const result: CallResult = args.includes("status") - ? { exitCode: 0 } - : args.includes("off") - ? (input.off ?? { exitCode: 0 }) - : (input.serve ?? { exitCode: 0 }); + const isTailnetStatus = args[0] === "status"; + const isServeStatus = args[0] === "serve" && args[1] === "status"; + const result: CallResult = + isTailnetStatus || isServeStatus + ? { exitCode: 0 } + : args.includes("off") + ? (input.off ?? { exitCode: 0 }) + : (input.serve ?? { exitCode: 0 }); return Effect.succeed( ChildProcessSpawner.makeHandle({ @@ -46,7 +57,11 @@ const spawnerLayer = (input: { readonly off?: CallResult; readonly serve?: CallR kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: args.includes("status") ? encode(TAILNET_STATUS) : Stream.empty, + stdout: isTailnetStatus + ? encode(TAILNET_STATUS) + : isServeStatus + ? encode(input.serveStatus ?? EMPTY_SERVE_STATUS) + : Stream.empty, stderr: result.stderr ? encode(result.stderr) : Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, @@ -60,19 +75,20 @@ describe("unshareDevServer", () => { it.effect("treats a removed mapping as cleared", () => Effect.gen(function* () { const result = yield* unshareDevServer(5788).pipe( - Effect.provide(spawnerLayer({ off: { exitCode: 0 } })), + Effect.provide( + spawnerLayer({ + off: { exitCode: 0 }, + serveStatus: serveStatus("http://127.0.0.1:5788"), + }), + ), ); assert.isTrue(result.cleared); }), ); - // `tailscale serve … off` exits 1 when the port had no mapping, which is the - // normal first-share case — the port is clear, so this must not be an error. it.effect("treats a missing handler as cleared", () => Effect.gen(function* () { - const result = yield* unshareDevServer(5788).pipe( - Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: NO_HANDLER_STDERR } })), - ); + const result = yield* unshareDevServer(5788).pipe(Effect.provide(spawnerLayer({}))); assert.isTrue(result.cleared); }), ); @@ -80,7 +96,12 @@ describe("unshareDevServer", () => { it.effect("reports a genuine removal failure as not cleared", () => Effect.gen(function* () { const result = yield* unshareDevServer(5788).pipe( - Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: "permission denied" } })), + Effect.provide( + spawnerLayer({ + off: { exitCode: 1, stderr: "permission denied" }, + serveStatus: serveStatus("http://127.0.0.1:5788"), + }), + ), ); assert.isFalse(result.cleared); assert.include(result.explanation, "permission denied"); @@ -88,13 +109,24 @@ describe("unshareDevServer", () => { assert.equal(result.cause?._tag, "TailscaleCommandExitError"); }), ); + + it.effect("does not remove another service's handler", () => + Effect.gen(function* () { + const result = yield* unshareDevServer(5788).pipe( + Effect.provide(spawnerLayer({ serveStatus: serveStatus("http://127.0.0.1:39831") })), + ); + assert.isFalse(result.cleared); + assert.include(result.explanation, "different Tailscale Serve handler"); + assert.equal(result.cause?._tag, "TailscaleServePortOccupiedError"); + }), + ); }); describe("shareDevServer", () => { it.effect("returns the tailnet URL for the same port", () => Effect.gen(function* () { const shared = yield* shareDevServer({ webPort: 5788 }).pipe( - Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: NO_HANDLER_STDERR } })), + Effect.provide(spawnerLayer({})), ); assert.equal(shared.host, "host.example.ts.net"); @@ -102,15 +134,11 @@ describe("shareDevServer", () => { }), ); - // The stale-mapping clear runs before serve, so a failure here leaves the - // port serving nothing. Saying only "serve failed" would let an operator - // assume their previous mapping survived. - it.effect("reports that the prior mapping was cleared when serve fails", () => + it.effect("reports a serve failure without claiming another mapping was removed", () => Effect.gen(function* () { const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( Effect.provide( spawnerLayer({ - off: { exitCode: 0 }, serve: { exitCode: 1, stderr: "port already in use" }, }), ), @@ -118,7 +146,6 @@ describe("shareDevServer", () => { ); assert.instanceOf(error, DevServeFailedError); - assert.equal(error.stage, "serve"); assert.equal(error.webPort, 5788); // The underlying failure is preserved rather than flattened to a string. assert.equal( @@ -129,7 +156,7 @@ describe("shareDevServer", () => { // message points at the command instead of echoing the CLI. assert.notInclude(error.message, "port already in use"); assert.include(error.message, "run the command by hand"); - assert.include(error.message, "no longer served"); + assert.notInclude(error.message, "cleared"); assert.include(error.message, "5788"); }), ); @@ -141,7 +168,6 @@ describe("shareDevServer", () => { const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( Effect.provide( spawnerLayer({ - off: { exitCode: 0 }, serve: { exitCode: 1, stderr: "permission denied for tskey-auth-secret-token-value", @@ -152,27 +178,21 @@ describe("shareDevServer", () => { ); assert.instanceOf(error, DevServeFailedError); - assert.equal(error.stage, "serve"); assert.include(error.message, "permission denied"); assert.include(error.message, "elevated privileges"); assert.notInclude(error.message, "tskey-auth-secret-token-value"); }), ); - // Serving over routes we could not remove yields a URL that loads but whose - // /ws and /api quietly point at a dead backend. - it.effect("refuses to serve when the existing mapping could not be cleared", () => + it.effect("refuses to replace an occupied Serve port", () => Effect.gen(function* () { const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( - Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: "permission denied" } })), + Effect.provide(spawnerLayer({ serveStatus: serveStatus("http://127.0.0.1:39831") })), Effect.flip, ); assert.instanceOf(error, DevServeFailedError); - // A distinct stage: the prior mapping survived, so nothing was replaced. - assert.equal(error.stage, "clear-existing"); - assert.include(error.message, "could not clear the existing mapping"); - assert.include(error.message, "permission denied"); + assert.include(error.message, "different Tailscale Serve handler"); }), ); }); diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts index 0f843b3ba91e..d9f968a5ea21 100644 --- a/scripts/lib/dev-share.ts +++ b/scripts/lib/dev-share.ts @@ -4,9 +4,8 @@ * work. * * Thin wrapper over `@t3tools/tailscale` (the same client the server's own - * `--tailscale-serve` uses). What it adds is dev-share semantics: replacing a - * stale mapping left by a killed run, and refusing to serve over routes it - * could not remove. + * `--tailscale-serve` uses). What it adds is dev-share error reporting and + * lifecycle cleanup for the exact mapping this dev server owns. * * Because browser dev is single-origin (Vite proxies the backend — see * `resolveDevProxyTarget` in apps/web/vite.config.ts), one proxy rule covering @@ -18,7 +17,7 @@ import { disableTailscaleServe, ensureTailscaleServe, readTailscaleStatus, - type TailscaleCommandError, + type TailscaleServeError, type TailscaleStderrDiagnostic, } from "@t3tools/tailscale"; import * as Effect from "effect/Effect"; @@ -41,10 +40,12 @@ const DIAGNOSTIC_EXPLANATIONS: Record +const explainCommandFailure = (error: TailscaleServeError): string | undefined => error._tag === "TailscaleCommandExitError" && error.stderrDiagnostic !== undefined ? (DIAGNOSTIC_EXPLANATIONS[error.stderrDiagnostic] ?? "run the command by hand to see why") - : undefined; + : error._tag === "TailscaleServePortOccupiedError" + ? "the port already belongs to a different Tailscale Serve handler" + : undefined; /** * Three distinct failures, three classes: each has its own caller-visible @@ -82,15 +83,9 @@ export class TailnetNameMissingError extends Schema.TaggedErrorClass()( "DevServeFailedError", { - stage: Schema.Literals(["clear-existing", "serve"]), webPort: Schema.Number, explanation: Schema.optional(Schema.String), cause: Schema.optional(Schema.Defect()), @@ -98,10 +93,7 @@ export class DevServeFailedError extends Schema.TaggedErrorClass => - disableTailscaleServe({ servePort: webPort }).pipe( + disableTailscaleServe({ localPort: webPort, servePort: webPort }).pipe( Effect.as({ cleared: true } as const), - Effect.catch((error: TailscaleCommandError) => + Effect.catch((error: TailscaleServeError) => Effect.succeed( // "Nothing was mapped" leaves the port clear either way. error._tag === "TailscaleCommandExitError" && @@ -177,29 +169,10 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in return yield* new TailnetNameMissingError(); } - // Clear any mapping left behind by a run that was killed before its finalizer - // could fire. Serve config survives both the process and a reboot, and a - // stale entry may carry path routes we no longer want — older versions mapped - // /ws, /api and friends to a separate backend port, and serving "/" alone - // would leave those pointing at a port nothing is listening on. - const cleared = yield* unshareDevServer(input.webPort); - if (!cleared.cleared) { - // Serving over routes we failed to remove would hand out a URL that is - // broken in a way the user cannot see: the page loads while /ws and /api - // silently resolve to a dead backend. Better to refuse and say why. - return yield* new DevServeFailedError({ - stage: "clear-existing", - webPort: input.webPort, - ...(cleared.explanation !== undefined ? { explanation: cleared.explanation } : {}), - ...(cleared.cause !== undefined ? { cause: cleared.cause } : {}), - }); - } - yield* ensureTailscaleServe({ localPort: input.webPort, servePort: input.webPort }).pipe( Effect.mapError((error) => { const explanation = explainCommandFailure(error); return new DevServeFailedError({ - stage: "serve", webPort: input.webPort, ...(explanation !== undefined ? { explanation } : {}), cause: error, From b390bfbd75b53d477e52f8f210686067197ecf96 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Tue, 25 Aug 2026 14:15:53 -0700 Subject: [PATCH 2/7] docs(tailscale): explain serve port ownership --- docs/internals/remote.md | 6 +++++- docs/user/remote-access.md | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/internals/remote.md b/docs/internals/remote.md index afce95f725bc..448a4e63e8c6 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -153,7 +153,11 @@ Worker itself. See [t3-connect.md](./t3-connect.md). A T3-managed `tailscale serve` mapping exposes the server on the tailnet over HTTPS, and the resulting private-network endpoints are advertised for pairing. Connection then follows the ordinary -bearer path. +bearer path. Serve configuration, rather than backend health, determines port ownership: T3 reuses +only an exact root handler for its loopback target, refuses to replace any other configured handler, +and removes a handler only while it still exactly matches the target being stopped. This lets other +applications keep additive handlers on separate HTTPS ports, including while their backends are +temporarily unavailable. ### Desktop-managed SSH access diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 5993fca5b352..f58dcf99cb04 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -18,7 +18,7 @@ If the server is only bound to loopback, the printed URL is not reachable from a npx t3 pair --tailscale ``` -This publishes the server over Tailscale Serve HTTPS (configuring the mapping if needed — it persists until you run `tailscale serve --https=443 off`) and pairs through the `https://machine.tailnet.ts.net/` URL. Use `--tailscale-serve-port` for a different HTTPS port, `--ttl` to change the token lifetime, and `--base-dir` to target a specific data directory. +This publishes the server over Tailscale Serve HTTPS (configuring the mapping if needed — it persists until you run `tailscale serve --https=443 off`) and pairs through the `https://machine.tailnet.ts.net/` URL. Use `--tailscale-serve-port` for a different HTTPS port, `--ttl` to change the token lifetime, and `--base-dir` to target a specific data directory. If that HTTPS port already has a different Serve handler, T3 leaves it unchanged and asks you to choose another port; an unavailable backend does not make an occupied port safe to reuse. If no server is running, `t3 pair` says so and points you at `npx t3 serve` or `npx t3 connect`. From 858e5eed8675b2025ec39118c7c9ae23edb7a4ad Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Wed, 26 Aug 2026 13:45:50 -0700 Subject: [PATCH 3/7] fix(dev): align shared Vite loopback --- apps/web/vite.config.ts | 5 +++-- packages/shared/src/devProxy.test.ts | 29 ++++++++++++++++++++++++++++ packages/shared/src/devProxy.ts | 9 +++++++++ scripts/dev-runner.test.ts | 2 ++ scripts/dev-runner.ts | 10 ++++++++++ scripts/lib/dev-share.ts | 13 +++++++++++-- 6 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 packages/shared/src/devProxy.test.ts diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 6ee5de587b9e..a3c42540e7f2 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,7 +10,7 @@ import "vite-plus/test/config"; import { defineConfig, type Connect, type Plugin } from "vite-plus"; import pkg from "./package.json" with { type: "json" }; -import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; +import { DEV_PROXIED_PATH_PREFIXES, resolveWebDevServerHost } from "@t3tools/shared/devProxy"; import { loadRepoEnv } from "../../scripts/lib/public-config"; @@ -28,7 +28,8 @@ const isSingleOriginDev = process.env.T3CODE_SINGLE_ORIGIN_DEV === "1"; const port = Number(process.env.PORT ?? 5733); const explicitHost = process.env.HOST?.trim(); -const host = explicitHost || "localhost"; +const sharedBindHost = process.env.T3CODE_WEB_BIND_HOST?.trim(); +const host = resolveWebDevServerHost({ explicitHost, sharedBindHost }); const configuredWsUrl = isSingleOriginDev ? undefined : process.env.VITE_WS_URL?.trim(); const configuredHttpUrl = isSingleOriginDev ? undefined : process.env.VITE_HTTP_URL?.trim(); const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; diff --git a/packages/shared/src/devProxy.test.ts b/packages/shared/src/devProxy.test.ts new file mode 100644 index 000000000000..4713f9360440 --- /dev/null +++ b/packages/shared/src/devProxy.test.ts @@ -0,0 +1,29 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { resolveWebDevServerHost, SHARED_DEV_LOOPBACK_HOST } from "./devProxy.ts"; + +describe("resolveWebDevServerHost", () => { + it("uses the same IPv4 loopback selected for Tailscale sharing", () => { + assert.equal( + resolveWebDevServerHost({ + explicitHost: undefined, + sharedBindHost: SHARED_DEV_LOOPBACK_HOST, + }), + "127.0.0.1", + ); + }); + + it("keeps ordinary local development and explicit desktop hosts unchanged", () => { + assert.equal( + resolveWebDevServerHost({ explicitHost: undefined, sharedBindHost: undefined }), + "localhost", + ); + assert.equal( + resolveWebDevServerHost({ + explicitHost: "192.0.2.10", + sharedBindHost: SHARED_DEV_LOOPBACK_HOST, + }), + "192.0.2.10", + ); + }); +}); diff --git a/packages/shared/src/devProxy.ts b/packages/shared/src/devProxy.ts index 13336cb48a1e..3b6d2c4dbdae 100644 --- a/packages/shared/src/devProxy.ts +++ b/packages/shared/src/devProxy.ts @@ -10,6 +10,15 @@ */ export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws"] as const; +export const SHARED_DEV_LOOPBACK_HOST = "127.0.0.1"; + +export function resolveWebDevServerHost(input: { + readonly explicitHost: string | undefined; + readonly sharedBindHost: string | undefined; +}): string { + return input.explicitHost || input.sharedBindHost || "localhost"; +} + export function isDevProxiedPath(pathname: string): boolean { return DEV_PROXIED_PATH_PREFIXES.some( (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 9b4f44475d95..027fc4e38413 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1080,6 +1080,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { Effect.gen(function* () { const env = yield* shareSpawnedEnv({ ambientBundledDev: undefined }); assert.equal(env?.T3CODE_BUNDLED_DEV, "1"); + assert.equal(env?.T3CODE_WEB_BIND_HOST, "127.0.0.1"); }), ); @@ -1116,6 +1117,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { ); assert.equal(captured?.T3CODE_BUNDLED_DEV, undefined); + assert.equal(captured?.T3CODE_WEB_BIND_HOST, ""); }), ); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index d426cc7829b1..37a762313135 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -5,6 +5,7 @@ import * as NodeOS from "node:os"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; +import { SHARED_DEV_LOOPBACK_HOST } from "@t3tools/shared/devProxy"; import { resolveGitWorktreePath, resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -333,6 +334,10 @@ export function createDevRunnerEnv({ devUrl?.toString() ?? `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, }; + // Internal runner-to-Vite setting. An explicit empty value prevents a + // repo env file from changing ordinary dev binding; --share replaces it + // only after the exact Tailscale mapping has been acquired. + output.T3CODE_WEB_BIND_HOST = ""; if (configuredBaseDir !== undefined) { output.T3CODE_HOME = resolvedBaseDir; @@ -773,6 +778,11 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { ); if (shared) { + // Tailscale proxies to IPv4 loopback. Bind Vite to that exact same + // address instead of `localhost`, whose address family varies by + // host. This is separate from HOST so remote HMR still derives its + // endpoint from the browser's shared origin. + env.T3CODE_WEB_BIND_HOST = SHARED_DEV_LOOPBACK_HOST; // The app is reached from the tailnet origin. Vite already allows // *.ts.net hosts; the backend needs the origin for credentialed // requests that bypass the proxy (desktop renderer, direct calls). diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts index d9f968a5ea21..560d3b257164 100644 --- a/scripts/lib/dev-share.ts +++ b/scripts/lib/dev-share.ts @@ -20,6 +20,7 @@ import { type TailscaleServeError, type TailscaleStderrDiagnostic, } from "@t3tools/tailscale"; +import { SHARED_DEV_LOOPBACK_HOST } from "@t3tools/shared/devProxy"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import type { ChildProcessSpawner } from "effect/unstable/process"; @@ -130,7 +131,11 @@ export const unshareDevServer = ( never, ChildProcessSpawner.ChildProcessSpawner > => - disableTailscaleServe({ localPort: webPort, servePort: webPort }).pipe( + disableTailscaleServe({ + localHost: SHARED_DEV_LOOPBACK_HOST, + localPort: webPort, + servePort: webPort, + }).pipe( Effect.as({ cleared: true } as const), Effect.catch((error: TailscaleServeError) => Effect.succeed( @@ -169,7 +174,11 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in return yield* new TailnetNameMissingError(); } - yield* ensureTailscaleServe({ localPort: input.webPort, servePort: input.webPort }).pipe( + yield* ensureTailscaleServe({ + localHost: SHARED_DEV_LOOPBACK_HOST, + localPort: input.webPort, + servePort: input.webPort, + }).pipe( Effect.mapError((error) => { const explanation = explainCommandFailure(error); return new DevServeFailedError({ From 6f6d04d5f4cc5b2679ff6fc3632735d3be731e11 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Wed, 26 Aug 2026 17:40:01 -0700 Subject: [PATCH 4/7] fix(tailscale): handle existing serve states --- apps/server/src/cli/pair.ts | 10 +++++-- docs/internals/remote.md | 5 ++-- packages/tailscale/src/tailscale.test.ts | 38 ++++++++++++++++++++++++ packages/tailscale/src/tailscale.ts | 8 +++-- 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 38fa3be8bb57..e9ebd0f02e89 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -20,6 +20,7 @@ import { DEFAULT_TAILSCALE_SERVE_PORT, ensureTailscaleServe, readTailscaleStatus, + type TailscaleServeError, } from "@t3tools/tailscale"; import * as Config from "effect/Config"; import * as Console from "effect/Console"; @@ -369,6 +370,7 @@ const awaitEnvironmentDescriptor = Effect.fn(function* (baseUrl: string) { const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase")( function* (input: { readonly target: DiscoveredPairTarget; readonly servePort: number }) { const notes: Array = []; + let replaceVerifiedHandler = false; const status = yield* readTailscaleStatus.pipe( Effect.mapError((cause) => new TailscaleUnavailableError({ cause })), ); @@ -396,6 +398,7 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase" if (input.target.state.devUrl === undefined) { return { baseUrl, notes }; } + replaceVerifiedHandler = true; } if (existing._tag === "not-a-t3-server") { return yield* new ServePortOccupiedError({ servePort: input.servePort }); @@ -408,10 +411,13 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase" yield* ensureTailscaleServe({ localPort: localTarget.localPort, servePort: input.servePort, + replaceVerifiedHandler, ...(localTarget.localHost !== undefined ? { localHost: localTarget.localHost } : {}), }).pipe( - Effect.mapError( - (cause) => new TailscaleServeFailedError({ servePort: input.servePort, cause }), + Effect.mapError((cause: TailscaleServeError) => + cause._tag === "TailscaleServePortOccupiedError" + ? new ServePortOccupiedError({ servePort: input.servePort }) + : new TailscaleServeFailedError({ servePort: input.servePort, cause }), ), ); notes.push( diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 448a4e63e8c6..73939616d52b 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -155,9 +155,10 @@ A T3-managed `tailscale serve` mapping exposes the server on the tailnet over HT resulting private-network endpoints are advertised for pairing. Connection then follows the ordinary bearer path. Serve configuration, rather than backend health, determines port ownership: T3 reuses only an exact root handler for its loopback target, refuses to replace any other configured handler, -and removes a handler only while it still exactly matches the target being stopped. This lets other +and checks that a handler still exactly matches the target before requesting removal. This lets other applications keep additive handlers on separate HTTPS ports, including while their backends are -temporarily unavailable. +temporarily unavailable. Applications should not concurrently change the same HTTPS port because +the status check and removal command are separate Tailscale operations. ### Desktop-managed SSH access diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 7957b648d600..618185bd19fb 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -350,6 +350,24 @@ describe("tailscale", () => { }); }); + it.effect("treats a null first-time serve status as unconfigured", () => { + const commands: ReadonlyArray[] = []; + const layer = mockSpawnerLayer((_command, args) => { + commands.push(args); + return args[1] === "status" ? { stdout: "null" } : {}; + }); + + return Effect.gen(function* () { + yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( + Effect.provide(layer), + ); + assert.deepEqual(commands, [ + ["serve", "status", "--json"], + ["serve", "--bg", "--https=8443", "http://127.0.0.1:13773"], + ]); + }); + }); + it.effect("reuses only the exact existing handler", () => { const commands: ReadonlyArray[] = []; const layer = mockSpawnerLayer((_command, args) => { @@ -380,6 +398,26 @@ describe("tailscale", () => { }); }); + it.effect("replaces a handler only when its environment was already verified", () => { + const commands: ReadonlyArray[] = []; + const layer = mockSpawnerLayer((_command, args) => { + commands.push(args); + return { stdout: serveStatusJson(8443, "http://127.0.0.1:13773") }; + }); + + return Effect.gen(function* () { + yield* ensureTailscaleServe({ + localPort: 5733, + servePort: 8443, + replaceVerifiedHandler: true, + }).pipe(Effect.provide(layer)); + assert.deepEqual(commands, [ + ["serve", "status", "--json"], + ["serve", "--bg", "--https=8443", "http://127.0.0.1:5733"], + ]); + }); + }); + it.effect("treats a non-web listener on the selected port as occupied", () => { const layer = mockSpawnerLayer(() => ({ stdout: JSON.stringify({ TCP: { 8443: { TCPForward: "127.0.0.1:39831" } }, Web: {} }), diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 6813aa5499c6..772a4688a2c4 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -196,7 +196,7 @@ const collectStderr = collectStdout; const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson)); const decodeTailscaleServeStatusJson = Schema.decodeEffect( - Schema.fromJsonString(TailscaleServeStatusJson), + Schema.fromJsonString(Schema.NullOr(TailscaleServeStatusJson)), ); function normalizeMagicDnsName(status: TailscaleStatusJson): string | null { @@ -446,13 +446,15 @@ const readTailscaleServePortState = (input: { Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), ), ), - Effect.map((status) => servePortState(status, input)), + Effect.map((status) => (status === null ? "absent" : servePortState(status, input))), ); export const ensureTailscaleServe = (input: { readonly localPort: number; readonly servePort?: number; readonly localHost?: string; + /** The caller has already verified that the current handler fronts this exact T3 environment. */ + readonly replaceVerifiedHandler?: boolean; }): Effect.Effect => { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; const localHost = input.localHost ?? "127.0.0.1"; @@ -462,7 +464,7 @@ export const ensureTailscaleServe = (input: { if (state === "exact") { return; } - if (state === "occupied") { + if (state === "occupied" && input.replaceVerifiedHandler !== true) { return yield* new TailscaleServePortOccupiedError({ servePort }); } yield* runTailscaleCommand( From 7b7bbaf8ae540b0fb172c18480897a4d9c4f214c Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Wed, 26 Aug 2026 17:57:18 -0700 Subject: [PATCH 5/7] fix(tailscale): reject Funnel handlers --- packages/tailscale/src/tailscale.test.ts | 35 ++++++++++++++++++++++++ packages/tailscale/src/tailscale.ts | 7 +++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 618185bd19fb..f3d6b191aa08 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -432,6 +432,41 @@ describe("tailscale", () => { }); }); + it.effect("refuses to reuse or disable a Funnel-enabled handler", () => { + const commands: ReadonlyArray[] = []; + const layer = mockSpawnerLayer((_command, args) => { + commands.push(args); + const status = JSON.parse(serveStatusJson(8443, "http://127.0.0.1:13773")) as Record< + string, + unknown + >; + return { + stdout: JSON.stringify({ + ...status, + AllowFunnel: { "desktop.tail.ts.net:8443": true }, + }), + }; + }); + + return Effect.gen(function* () { + const ensureError = yield* ensureTailscaleServe({ + localPort: 13773, + servePort: 8443, + }).pipe(Effect.flip, Effect.provide(layer)); + assert.instanceOf(ensureError, TailscaleServePortOccupiedError); + + const disableError = yield* disableTailscaleServe({ + localPort: 13773, + servePort: 8443, + }).pipe(Effect.flip, Effect.provide(layer)); + assert.instanceOf(disableError, TailscaleServePortOccupiedError); + assert.deepEqual(commands, [ + ["serve", "status", "--json"], + ["serve", "status", "--json"], + ]); + }); + }); + it.effect("retains tailscale serve exit diagnostics", () => { const layer = mockSpawnerLayer((_command, args) => args[1] === "status" diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 772a4688a2c4..e677b7ce1af1 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -159,7 +159,6 @@ const TailscaleServeStatusJson = Schema.Struct({ Schema.String, Schema.Struct({ HTTPS: Schema.optional(Schema.Unknown), - Funnel: Schema.optional(Schema.Unknown), }), ), ), @@ -171,6 +170,7 @@ const TailscaleServeStatusJson = Schema.Struct({ }), ), ), + AllowFunnel: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), }); type TailscaleServeStatusJson = typeof TailscaleServeStatusJson.Type; @@ -409,6 +409,9 @@ function servePortState( const entries = Object.entries(status.Web ?? {}).filter( ([authority]) => authorityPort(authority) === input.servePort, ); + const funnelEnabled = Object.entries(status.AllowFunnel ?? {}).some( + ([authority, enabled]) => authorityPort(authority) === input.servePort && enabled, + ); if (entries.length === 0 && tcpEntries.length === 0) { return "absent"; } @@ -416,7 +419,7 @@ function servePortState( return "occupied"; } const tcp = tcpEntries[0]?.[1]; - if (tcp?.HTTPS !== true || tcp.Funnel === true) { + if (tcp?.HTTPS !== true || funnelEnabled) { return "occupied"; } From d8d738dbd9352d0c0acdb4c3845fb07775791a06 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Wed, 26 Aug 2026 18:10:51 -0700 Subject: [PATCH 6/7] fix(dev): enforce shared proxy boundaries --- apps/web/vite.config.ts | 5 +++-- packages/shared/src/devProxy.test.ts | 12 +++++++++++- packages/shared/src/devProxy.ts | 2 +- packages/tailscale/src/tailscale.test.ts | 1 + packages/tailscale/src/tailscale.ts | 11 +++++++---- 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a3c42540e7f2..73a4f059be84 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -30,6 +30,7 @@ const port = Number(process.env.PORT ?? 5733); const explicitHost = process.env.HOST?.trim(); const sharedBindHost = process.env.T3CODE_WEB_BIND_HOST?.trim(); const host = resolveWebDevServerHost({ explicitHost, sharedBindHost }); +const explicitHmrHost = sharedBindHost ? undefined : explicitHost; const configuredWsUrl = isSingleOriginDev ? undefined : process.env.VITE_WS_URL?.trim(); const configuredHttpUrl = isSingleOriginDev ? undefined : process.env.VITE_HTTP_URL?.trim(); const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; @@ -248,11 +249,11 @@ export default defineConfig(() => { // page origin, which is what makes HMR work over Tailscale/LAN instead of // failing an attempt against the wrong machine's localhost first. // (Vite 8 logs connection state via console.debug — enable "Verbose".) - ...(explicitHost + ...(explicitHmrHost ? { hmr: { protocol: "ws", - host: explicitHost, + host: explicitHmrHost, clientPort: port, }, } diff --git a/packages/shared/src/devProxy.test.ts b/packages/shared/src/devProxy.test.ts index 4713f9360440..dc660243ec41 100644 --- a/packages/shared/src/devProxy.test.ts +++ b/packages/shared/src/devProxy.test.ts @@ -21,9 +21,19 @@ describe("resolveWebDevServerHost", () => { assert.equal( resolveWebDevServerHost({ explicitHost: "192.0.2.10", - sharedBindHost: SHARED_DEV_LOOPBACK_HOST, + sharedBindHost: undefined, }), "192.0.2.10", ); }); + + it("does not let an environment HOST override the shared proxy address", () => { + assert.equal( + resolveWebDevServerHost({ + explicitHost: "localhost", + sharedBindHost: SHARED_DEV_LOOPBACK_HOST, + }), + "127.0.0.1", + ); + }); }); diff --git a/packages/shared/src/devProxy.ts b/packages/shared/src/devProxy.ts index 3b6d2c4dbdae..373c27111cbf 100644 --- a/packages/shared/src/devProxy.ts +++ b/packages/shared/src/devProxy.ts @@ -16,7 +16,7 @@ export function resolveWebDevServerHost(input: { readonly explicitHost: string | undefined; readonly sharedBindHost: string | undefined; }): string { - return input.explicitHost || input.sharedBindHost || "localhost"; + return input.sharedBindHost || input.explicitHost || "localhost"; } export function isDevProxiedPath(pathname: string): boolean { diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index f3d6b191aa08..84886d58361f 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -452,6 +452,7 @@ describe("tailscale", () => { const ensureError = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443, + replaceVerifiedHandler: true, }).pipe(Effect.flip, Effect.provide(layer)); assert.instanceOf(ensureError, TailscaleServePortOccupiedError); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index e677b7ce1af1..e5252f8d4cf8 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -388,7 +388,7 @@ const runTailscaleCommand = ( ); }); -type TailscaleServePortState = "absent" | "exact" | "occupied"; +type TailscaleServePortState = "absent" | "exact" | "funnel" | "occupied"; function authorityPort(authority: string): number | null { try { @@ -419,7 +419,10 @@ function servePortState( return "occupied"; } const tcp = tcpEntries[0]?.[1]; - if (tcp?.HTTPS !== true || funnelEnabled) { + if (funnelEnabled) { + return "funnel"; + } + if (tcp?.HTTPS !== true) { return "occupied"; } @@ -467,7 +470,7 @@ export const ensureTailscaleServe = (input: { if (state === "exact") { return; } - if (state === "occupied" && input.replaceVerifiedHandler !== true) { + if (state === "funnel" || (state === "occupied" && input.replaceVerifiedHandler !== true)) { return yield* new TailscaleServePortOccupiedError({ servePort }); } yield* runTailscaleCommand( @@ -490,7 +493,7 @@ export const disableTailscaleServe = (input: { if (state === "absent") { return; } - if (state === "occupied") { + if (state === "occupied" || state === "funnel") { return yield* new TailscaleServePortOccupiedError({ servePort }); } yield* runTailscaleCommand(["serve", `--https=${servePort}`, "off"], TAILSCALE_SERVE_TIMEOUT); From 1c54d3f2949c4fa9388252f1a03d4b1015112f8c Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Wed, 26 Aug 2026 18:25:59 -0700 Subject: [PATCH 7/7] fix(tailscale): narrow verified replacement --- packages/tailscale/src/tailscale.test.ts | 24 ++++++++++++++++++++++++ packages/tailscale/src/tailscale.ts | 12 ++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 84886d58361f..c2cbfe0795d9 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -418,6 +418,30 @@ describe("tailscale", () => { }); }); + it.effect("does not replace a complex handler after an environment probe", () => { + const commands: ReadonlyArray[] = []; + const layer = mockSpawnerLayer((_command, args) => { + commands.push(args); + const status = JSON.parse(serveStatusJson(8443, "http://127.0.0.1:13773")) as { + Web: Record }>; + }; + const web = status.Web["desktop.tail.ts.net:8443"]; + assert.isDefined(web); + web.Handlers["/api"] = { Proxy: "http://127.0.0.1:39831" }; + return { stdout: JSON.stringify(status) }; + }); + + return Effect.gen(function* () { + const error = yield* ensureTailscaleServe({ + localPort: 5733, + servePort: 8443, + replaceVerifiedHandler: true, + }).pipe(Effect.flip, Effect.provide(layer)); + assert.instanceOf(error, TailscaleServePortOccupiedError); + assert.deepEqual(commands, [["serve", "status", "--json"]]); + }); + }); + it.effect("treats a non-web listener on the selected port as occupied", () => { const layer = mockSpawnerLayer(() => ({ stdout: JSON.stringify({ TCP: { 8443: { TCPForward: "127.0.0.1:39831" } }, Web: {} }), diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index e5252f8d4cf8..dad5ccd795d3 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -388,7 +388,7 @@ const runTailscaleCommand = ( ); }); -type TailscaleServePortState = "absent" | "exact" | "funnel" | "occupied"; +type TailscaleServePortState = "absent" | "exact" | "funnel" | "occupied" | "replaceable"; function authorityPort(authority: string): number | null { try { @@ -435,7 +435,7 @@ function servePortState( return "occupied"; } const proxy = Reflect.get(root, "Proxy"); - return proxy === input.proxy ? "exact" : "occupied"; + return proxy === input.proxy ? "exact" : "replaceable"; } const readTailscaleServePortState = (input: { @@ -470,7 +470,11 @@ export const ensureTailscaleServe = (input: { if (state === "exact") { return; } - if (state === "funnel" || (state === "occupied" && input.replaceVerifiedHandler !== true)) { + if ( + state === "funnel" || + state === "occupied" || + (state === "replaceable" && input.replaceVerifiedHandler !== true) + ) { return yield* new TailscaleServePortOccupiedError({ servePort }); } yield* runTailscaleCommand( @@ -493,7 +497,7 @@ export const disableTailscaleServe = (input: { if (state === "absent") { return; } - if (state === "occupied" || state === "funnel") { + if (state === "occupied" || state === "funnel" || state === "replaceable") { return yield* new TailscaleServePortOccupiedError({ servePort }); } yield* runTailscaleCommand(["serve", `--https=${servePort}`, "off"], TAILSCALE_SERVE_TIMEOUT);