diff --git a/packages/coding-agent/.changes/tailscale-support.md b/packages/coding-agent/.changes/tailscale-support.md new file mode 100644 index 0000000000..3412b81763 --- /dev/null +++ b/packages/coding-agent/.changes/tailscale-support.md @@ -0,0 +1 @@ +- Added first-class Tailscale support: `prime-agent tailscale` reports tailnet and MagicDNS state, `prime-agent tailscale serve --port [--funnel]` exposes a local port on the tailnet via `tailscale serve --bg`, doctor surfaces Tailscale detection, and docs cover the tailnet patterns (Tailscale SSH remote control, serve/funnel exposure, MagicDNS reach from cloud containers, and adding Tailscale's MCP server). diff --git a/packages/coding-agent/docs/tailscale.md b/packages/coding-agent/docs/tailscale.md new file mode 100644 index 0000000000..8de1e0c83b --- /dev/null +++ b/packages/coding-agent/docs/tailscale.md @@ -0,0 +1,41 @@ +# Tailscale + +Prime Agent is tailnet-aware: it detects Tailscale, reports your tailnet state, and can expose local ports on your tailnet with one command. This page documents the three supported patterns. + +## 1. Reach your agent from anywhere (Tailscale SSH) + +The agents view is local-first: it renders in your terminal over the daemon's unix socket. To control it from any device on your tailnet, use Tailscale SSH into the host and run `prime-agent` there: + +```sh +# from your laptop or phone terminal, on any tailnet device: +ssh your-agent-host +prime-agent agents +``` + +No port forwarding, no public exposure - Tailscale SSH authenticates with your tailnet identity. Prerequisites: Tailscale SSH must be enabled on the agent host (`tailscale up --ssh` on it, and your tailnet ACL must allow `autogroup:member` ssh access to it); plain `ssh` without Tailscale SSH enabled would fall back to a normal SSH server that may not exist or use different credentials. + +## 2. Expose a local port on your tailnet (`prime-agent tailscale serve`) + +Wrap `tailscale serve` for any local bridge, API, or dev server: + +```sh +prime-agent tailscale serve --port 3000 # https://..ts.net +prime-agent tailscale serve --port 3000 --funnel # public via tailscale funnel +prime-agent tailscale # status: tailnet, MagicDNS name, served endpoints +``` + +The command refuses with a teaching error when the tailscale CLI is missing or the machine is not up on a tailnet. `prime-agent doctor` includes the same detection in its report. + +## 3. Let the agent reach tailnet services (MagicDNS + containers) + +A process on a tailnet machine reaches every other device by MagicDNS name (`http://db.tailnet.ts.net:5432`) with no extra wiring - the agent can already do this from the kernel. To give a CLOUD-hosted agent container tailnet access, join it to your tailnet: install the Tailscale CLI (or sidecar container) and run `tailscale up` with an auth key in the container bootstrap, then MagicDNS names resolve from inside the agent. Container note: without `/dev/net/tun` (typical for hosted containers), run tailscaled in userspace-networking mode (`tailscaled --tun=userspace-networking`) - the container then dials out through userspace networking and MagicDNS still works; `tailscale up` alone cannot create the tunnel interface in that environment. + +## 4. Operate your tailnet from the agent (Tailscale MCP) + +Tailscale publishes an MCP server for AI agents to operate a tailnet (list devices, manage serve/funnel). Find the current endpoint in Tailscale's docs (https://tailscale.com/kb - search "MCP"), then add it as a remote MCP server: + +```sh +prime-agent mcp add remote --url https:// +``` + +References: https://tailscale.com/kb (Serve/Funnel, MagicDNS, container patterns, MCP). diff --git a/packages/coding-agent/src/cli/command-registry.ts b/packages/coding-agent/src/cli/command-registry.ts index f160929ae9..24d9810a11 100644 --- a/packages/coding-agent/src/cli/command-registry.ts +++ b/packages/coding-agent/src/cli/command-registry.ts @@ -86,6 +86,16 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ summary: "Inspect and safely clean up background services", options: ["--fix Remove stale sockets and stop idle orphaned services", "--json Print JSON"], }, + { + path: ["tailscale"], + usage: "tailscale [status] | tailscale serve --port [--funnel]", + summary: "Tailscale tailnet support: status, and expose a local port via serve/funnel", + options: [ + "status (default) show tailnet state, MagicDNS name, and served endpoints", + "serve --port expose localhost: on your tailnet (wraps `tailscale serve --bg`)", + "--funnel with serve: expose publicly via tailscale funnel", + ], + }, { path: ["shutdown"], usage: "shutdown [--force] [--json]", diff --git a/packages/coding-agent/src/cli/public-command.ts b/packages/coding-agent/src/cli/public-command.ts index 5fa05c40cd..11e62a64f2 100644 --- a/packages/coding-agent/src/cli/public-command.ts +++ b/packages/coding-agent/src/cli/public-command.ts @@ -19,6 +19,7 @@ import { handleDaemonCommand } from "./daemon-command.js"; import { runPs, runReap, runShutdownAll } from "./daemon-ps.js"; import { DAEMON_UPDATE_RESTART_COORDINATOR_FLAG } from "./daemon-update-restart.js"; import { extractHelpCommandPath, rotateGlobalFlagsBeforeCommand } from "./global-flags.js"; +import { parseTailscaleArgs, runTailscaleServe, runTailscaleStatus, tailscaleDoctorFacts } from "./tailscale.js"; export interface PublicCommandResult { handled: boolean; @@ -113,6 +114,8 @@ async function runPublicCommand(args: string[]): Promise { return runStatus(args.slice(1)); case "doctor": return runDoctor(args.slice(1)); + case "tailscale": + return runTailscaleCommand(args.slice(1)); case "shutdown": return runShutdown(args.slice(1)); case "package": @@ -255,7 +258,27 @@ async function runDoctor(args: string[]): Promise { await runReap(options.has("--json"), false); } else { await runPs(options.has("--json")); + if (!options.has("--json")) { + for (const fact of tailscaleDoctorFacts()) { + console.log(fact); + } + } + } + return HANDLED; +} + +function runTailscaleCommand(args: string[]): PublicCommandResult { + const parsed = parseTailscaleArgs(args); + if (parsed.kind === "error") { + console.log(chalk.red(parsed.message)); + process.exitCode = 1; + return HANDLED; + } + if (parsed.kind === "serve") { + process.exitCode = runTailscaleServe(parsed.port, parsed.funnel); + return HANDLED; } + process.exitCode = runTailscaleStatus(parsed.json); return HANDLED; } diff --git a/packages/coding-agent/src/cli/tailscale.ts b/packages/coding-agent/src/cli/tailscale.ts new file mode 100644 index 0000000000..cd4b10c09a --- /dev/null +++ b/packages/coding-agent/src/cli/tailscale.ts @@ -0,0 +1,410 @@ +import chalk from "chalk"; +import { spawnSyncHidden } from "../utils/child-process.js"; + +/** + * First-class Tailscale support for prime-agent (the "out of the box" tailnet moment). + * + * Three patterns, one command group: + * status - is this machine on a tailnet, what is its MagicDNS name, what is served/funneled + * serve - expose a local port on your tailnet (wraps `tailscale serve`, --funnel for public) + * doctor - the same detection surfaced in `prime-agent doctor` + * + * The TUI itself is local-first (it renders in your terminal over the daemon's + * unix socket), so "reach your agent from anywhere" is the Tailscale-SSH + * pattern documented in docs/tailscale.md; `serve` covers exposing any local + * bridge/API port on the tailnet. + */ + +interface TailscaleProbe { + /** Absolute path of the `tailscale` CLI on PATH, or null when absent. */ + cliPath: string | null; + /** True when this node is up on a tailnet right now. */ + onTailnet: boolean; + /** The tailnet's MagicDNS suffix (e.g. "tailnet-name.ts.net."), or null. */ + magicDnsSuffix: string | null; + /** This node's tailnet hostname (without the suffix), or null. */ + hostname: string | null; + /** True when the backend is Running but the node is not online right now. */ + offlineButUp?: boolean; + /** The raw error line when the CLI exists but reports a failure, or null. */ + error: string | null; +} + +interface TailscaleStatusJson { + Self?: { Online?: boolean; HostName?: string; DNSName?: string }; + MagicDNSSuffix?: string; + CurrentTailnet?: { MagicDNSSuffix?: string }; + BackendState?: string; +} + +/** Run the tailscale CLI once, returning stdout or an error descriptor. */ +function runTailscale(args: string[]): { code: number; stdout: string; stderr: string } { + const result = spawnSyncHidden("tailscale", args, { encoding: "utf8", timeout: 15000, killSignal: "SIGKILL" }); + if (result.error) { + return { code: -1, stdout: "", stderr: result.error.message }; + } + return { code: result.status ?? -1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; +} + +/** + * Detect the CLI and, when present, this node's tailnet state. + * Exported as a test seam: production code calls it inside the command functions + * below, and it is not part of the package's public API (src/index.ts). + */ +export function probeTailscale(): TailscaleProbe { + // Node's own ENOENT detection - no `which` binary needed (absent on Windows shells). + const version = spawnSyncHidden("tailscale", ["version"], { + encoding: "utf8", + timeout: 15000, + killSignal: "SIGKILL", + }); + if (version.error) { + // ENOENT = genuinely absent; anything else (EACCES, ETIMEDOUT, hung CLI) is + // an installed-but-unusable CLI and must say so instead of "not found". + if ((version.error as NodeJS.ErrnoException).code === "ENOENT") { + return { cliPath: null, onTailnet: false, magicDnsSuffix: null, hostname: null, error: null }; + } + return { + cliPath: "tailscale", + onTailnet: false, + magicDnsSuffix: null, + hostname: null, + error: `tailscale CLI could not be run (${(version.error as NodeJS.ErrnoException).code ?? "unknown error"})`, + }; + } + const cliPath = "tailscale"; + const status = runTailscale(["status", "--json"]); + if (status.code !== 0) { + const firstLine = status.stderr.split("\n")[0]?.trim(); + return { + cliPath, + onTailnet: false, + magicDnsSuffix: null, + hostname: null, + error: firstLine || "tailscale status failed with no diagnostic", + }; + } + try { + const parsed = JSON.parse(status.stdout) as TailscaleStatusJson; + // BackendState distinguishes a stopped/logged-out daemon from a node that + // is up but temporarily unreachable; only Running serves. + const backend = parsed.BackendState ?? ""; + const onTailnet = parsed.Self?.Online === true || backend === "Running"; + const dnsName = parsed.Self?.DNSName ?? parsed.Self?.HostName ?? null; + // Top-level MagicDNSSuffix is deprecated upstream; prefer CurrentTailnet's. + const suffix = parsed.CurrentTailnet?.MagicDNSSuffix ?? parsed.MagicDNSSuffix ?? null; + let hostname = dnsName ? dnsName.replace(/\.+$/, "") : null; + if (hostname && suffix) { + const trimmedSuffix = suffix.replace(/\.+$/, ""); + if (hostname.endsWith(`.${trimmedSuffix}`)) { + hostname = hostname.slice(0, -(trimmedSuffix.length + 1)); + } + } + return { + cliPath, + onTailnet, + magicDnsSuffix: suffix, + hostname, + offlineButUp: backend === "Running" && parsed.Self?.Online === false, + error: null, + }; + } catch (err) { + return { + cliPath, + onTailnet: false, + magicDnsSuffix: null, + hostname: null, + error: `unparseable status output: ${String(err)}`, + }; + } +} + +export type TailscaleArgs = + | { kind: "serve"; port: number; funnel: boolean } + | { kind: "status"; json: boolean } + | { kind: "error"; message: string }; + +/** Parse `prime-agent tailscale ...` argv into a mode, port, funnel, and json flag. */ +export function parseTailscaleArgs(args: string[]): TailscaleArgs { + const json = args.includes("--json"); + const rest = args.filter((arg) => arg !== "--json"); + if (rest.length === 0 || (rest.length === 1 && rest[0] === "status")) { + return { kind: "status", json }; + } + let port: number | null = null; + let funnel = false; + let sawServe = false; + let sawStatus = false; + for (let index = 0; index < rest.length; index++) { + const token = rest[index] as string; + if (token === "serve") { + if (sawServe || sawStatus) { + return { kind: "error", message: `tailscale: ${token} appears more than once` }; + } + sawServe = true; + continue; + } + if (token === "status") { + if (sawStatus || sawServe) { + return { kind: "error", message: `tailscale: ${token} appears more than once` }; + } + sawStatus = true; + continue; + } + if (token === "--funnel") { + if (funnel) { + return { kind: "error", message: "tailscale: --funnel appears more than once" }; + } + funnel = true; + continue; + } + if (token === "--port" || token.startsWith("--port=")) { + if (port !== null) { + return { kind: "error", message: "tailscale: --port appears more than once" }; + } + const value = token.startsWith("--port=") ? token.slice("--port=".length) : rest[++index]; + const parsed = Number(value); + if (value === undefined || Number.isNaN(parsed)) { + return { kind: "error", message: "--port requires a numeric value (1-65535)" }; + } + port = parsed; + continue; + } + if (token.startsWith("-")) { + return { kind: "error", message: `tailscale: unrecognized option ${token}` }; + } + return { kind: "error", message: `tailscale: unexpected argument ${token}` }; + } + if (sawStatus) { + if (port !== null || funnel) { + return { kind: "error", message: "tailscale: status takes no serve flags" }; + } + return { kind: "status", json }; + } + if (sawServe || port !== null || funnel) { + if (port === null) { + return { + kind: "error", + message: "tailscale serve requires --port (the LOCAL port to expose); refusing to guess a default", + }; + } + return { kind: "serve", port, funnel }; + } + return { kind: "error", message: `tailscale: unknown subcommand ${rest[0]}` }; +} + +/** Print the tailnet overview (human or `--json` form). Exits non-zero when Tailscale is unusable. */ +export function runTailscaleStatus(json = false): number { + const probe = probeTailscale(); + if (json) { + console.log(tailscaleStatusJson(probe)); + return probe.cliPath === null || !probe.onTailnet || probe.error !== null ? 1 : 0; + } + if (probe.error) { + console.log(chalk.red(`tailscale reported a problem: ${probe.error}`)); + return 1; + } + if (probe.cliPath === null) { + console.log(chalk.yellow("tailscale CLI not found on PATH")); + console.log("Install Tailscale: https://tailscale.com/download"); + return 1; + } + if (!probe.onTailnet) { + if (probe.offlineButUp) { + console.log(chalk.yellow("This node is up on a tailnet but currently offline - check connectivity")); + } else { + console.log(chalk.yellow("Tailscale is installed but this machine is not up on a tailnet")); + console.log("Run `tailscale up` first (or log in), then retry."); + } + return 1; + } + console.log( + `${chalk.bold("Tailnet")}: ${probe.offlineButUp ? "up (currently offline)" : "on (this machine is online)"}`, + ); + console.log(`${chalk.bold("MagicDNS suffix")}: ${probe.magicDnsSuffix ?? "unknown"}`); + console.log(`${chalk.bold("This node")}: ${probe.hostname ?? "unknown"}`); + const serve = runTailscale(["serve", "status", "--json"]); + if (serve.code !== 0) { + console.log( + chalk.yellow(`tailscale serve status failed (exit ${serve.code}); served-local status is unavailable`), + ); + return 1; + } + try { + const parsed = JSON.parse(serve.stdout) as { + TCP?: Record; + Web?: Record }>; + }; + const rows: string[] = []; + for (const [listen, entry] of Object.entries(parsed.TCP ?? {})) { + rows.push(` ${listen} -> ${entry.TCPForward ?? "tcp"}`); + } + for (const [listen, server] of Object.entries(parsed.Web ?? {})) { + for (const [path, handler] of Object.entries(server.Handlers ?? {})) { + const target = handler.Proxy ?? handler.Path ?? handler.Text ?? "static"; + rows.push(` ${listen}${path} -> ${target}`); + } + } + if (rows.length > 0) { + console.log(`${chalk.bold("Served locally")}:`); + for (const row of rows) { + console.log(row); + } + } else { + console.log(`${chalk.bold("Served locally")}: nothing (see docs/tailscale.md)`); + } + } catch { + console.log(`${chalk.bold("Served locally")}: (unparseable status)`); + } + return 0; +} + +/** Machine-readable status for `--json`. */ +function tailscaleStatusJson(probe = probeTailscale()): string { + return JSON.stringify( + { + cli: probe.cliPath, + onTailnet: probe.onTailnet, + offlineButUp: probe.offlineButUp ?? false, + magicDnsSuffix: probe.magicDnsSuffix, + hostname: probe.hostname, + error: probe.error, + }, + null, + 2, + ); +} + +/** + * Expose a local port on the tailnet: `tailscale serve --bg localhost:` + * (or `tailscale funnel --bg localhost:` with --funnel for public access). + * Returns the exit code and prints the next steps. + */ +export function runTailscaleServe(port: number, funnel: boolean): number { + if (!Number.isInteger(port) || port < 1 || port > 65535) { + console.log(chalk.red(`--port must be 1-65535, got ${port}`)); + return 1; + } + const probe = probeTailscale(); + if (probe.error) { + console.log(chalk.red(`tailscale status failed: ${probe.error}`)); + return 1; + } + if (probe.cliPath === null) { + console.log(chalk.yellow("tailscale CLI not found on PATH")); + console.log("Install Tailscale: https://tailscale.com/download"); + return 1; + } + if (!probe.onTailnet) { + console.log(chalk.yellow("This machine is not up on a tailnet (run `tailscale up` first)")); + return 1; + } + if (probe.offlineButUp) { + console.log( + chalk.yellow("This node is up on a tailnet but currently offline - restore connectivity before serving"), + ); + return 1; + } + const target = `localhost:${port}`; + const args = funnel ? ["funnel", "--bg", target] : ["serve", "--bg", target]; + console.log(`Running tailscale ${args.join(" ")} ...`); + // stdin inherited: funnel's first enable prompts interactively; timeout bounds a hung tailscaled. + const result = spawnSyncHidden("tailscale", args, { + encoding: "utf8" as const, + stdio: "inherit", + timeout: 60000, + killSignal: "SIGKILL", + }); + if (result.status !== 0) { + console.log(chalk.red("tailscale did not accept the serve/funnel command (see its output above)")); + return result.status ?? 1; + } + // tailscale can exit 0 after only printing an interactive enable URL (enableFeatureInteractive) + // without configuring anything; verify the target is really being served, matching the + // local endpoint EXACTLY (port 80 must not match localhost:8000). + const verify = runTailscale(["serve", "status", "--json"]); + if (verify.code !== 0) { + console.log(chalk.red(`post-serve verification failed: tailscale serve status exited ${verify.code}`)); + return 1; + } + let servedExactly = false; + let funnelEnabled = false; + try { + const parsedVerify = JSON.parse(verify.stdout) as { + TCP?: Record; + Web?: Record }>; + AllowFunnel?: Record; + }; + for (const entry of Object.values(parsedVerify.TCP ?? {})) { + if (entry.TCPForward === `127.0.0.1:${port}` || entry.TCPForward === `localhost:${port}`) { + servedExactly = true; + } + } + for (const [listen, server] of Object.entries(parsedVerify.Web ?? {})) { + for (const handler of Object.values(server.Handlers ?? {})) { + if (!handler.Proxy) { + continue; + } + try { + const target = new URL(handler.Proxy); + // URL.port is "" for default ports (80 for http:, 443 for https:). + const targetPort = target.port === "" ? (target.protocol === "https:" ? 443 : 80) : Number(target.port); + if (targetPort === port && ["127.0.0.1", "localhost", "::1"].includes(target.hostname)) { + servedExactly = true; + if (parsedVerify.AllowFunnel?.[listen] === true) { + funnelEnabled = true; + } + } + } catch { + // unparseable proxy target - cannot confirm; keep searching + } + } + } + } catch { + servedExactly = false; + } + if (!servedExactly) { + console.log( + chalk.yellow( + "tailscale exited 0 but the target does not appear in `tailscale serve status` - an interactive enable flow (URL printed above) may still be pending; re-run this command after enabling.", + ), + ); + return 1; + } + if (funnel && !funnelEnabled) { + console.log( + chalk.yellow( + "the local target is served, but `tailscale serve status` reports the endpoint as NOT funnel-enabled - check your tailnet funnel ACL and the enable URL printed above, then re-run.", + ), + ); + return 1; + } + console.log(""); + if (probe.hostname) { + const suffix = probe.magicDnsSuffix?.replace(/\.+$/, "") ?? "ts.net"; + // Exact domain-suffix match: the hostname must end with "." (or equal it). + const host = + probe.hostname.endsWith(`.${suffix}`) || probe.hostname === suffix + ? probe.hostname + : `${probe.hostname}.${suffix}`; + console.log(chalk.green(`Now reachable on your tailnet as ${host}`)); + if (funnel) { + console.log(`Public URL: https://${host}/`); + } + } + console.log("Stop with: tailscale serve status, then tailscale serve off (or funnel off)"); + return 0; +} + +/** One-line doctor facts for `prime-agent doctor` to include in its report. */ +export function tailscaleDoctorFacts(): string[] { + const probe = probeTailscale(); + if (probe.error) return [`tailscale: CLI present but erroring (${probe.error})`]; + if (probe.cliPath === null) + return ["tailscale: CLI not found (optional; install from https://tailscale.com/download)"]; + if (!probe.onTailnet) return ["tailscale: installed but not up on a tailnet (tailscale up)"]; + const offline = probe.offlineButUp ? " (currently offline)" : ""; + return [ + `tailscale: on tailnet (node ${probe.hostname ?? "unknown"}, MagicDNS ${probe.magicDnsSuffix ?? "unknown"}${offline})`, + ]; +} diff --git a/packages/coding-agent/test/tailscale.test.ts b/packages/coding-agent/test/tailscale.test.ts new file mode 100644 index 0000000000..e0a6232e86 --- /dev/null +++ b/packages/coding-agent/test/tailscale.test.ts @@ -0,0 +1,259 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + parseTailscaleArgs, + probeTailscale, + runTailscaleServe, + runTailscaleStatus, + tailscaleDoctorFacts, +} from "../src/cli/tailscale.js"; + +function writeShim(dir: string, script: string): string { + writeFileSync(join(dir, "tailscale"), script, { mode: 0o755 }); + return dir; +} + +/** Fake tailscale printing the given `status --json` payload; logs every argv to argv.log. */ +function shimTailscale(statusJson: string, extra = ""): { dir: string; argvs: () => string[][] } { + const dir = mkdtempSync(join(tmpdir(), "ts-shim-")); + const lines = [ + "#!/bin/sh", + `echo "$@" >> ${dir}/argv.log`, + '[ "$1" = version ] && exit 0', + 'if [ "$1" = status ]; then', + `printf '%s' '${statusJson.replace(/'/g, "")}'; exit 0; fi`, + 'if [ "$1 $2" = "serve status" ]; then', + `printf '%s' '${(extra || "{}").replace(/'/g, "")}'; exit 0; fi`, + 'case "$1" in serve|funnel) exit 0;; esac', + "exit 0", + ]; + writeShim(dir, lines.join("\n")); + return { + dir, + argvs: () => { + try { + return readFileSync(join(dir, "argv.log"), "utf8") + .trim() + .split("\n") + .map((line) => line.split(" ")); + } catch { + return []; + } + }, + }; +} + +/** serve-status JSON whose Web map proxies the given local port. */ +function serveStatusFor(port: number): string { + return JSON.stringify({ + Web: { "milk.tailnet.ts.net:443": { Handlers: { "/": { Proxy: `http://127.0.0.1:${port}` } } } }, + AllowFunnel: { "milk.tailnet.ts.net:443": true }, + }); +} + +function erroringShim(): string { + const dir = mkdtempSync(join(tmpdir(), "ts-shim-")); + return writeShim(dir, '#!/bin/sh\necho "shim failure" >&2\nexit 1\n'); +} + +function emptyDir(): string { + return mkdtempSync(join(tmpdir(), "ts-empty-")); +} + +const ONLINE = JSON.stringify({ + BackendState: "Running", + Self: { Online: true, HostName: "milk", DNSName: "milk.tailnet.ts.net." }, + MagicDNSSuffix: "tailnet.ts.net.", +}); + +const REAL_PATH = process.env.PATH; +beforeEach(() => { + process.env.PATH = REAL_PATH; +}); +afterEach(() => { + process.env.PATH = REAL_PATH; +}); + +describe("probeTailscale", () => { + it("reports a null CLI when tailscale is not on PATH", () => { + process.env.PATH = emptyDir(); + const probe = probeTailscale(); + expect(probe.cliPath).toBeNull(); + expect(probe.onTailnet).toBe(false); + }); + it("parses tailnet facts from a working status", () => { + process.env.PATH = `${shimTailscale(ONLINE).dir}:${process.env.PATH}`; + const probe = probeTailscale(); + expect(probe.onTailnet).toBe(true); + expect(probe.magicDnsSuffix).toBe("tailnet.ts.net."); + expect(probe.hostname).toBe("milk"); + }); + it("surfaces a CLI error line when status fails", () => { + process.env.PATH = `${erroringShim()}:${process.env.PATH}`; + const probe = probeTailscale(); + expect(probe.cliPath).not.toBeNull(); + expect(probe.onTailnet).toBe(false); + expect(probe.error).toContain("shim failure"); + }); + it("covers doctor facts and probe in one pass", () => { + expect(tailscaleDoctorFacts().length).toBe(1); + const shim = shimTailscale(ONLINE, serveStatusFor(3000)); + process.env.PATH = `${shim.dir}:${process.env.PATH}`; + expect(tailscaleDoctorFacts()[0]).toContain("on tailnet"); + }); + it("does not mark a healthy online node as offline", () => { + const shim = shimTailscale(ONLINE, serveStatusFor(3000)); + process.env.PATH = `${shim.dir}:${process.env.PATH}`; + const probe = probeTailscale(); + expect(probe.onTailnet).toBe(true); + expect(probe.offlineButUp).toBe(false); + expect(runTailscaleStatus()).toBe(0); + }); + it("distinguishes a stopped backend from up-but-offline", () => { + const stopped = JSON.stringify({ + BackendState: "Stopped", + Self: { Online: false }, + MagicDNSSuffix: "tailnet.ts.net.", + }); + const offline = JSON.stringify({ + BackendState: "Running", + Self: { Online: false, HostName: "milk" }, + MagicDNSSuffix: "tailnet.ts.net.", + }); + process.env.PATH = `${shimTailscale(stopped).dir}:${process.env.PATH}`; + expect(probeTailscale().onTailnet).toBe(false); + process.env.PATH = `${shimTailscale(offline).dir}:${process.env.PATH}`; + const probe = probeTailscale(); + expect(probe.onTailnet).toBe(true); + expect(probe.offlineButUp).toBe(true); + }); +}); + +describe("runTailscaleStatus", () => { + it("exits 1 with the install hint when the CLI is missing", () => { + process.env.PATH = emptyDir(); + expect(runTailscaleStatus()).toBe(1); + expect(runTailscaleStatus(true)).toBe(1); + }); + it("exits 1 when the backend is stopped", () => { + const stopped = JSON.stringify({ BackendState: "Stopped", Self: { Online: false } }); + process.env.PATH = `${shimTailscale(stopped).dir}:${process.env.PATH}`; + expect(runTailscaleStatus()).toBe(1); + expect(runTailscaleStatus(true)).toBe(1); + }); + it("exits 0 when online and prints listen->target pairs incl. TCP forwards", () => { + const serveStatus = JSON.stringify({ + TCP: { "10000": { TCPForward: "127.0.0.1:9000" } }, + Web: { "milk.tailnet.ts.net:443": { Handlers: { "/": { Proxy: "http://127.0.0.1:3000" } } } }, + AllowFunnel: {}, + }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + process.env.PATH = `${shimTailscale(ONLINE, serveStatus).dir}:${process.env.PATH}`; + expect(runTailscaleStatus(true)).toBe(0); + expect(runTailscaleStatus()).toBe(0); + const logged = spy.mock.calls.map((call) => call.join(" ")).join("\n"); + spy.mockRestore(); + expect(logged).toContain("127.0.0.1:9000"); + expect(logged).toContain("milk.tailnet.ts.net:443/ -> http://127.0.0.1:3000"); + }); +}); + +describe("runTailscaleServe", () => { + it("refuses ports outside 1-65535 before anything else", () => { + const shim = shimTailscale(ONLINE); + process.env.PATH = `${shim.dir}:${process.env.PATH}`; + expect(runTailscaleServe(0, false)).toBe(1); + expect(runTailscaleServe(65536, false)).toBe(1); + expect(shim.argvs().filter((argv) => argv[0] === "serve" || argv[0] === "funnel")).toEqual([]); + }); + it("refuses serve when the CLI is missing", () => { + process.env.PATH = emptyDir(); + expect(runTailscaleServe(3000, false)).toBe(1); + }); + it("builds serve/funnel --bg localhost:", () => { + const shim = shimTailscale(ONLINE, serveStatusFor(3000)); + process.env.PATH = `${shim.dir}:${process.env.PATH}`; + expect(runTailscaleServe(3000, false)).toBe(0); + expect(runTailscaleServe(3000, true)).toBe(0); + const spawned = shim + .argvs() + .filter((argv) => (argv[0] === "serve" || argv[0] === "funnel") && argv[1] !== "status"); + expect(spawned).toEqual([ + ["serve", "--bg", "localhost:3000"], + ["funnel", "--bg", "localhost:3000"], + ]); + const notPublic = shimTailscale( + ONLINE, + serveStatusFor(3000).replace('"milk.tailnet.ts.net:443":true', '"milk.tailnet.ts.net:443":false'), + ); + process.env.PATH = `${notPublic.dir}:${process.env.PATH}`; + expect(runTailscaleServe(3000, true)).toBe(1); // funnel requested, endpoint not funnel-enabled + }); +}); + +describe("parseTailscaleArgs", () => { + it("requires a port for serve and never guesses a default", () => { + expect(parseTailscaleArgs(["serve"]).kind).toBe("error"); + expect(parseTailscaleArgs(["--funnel"]).kind).toBe("error"); + }); + it("accepts --port n, --port=n, and --funnel together", () => { + expect(parseTailscaleArgs(["serve", "--port", "3000"])).toEqual({ kind: "serve", port: 3000, funnel: false }); + expect(parseTailscaleArgs(["serve", "--port=3000"])).toEqual({ kind: "serve", port: 3000, funnel: false }); + expect(parseTailscaleArgs(["--port", "3000", "--funnel"])).toEqual({ kind: "serve", port: 3000, funnel: true }); + expect(parseTailscaleArgs(["--port", "abc"]).kind).toBe("error"); + }); + it("treats no args as status, honors --json, and rejects unknown subcommands", () => { + expect(parseTailscaleArgs([])).toEqual({ kind: "status", json: false }); + expect(parseTailscaleArgs(["status", "--json"])).toEqual({ kind: "status", json: true }); + expect(parseTailscaleArgs(["bogus"]).kind).toBe("error"); + }); + it("rejects unconsumed, repeated, and conflicting arguments before any side effect", () => { + // "--funnel false" must NOT be parsed as funnel: true (public exposure!) + expect(parseTailscaleArgs(["serve", "--port", "3000", "--funnel", "false"]).kind).toBe("error"); + expect(parseTailscaleArgs(["serve", "--port", "3000", "--port", "4000"]).kind).toBe("error"); + expect(parseTailscaleArgs(["serve", "--funel", "--port", "3000"]).kind).toBe("error"); + expect(parseTailscaleArgs(["status", "--port", "3000"]).kind).toBe("error"); + const bare = parseTailscaleArgs(["serve"]); + expect(bare.kind).toBe("error"); + expect(bare.kind === "error" && bare.message).toContain("requires --port"); + }); +}); + +describe("post-serve verification", () => { + it("does not match a longer port via substring (port 80 vs localhost:8000)", () => { + const longer = shimTailscale( + ONLINE, + JSON.stringify({ Web: { "milk.ts.net:443": { Handlers: { "/": { Proxy: "http://localhost:8000" } } } } }), + ); + process.env.PATH = `${longer.dir}:${process.env.PATH}`; + expect(runTailscaleServe(80, false)).toBe(1); + const exactTcp = shimTailscale(ONLINE, JSON.stringify({ TCP: { "443": { TCPForward: "127.0.0.1:80" } } })); + process.env.PATH = `${exactTcp.dir}:${process.env.PATH}`; + expect(runTailscaleServe(80, false)).toBe(0); + const defaultPort = shimTailscale(ONLINE, serveStatusFor(80).replace("http://127.0.0.1:80", "http://127.0.0.1")); + process.env.PATH = `${defaultPort.dir}:${process.env.PATH}`; + expect(runTailscaleServe(80, false)).toBe(0); + }); + it("fails when tailscale exits 0 without serving the target, succeeds when it does", () => { + const pending = shimTailscale(ONLINE); // serve-status answers {} -> target absent + process.env.PATH = `${pending.dir}:${process.env.PATH}`; + expect(runTailscaleServe(3000, false)).toBe(1); + const served = shimTailscale(ONLINE, serveStatusFor(3000)); + process.env.PATH = `${served.dir}:${process.env.PATH}`; + expect(runTailscaleServe(3000, false)).toBe(0); + }); +}); + +describe("status failure diagnostics", () => { + it("reports an error for a hard status failure with empty stderr", () => { + const dir = mkdtempSync(join(tmpdir(), "ts-shim-")); + writeShim(dir, "#!/bin/sh\nexit 1\n"); + process.env.PATH = `${dir}:${process.env.PATH}`; + const probe = probeTailscale(); + expect(probe.cliPath).not.toBeNull(); + expect(probe.error).toContain("no diagnostic"); + expect(runTailscaleStatus()).toBe(1); + }); +});