From 106ba704a87ba3c4f8b052a22bc02c9371fcc469 Mon Sep 17 00:00:00 2001 From: Sethmr Date: Wed, 9 Sep 2026 11:46:32 -0400 Subject: [PATCH 1/4] fix(server): explain a disabled tailnet Serve instead of timing out When a tailnet has not enabled HTTPS certificates, `tailscale serve` prints an enablement URL and then blocks until someone visits it. `t3 pair --tailscale` hit its 10s command timeout and reported `TailscaleServeFailedError` wrapping a bare `TimeoutError`, which names neither the setting nor where to change it. `tailscale status --json` already reports the capability that gates serve, and the pairing path already reads that status to build the MagicDNS URL, so the check costs no extra spawn. A tailnet missing the capability now fails immediately with the admin console URL. A status that reports no capability map stays "unknown" and still attempts serve, so an older CLI or an unexpected shape cannot block a working tailnet. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/cli/pair.ts | 15 +++++++++++++ packages/tailscale/src/tailscale.test.ts | 28 ++++++++++++++++++++++++ packages/tailscale/src/tailscale.ts | 25 +++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 04ce0332c14c..0c60c15bf11f 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -106,6 +106,15 @@ export class MagicDnsNameMissingError extends Schema.TaggedError()( + "TailscaleServeNotEnabledError", + {}, +) { + override get message(): string { + return "Tailscale Serve is not enabled on your tailnet. Enable HTTPS certificates at https://login.tailscale.com/admin/dns, then run this command again."; + } +} + export class ServesOtherEnvironmentError extends Schema.TaggedError()( "ServesOtherEnvironmentError", { servePort: Schema.Number }, @@ -370,6 +379,12 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase" if (status.magicDnsName === null) { return yield* new MagicDnsNameMissingError(); } + // Without the HTTPS capability `tailscale serve` prints an enablement URL + // and blocks on it forever, so letting it run only buys a timeout that + // names neither the setting nor where to change it. + if (status.httpsServeEnabled === false) { + return yield* new TailscaleServeNotEnabledError(); + } const baseUrl = buildTailscaleHttpsBaseUrl({ magicDnsName: status.magicDnsName, servePort: input.servePort, diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 102153f6e204..00b9434f70d9 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -149,10 +149,37 @@ describe("tailscale", () => { assert.deepEqual(status, { magicDnsName: "desktop.tail.ts.net", tailnetIpv4Addresses: ["100.100.100.100"], + httpsServeEnabled: null, }); }), ); + it.effect("reads the HTTPS serve capability from the status capability map", () => + Effect.gen(function* () { + const enabled = yield* parseTailscaleStatus( + `{"Self":{"CapMap":{"https":null,"funnel":null}}}`, + ); + assert.equal(enabled.httpsServeEnabled, true); + + const disabled = yield* parseTailscaleStatus(`{"Self":{"CapMap":{"funnel":null}}}`); + assert.equal(disabled.httpsServeEnabled, false); + }), + ); + + // A missing or malformed capability map must not read as "disabled": callers + // refuse to run `tailscale serve` on false, so guessing would break a tailnet + // that is actually fine. + it.effect("reports an unknown HTTPS serve capability rather than guessing", () => + Effect.gen(function* () { + assert.equal((yield* parseTailscaleStatus(`{"Self":{}}`)).httpsServeEnabled, null); + assert.equal((yield* parseTailscaleStatus("{}")).httpsServeEnabled, null); + assert.equal( + (yield* parseTailscaleStatus(`{"Self":{"CapMap":["https"]}}`)).httpsServeEnabled, + null, + ); + }), + ); + it.effect("preserves status decoding failures without exposing cause text", () => Effect.gen(function* () { const error = yield* parseTailscaleStatus("{not-json").pipe(Effect.flip); @@ -191,6 +218,7 @@ describe("tailscale", () => { assert.deepEqual(status, { magicDnsName: "desktop.tail.ts.net", tailnetIpv4Addresses: ["100.90.1.2"], + httpsServeEnabled: null, }); }); }); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 096176aa42f8..3d38de806b87 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -129,6 +129,7 @@ export class TailscaleStatusParseError extends Schema.TaggedError(stream: Stream.Stream): Effect.Effect => @@ -210,6 +234,7 @@ export const parseTailscaleStatus = ( return { magicDnsName: normalizeMagicDnsName(parsed), tailnetIpv4Addresses, + httpsServeEnabled: readHttpsServeEnabled(parsed), }; }), ); From f1bcdcdb9051ae483bb48290402ea10e26dc9cdd Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:54:03 -0700 Subject: [PATCH 2/4] test(server): pair --tailscale stops at status when the tailnet lacks HTTPS serve Replaces the PR's second parser case with a CLI-level test that runs t3 pair --tailscale against a stubbed tailscale status and checks the diagnostic plus that tailscale serve is never spawned. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/cli/pair.test.ts | 55 ++++++++++++++++++++++++ packages/tailscale/src/tailscale.test.ts | 14 ------ 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index 207721b64a14..845f20195aa7 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -11,8 +11,11 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import * as TestConsole from "effect/testing/TestConsole"; import { Command, CliError } from "effect/cli"; +import { ChildProcessSpawner } from "effect/process"; import { cli } from "../binCli.ts"; import { @@ -318,6 +321,58 @@ describe("t3 pair", () => { assert.include(rendered, "No running T3 Code server found."); }).pipe(Effect.provide(NodeServices.layer)), ); + + // Without the capability `tailscale serve` blocks on an enablement URL, so + // pairing must stop at status instead of spawning it. + it.effect("explains a tailnet without HTTPS serve instead of running tailscale serve", () => + withDescriptorServer((origin) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-ts-test-")); + yield* persistServerRuntimeState({ + path: NodePath.join(baseDir, "userdata", "server-runtime.json"), + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: Number(new URL(origin).port), + }), + }); + const tailscaleCalls: Array> = []; + const spawner = ChildProcessSpawner.make((command) => { + const { args } = command as unknown as { readonly args: ReadonlyArray }; + tailscaleCalls.push(args); + const stdout = + args[0] === "status" + ? `{"Self":{"DNSName":"pair-test.invalid.","CapMap":{"funnel":null}}}` + : ""; + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(args[0] === "status" ? 0 : 1)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(new TextEncoder().encode(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + + const error = yield* provideCliTestLayers( + runCli(["pair", "--base-dir", baseDir, "--tailscale"]).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.flip, + ), + ); + + assert.include(String(error), "Tailscale Serve is not enabled on your tailnet."); + assert.include(String(error), "https://login.tailscale.com/admin/dns"); + assert.deepEqual(tailscaleCalls, [["status", "--json"]]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); }); describe("auth scope options", () => { diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 91c1761e14ac..1c891aa9a452 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -159,20 +159,6 @@ describe("tailscale", () => { }), ); - // A missing or malformed capability map must not read as "disabled": callers - // refuse to run `tailscale serve` on false, so guessing would break a tailnet - // that is actually fine. - it.effect("reports an unknown HTTPS serve capability rather than guessing", () => - Effect.gen(function* () { - assert.equal((yield* parseTailscaleStatus(`{"Self":{}}`)).httpsServeEnabled, null); - assert.equal((yield* parseTailscaleStatus("{}")).httpsServeEnabled, null); - assert.equal( - (yield* parseTailscaleStatus(`{"Self":{"CapMap":["https"]}}`)).httpsServeEnabled, - null, - ); - }), - ); - it.effect("preserves status decoding failures without exposing cause text", () => Effect.gen(function* () { const error = yield* parseTailscaleStatus("{not-json").pipe(Effect.flip); From f7838e9509019685fed2956e02bf768d40eff7b8 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:57:30 -0700 Subject: [PATCH 3/4] fix(server): pair --tailscale names the disabled HTTPS setting instead of timing out Without the https capability tailscale serve blocks on an enablement URL until the 10s timeout. Read the capability from the status JSON pairing already fetches and fail immediately with the admin DNS page. A status without a capability map stays null and still attempts serve. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/cli/pair.ts | 5 ++--- packages/tailscale/src/tailscale.ts | 29 ++++++++--------------------- 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 1e5c42758b33..d866981bf938 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -351,9 +351,8 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase" if (status.magicDnsName === null) { return yield* new MagicDnsNameMissingError(); } - // Without the HTTPS capability `tailscale serve` prints an enablement URL - // and blocks on it forever, so letting it run only buys a timeout that - // names neither the setting nor where to change it. + // Without the capability `tailscale serve` blocks on an enablement URL + // until it times out, which names neither the setting nor where to change it. if (status.httpsServeEnabled === false) { return yield* new TailscaleServeNotEnabledError(); } diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index d333a466a32f..1b8e4802b425 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -138,30 +138,13 @@ export interface TailscaleStatus { readonly magicDnsName: string | null; readonly tailnetIpv4Addresses: readonly string[]; /** - * Whether the control plane grants this node the capability that - * `tailscale serve --https` requires. `null` means `tailscale status` did not - * report a capability map at all: an older CLI or an unexpected shape must - * not read as "disabled", because that would block a working tailnet. + * `tailscale serve --https` needs the `https` capability, which the control + * plane grants once the tailnet enables HTTPS certificates. `null` when the + * CLI reports no capability map (older CLI), so callers still attempt serve. */ readonly httpsServeEnabled: boolean | null; } -/** - * Capability the control plane grants once a tailnet enables HTTPS - * certificates. Without it `tailscale serve` prints an enablement URL and then - * blocks until someone visits it, which reads to a caller as an unexplained - * timeout rather than a setting it can name. - */ -const HTTPS_SERVE_CAPABILITY = "https"; - -function readHttpsServeEnabled(status: TailscaleStatusJson): boolean | null { - const capabilities = status.Self?.CapMap; - if (typeof capabilities !== "object" || capabilities === null || Array.isArray(capabilities)) { - return null; - } - return Object.hasOwn(capabilities, HTTPS_SERVE_CAPABILITY); -} - const collectStdout = (stream: Stream.Stream): Effect.Effect => stream.pipe( Stream.decodeText(), @@ -227,10 +210,14 @@ export const parseTailscaleStatus = ( } } + const capMap = parsed.Self?.CapMap; return { magicDnsName: normalizeMagicDnsName(parsed), tailnetIpv4Addresses, - httpsServeEnabled: readHttpsServeEnabled(parsed), + httpsServeEnabled: + typeof capMap === "object" && capMap !== null && !Array.isArray(capMap) + ? Object.hasOwn(capMap, "https") + : null, }; }), ); From 5d6cc14ba85a3573daeb9816fb5c929b39e64444 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:02:38 -0700 Subject: [PATCH 4/4] test(desktop): injected tailscale status fixture carries httpsServeEnabled TailscaleStatus gained a required tri-state httpsServeEnabled field, and the desktop endpoint-provider test still built the injected status without it, so the desktop typecheck failed. Co-Authored-By: Claude Fable 5.1 --- apps/desktop/src/backend/tailscaleEndpointProvider.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index 1911654bdfa5..c3cdaaa317e6 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -209,6 +209,7 @@ describe("tailscale endpoint provider", () => { return { magicDnsName: "desktop.tail.ts.net", tailnetIpv4Addresses: ["100.74.126.34"], + httpsServeEnabled: null, }; }), });