From e17cca1b7700c0a3f8c3c402d3802e4858967c65 Mon Sep 17 00:00:00 2001 From: Corey Quinn Date: Fri, 31 Jul 2026 14:20:12 -0700 Subject: [PATCH] fix(tailscale): fall back to the macOS app binary when tailscale isn't on PATH Tailscale's macOS app doesn't add `tailscale` to PATH; users typically work around this with a shell alias to the app's bundled binary. That alias is invisible to our direct (non-shell) subprocess spawn, so `readTailscaleStatus`/`ensureTailscaleServe`/`disableTailscaleServe` fail with ENOENT and Tailnet detection silently reports "not on Tailscale" even when Tailscale is running. When spawning `tailscale` fails with ENOENT on darwin, retry once against the app's bundled CLI at /Applications/Tailscale.app/Contents/MacOS/Tailscale if that file exists. No change in behavior on other platforms or when the app binary isn't present. --- packages/tailscale/src/tailscale.test.ts | 90 ++++++++++++++++++++++++ packages/tailscale/src/tailscale.ts | 63 ++++++++++++++--- 2 files changed, 143 insertions(+), 10 deletions(-) diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 24c22454d9d..e7be743d182 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -14,6 +15,7 @@ import { disableTailscaleServe, ensureTailscaleServe, isTailscaleIpv4Address, + MACOS_TAILSCALE_APP_EXECUTABLE, parseTailscaleMagicDnsName, parseTailscaleStatus, readTailscaleStatus, @@ -21,6 +23,7 @@ import { TailscaleCommandExitError, TailscaleCommandSpawnError, TailscaleCommandTimeoutError, + TailscaleExecutableFileCheck, TailscaleStatusParseError, } from "./tailscale.ts"; @@ -212,6 +215,93 @@ describe("tailscale", () => { }); }); + it.effect("falls back to the macOS Tailscale.app binary when `tailscale` isn't on PATH", () => { + const notFoundCause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + cause: new Error("ENOENT"), + }); + const spawnedCommands: Array = []; + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { readonly command: string }; + spawnedCommands.push(childProcess.command); + return childProcess.command === "tailscale" + ? Effect.fail(notFoundCause) + : Effect.succeed(mockHandle({ stdout: tailscaleStatusWithSingleIpJson })); + }), + ); + + return Effect.gen(function* () { + const status = yield* readTailscaleStatus.pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + TailscaleExecutableFileCheck, + (filePath) => filePath === MACOS_TAILSCALE_APP_EXECUTABLE, + ), + Effect.provide(layer), + ); + + assert.deepEqual(status, { + magicDnsName: "desktop.tail.ts.net", + tailnetIpv4Addresses: ["100.90.1.2"], + }); + assert.deepEqual(spawnedCommands, ["tailscale", MACOS_TAILSCALE_APP_EXECUTABLE]); + }); + }); + + it.effect("does not fall back when the macOS Tailscale.app binary isn't installed either", () => { + const notFoundCause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + cause: new Error("ENOENT"), + }); + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(notFoundCause)), + ); + + return Effect.gen(function* () { + const error = yield* readTailscaleStatus.pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(TailscaleExecutableFileCheck, () => false), + Effect.provide(layer), + Effect.flip, + ); + + assert.instanceOf(error, TailscaleCommandSpawnError); + assert.strictEqual(error.cause, notFoundCause); + }); + }); + + it.effect("does not fall back to the macOS binary on non-macOS platforms", () => { + const notFoundCause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + cause: new Error("ENOENT"), + }); + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(notFoundCause)), + ); + + return Effect.gen(function* () { + const error = yield* readTailscaleStatus.pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(TailscaleExecutableFileCheck, () => true), + Effect.provide(layer), + Effect.flip, + ); + + assert.instanceOf(error, TailscaleCommandSpawnError); + assert.strictEqual(error.cause, notFoundCause); + }); + }); + it.effect("keeps nonzero exit diagnostics structured", () => { const layer = mockSpawnerLayer(() => ({ code: 7, diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 7260a9de11b..41a10652021 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -1,7 +1,12 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; + import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import type * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; @@ -17,6 +22,32 @@ export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "tailscale.exe" => platform === "win32" ? "tailscale.exe" : "tailscale"; +// The Tailscale macOS app doesn't put `tailscale` on PATH; Tailscale's own +// docs have users add a shell alias to this binary instead, which is +// invisible to a direct (non-shell) subprocess spawn. +export const MACOS_TAILSCALE_APP_EXECUTABLE = + "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; + +export type TailscaleExecutableFileCheck = (filePath: string) => boolean; + +function isExistingFile(filePath: string): boolean { + try { + return NodeFS.statSync(filePath).isFile(); + } catch { + return false; + } +} + +/** Injectable file-existence check so tests can run against a fake filesystem. */ +export const TailscaleExecutableFileCheck = Context.Reference( + "@t3tools/tailscale/TailscaleExecutableFileCheck", + { defaultValue: () => isExistingFile }, +); + +function isSpawnNotFoundError(error: PlatformError.PlatformError): boolean { + return error.reason._tag === "NotFound"; +} + const TailscaleCommandContext = { executable: Schema.Literals(["tailscale", "tailscale.exe"]), subcommand: Schema.Literals(["status", "serve"]), @@ -221,6 +252,7 @@ export const readTailscaleStatus = Effect.gen(function* () { const args = ["status", "--json"]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; + const isExecutableFile = yield* TailscaleExecutableFileCheck; const executable = tailscaleCommandForPlatform(hostPlatform); const commandContext = { executable, @@ -228,11 +260,16 @@ export const readTailscaleStatus = Effect.gen(function* () { argumentCount: args.length, }; return yield* Effect.gen(function* () { - const child = yield* spawner - .spawn(ChildProcess.make(executable, args)) - .pipe( - Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), - ); + const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( + Effect.catchIf( + (error) => hostPlatform === "darwin" && isSpawnNotFoundError(error), + (error) => + isExecutableFile(MACOS_TAILSCALE_APP_EXECUTABLE) + ? spawner.spawn(ChildProcess.make(MACOS_TAILSCALE_APP_EXECUTABLE, args)) + : Effect.fail(error), + ), + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + ); const [stdout, stderr, exitCode] = yield* Effect.all( [ collectStdout(child.stdout), @@ -291,6 +328,7 @@ const runTailscaleCommand = ( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; + const isExecutableFile = yield* TailscaleExecutableFileCheck; const executable = tailscaleCommandForPlatform(hostPlatform); const commandContext = { executable, @@ -299,11 +337,16 @@ const runTailscaleCommand = ( }; const timeout = Duration.fromInputUnsafe(timeoutInput); return yield* Effect.gen(function* () { - const child = yield* spawner - .spawn(ChildProcess.make(executable, args)) - .pipe( - Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), - ); + const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( + Effect.catchIf( + (error) => hostPlatform === "darwin" && isSpawnNotFoundError(error), + (error) => + isExecutableFile(MACOS_TAILSCALE_APP_EXECUTABLE) + ? spawner.spawn(ChildProcess.make(MACOS_TAILSCALE_APP_EXECUTABLE, args)) + : Effect.fail(error), + ), + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + ); const [stderr, exitCode] = yield* Effect.all( [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], { concurrency: "unbounded" },