From 908d9331af89fc7e83fcdf01ff23bc4270a201fe Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:16:19 +0000 Subject: [PATCH] feat: use sudo fallback for tailscale configuration commands To avoid permission denied errors when modifying Tailscale configuration (serve/funnel), we now attempt the command directly first. If it fails, we catch the error and retry with `sudo -n`. This preserves existing behavior for users where it works, but attempts to escalate privileges (non-interactively) if needed. - Added `execWithSudoFallback` helper in `src/infra/tailscale.ts`. - Updated `ensureFunnel`, `enableTailscaleServe`, `disableTailscaleServe`, `enableTailscaleFunnel`, and `disableTailscaleFunnel` to use the fallback helper. - Added tests in `src/infra/tailscale.test.ts` to verify fallback behavior. --- src/infra/tailscale.test.ts | 107 +++++++++++++++++++++++++++++++++++- src/infra/tailscale.ts | 85 +++++++++++++++++++++------- 2 files changed, 171 insertions(+), 21 deletions(-) diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index cb8d0be4e..39bb9ecc3 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it, vi } from "vitest"; -import { ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname } from "./tailscale.js"; +import { + ensureGoInstalled, + ensureTailscaledInstalled, + getTailnetHostname, + enableTailscaleServe, + disableTailscaleServe, + enableTailscaleFunnel, + disableTailscaleFunnel, + ensureFunnel +} from "./tailscale.js"; describe("tailscale helpers", () => { it("parses DNS name from tailscale status", async () => { @@ -48,4 +57,100 @@ describe("tailscale helpers", () => { await ensureTailscaledInstalled(exec as never, prompt, runtime); expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]); }); + + it("enableTailscaleServe attempts normal first, then sudo", async () => { + // 1. First attempt fails + // 2. Second attempt (sudo) succeeds + const exec = vi.fn() + .mockRejectedValueOnce(new Error("permission denied")) + .mockResolvedValueOnce({ stdout: "" }); + + await enableTailscaleServe(3000, exec as never); + + expect(exec).toHaveBeenNthCalledWith( + 1, + "tailscale", + expect.arrayContaining(["serve", "--bg", "--yes", "3000"]), + expect.any(Object) + ); + + expect(exec).toHaveBeenNthCalledWith( + 2, + "sudo", + expect.arrayContaining(["-n", "tailscale", "serve", "--bg", "--yes", "3000"]), + expect.any(Object) + ); + }); + + it("enableTailscaleServe does NOT use sudo if first attempt succeeds", async () => { + const exec = vi.fn().mockResolvedValue({ stdout: "" }); + + await enableTailscaleServe(3000, exec as never); + + expect(exec).toHaveBeenCalledTimes(1); + expect(exec).toHaveBeenCalledWith( + "tailscale", + expect.arrayContaining(["serve", "--bg", "--yes", "3000"]), + expect.any(Object) + ); + }); + + it("disableTailscaleServe uses fallback", async () => { + const exec = vi.fn() + .mockRejectedValueOnce(new Error("failed")) + .mockResolvedValueOnce({ stdout: "" }); + + await disableTailscaleServe(exec as never); + + expect(exec).toHaveBeenCalledTimes(2); + expect(exec).toHaveBeenNthCalledWith( + 2, + "sudo", + expect.arrayContaining(["-n", "tailscale", "serve", "reset"]), + expect.any(Object) + ); + }); + + it("ensureFunnel uses fallback for enabling", async () => { + // Mock exec: + // 1. status (success) + // 2. enable (fails) + // 3. enable sudo (success) + const exec = vi.fn() + .mockResolvedValueOnce({ stdout: JSON.stringify({ BackendState: "Running" }) }) // status + .mockRejectedValueOnce(new Error("failed")) // enable normal + .mockResolvedValueOnce({ stdout: "" }); // enable sudo + + const runtime = { + error: vi.fn(), + log: vi.fn(), + exit: vi.fn() as unknown as (code: number) => never, + }; + const prompt = vi.fn(); + + await ensureFunnel(8080, exec as never, runtime, prompt); + + // 1. status + expect(exec).toHaveBeenNthCalledWith( + 1, + "tailscale", + expect.arrayContaining(["funnel", "status", "--json"]) + ); + + // 2. enable normal + expect(exec).toHaveBeenNthCalledWith( + 2, + "tailscale", + expect.arrayContaining(["funnel", "--yes", "--bg", "8080"]), + expect.any(Object) + ); + + // 3. enable sudo + expect(exec).toHaveBeenNthCalledWith( + 3, + "sudo", + expect.arrayContaining(["-n", "tailscale", "funnel", "--yes", "--bg", "8080"]), + expect.any(Object) + ); + }); }); diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index ca2faf8d5..f7a0c6d41 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -206,6 +206,25 @@ export async function ensureTailscaledInstalled( await exec("brew", ["install", "tailscale"]); } +// Helper to attempt a command, and retry with sudo if it fails. +async function execWithSudoFallback( + exec: typeof runExec, + bin: string, + args: string[], + opts: { maxBuffer?: number; timeoutMs?: number }, +): Promise<{ stdout: string; stderr: string }> { + try { + return await exec(bin, args, opts); + } catch (err) { + // If the error suggests permission denied or access denied, try with sudo. + // Or honestly, for any error in these specific ops, trying sudo is a reasonable fallback + // given the context of what we're doing (system-level network config). + // We'll log a verbose message that we're falling back. + logVerbose(`Command failed, retrying with sudo: ${bin} ${args.join(" ")}`); + return await exec("sudo", ["-n", bin, ...args], opts); + } +} + export async function ensureFunnel( port: number, exec: typeof runExec = runExec, @@ -237,10 +256,16 @@ export async function ensureFunnel( } logVerbose(`Enabling funnel on port ${port}…`); - const { stdout } = await exec("sudo", ["-n", tailscaleBin, "funnel", "--yes", "--bg", `${port}`], { - maxBuffer: 200_000, - timeoutMs: 15_000, - }); + // Attempt with fallback + const { stdout } = await execWithSudoFallback( + exec, + tailscaleBin, + ["funnel", "--yes", "--bg", `${port}`], + { + maxBuffer: 200_000, + timeoutMs: 15_000, + }, + ); if (stdout.trim()) console.log(stdout.trim()); } catch (err) { const errOutput = err as { stdout?: unknown; stderr?: unknown }; @@ -288,32 +313,52 @@ export async function ensureFunnel( export async function enableTailscaleServe(port: number, exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec("sudo", ["-n", tailscaleBin, "serve", "--bg", "--yes", `${port}`], { - maxBuffer: 200_000, - timeoutMs: 15_000, - }); + await execWithSudoFallback( + exec, + tailscaleBin, + ["serve", "--bg", "--yes", `${port}`], + { + maxBuffer: 200_000, + timeoutMs: 15_000, + }, + ); } export async function disableTailscaleServe(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec("sudo", ["-n", tailscaleBin, "serve", "reset"], { - maxBuffer: 200_000, - timeoutMs: 15_000, - }); + await execWithSudoFallback( + exec, + tailscaleBin, + ["serve", "reset"], + { + maxBuffer: 200_000, + timeoutMs: 15_000, + }, + ); } export async function enableTailscaleFunnel(port: number, exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec("sudo", ["-n", tailscaleBin, "funnel", "--bg", "--yes", `${port}`], { - maxBuffer: 200_000, - timeoutMs: 15_000, - }); + await execWithSudoFallback( + exec, + tailscaleBin, + ["funnel", "--bg", "--yes", `${port}`], + { + maxBuffer: 200_000, + timeoutMs: 15_000, + }, + ); } export async function disableTailscaleFunnel(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec("sudo", ["-n", tailscaleBin, "funnel", "reset"], { - maxBuffer: 200_000, - timeoutMs: 15_000, - }); + await execWithSudoFallback( + exec, + tailscaleBin, + ["funnel", "reset"], + { + maxBuffer: 200_000, + timeoutMs: 15_000, + }, + ); }