I hit this on current Nightly (0.1.4-nightly.20261027.3796) and tracked down the root cause. It isn't (only) PATH: even when the app finds the CLI, the Tailscale binary runs in GUI mode because the Dock-launched app's env has no SHLVL/TERM.
Repro (no T3 needed), simulating a Finder/Dock-launched process:
| 1 | env -i PATH=/usr/bin:/bin HOME=$HOME /Applications/Tailscale.app/Contents/MacOS/Tailscale status --json; echo "exit=$?" |
| 2 | # The Tailscale GUI failed to start: The operation couldn’t be completed. (Tailscale.CLIError error 3.) |
| 3 | # exit=0 |
| 4 | |
| 5 | env -i PATH=/usr/bin:/bin HOME=$HOME TAILSCALE_BE_CLI=1 /Applications/Tailscale.app/Contents/MacOS/Tailscale status --json | head -c 40 |
| 6 | # { "Version": "1.104.x-…", "TUN": true, … |
| Env passed to the Tailscale app binary | Result |
|---|---|
| none of the below (launchd / Finder / Dock) | GUI failure text on stdout, exit 0 |
SHLVL set (any value, even 0) | CLI JSON |
TERM set | CLI JSON |
TAILSCALE_BE_CLI set | CLI JSON |
This matches the mode check described in #21609 (ppid, then TAILSCALE_BE_CLI / SHLVL / TERM / TERM_PROGRAM / PS1).
What happens in T3: DesktopShellEnvironment copies only an allowlist from the login shell (PATH, SSH_AUTH_SOCK, HOMEBREW_*…), so with the Tailscale app dir on the login-shell PATH, the desktop does find the binary, but spawns it without SHLVL/TERM. tailscale status --json exits 0 with that non-JSON text, parseTailscaleStatus fails, and orElseSucceed(() => null) turns it into "no Tailscale" with nothing logged. getAdvertisedEndpoints() then has tailscale-ip: but no tailscale-magicdns:.
So the bundle-path suggestion above wouldn't fix it on its own. I checked: spawning the absolute .../MacOS/Tailscale path fails the same way. Command name, path case, PATH lookup vs absolute path, detached, stdin, cwd and output size make no difference (288-run matrix across plain Node, ELECTRON_RUN_AS_NODE, Electron main, and Electron launched via open).
Reproduces on both Tailscale variants:
- macOS 27.0.1 arm64, Tailscale 1.104.1, App Store (
io.tailscale.ipn.macos) - macOS 27.2 arm64, Tailscale 1.104.2, standalone (
io.tailscale.ipn.macsys)
Workaround: a wrapper earlier on PATH than the app dir:
| 1 | #!/bin/sh |
| 2 | TAILSCALE_BE_CLI=1 exec /Applications/Tailscale.app/Contents/MacOS/Tailscale "$@" |
(A plain sh wrapper also works because sh sets SHLVL. A symlink does not: in CLI mode it aborts with BundleIdentifiers.swift:49: Fatal error: The current bundleIdentifier is unknown to the registry.)
Suggested fix: add TAILSCALE_BE_CLI=1 with extendEnv: true to every darwin tailscale spawn in packages/tailscale (status, debug prefs, serve), and log a warning (e.g. tagged gui-mode) instead of silently returning null when the status can't be parsed. That also covers t3 serve started by launchd. I have a small patch with tests that passes vp run check:changed. Happy to open a PR if you want one.
Co-authored with Claude Opus 5.5 in Claude Code.