From 4e070b9833bbdbeda81b83a7f59d6439f891944b Mon Sep 17 00:00:00 2001 From: Andro Marces Date: Sun, 26 Jul 2026 01:25:34 +0800 Subject: [PATCH] fix(cli): bound Tailscale Serve invocation and surface stdout in diagnostic `codor install` hangs indefinitely at "configuring Tailscale" when the tailnet has no HTTPS certificates enabled: `tailscale serve --bg` does not fail in that case, it blocks on an interactive browser-consent flow and prints the consent URL to stdout, which `execFileSync` captures into a buffer nobody ever sees, with no timeout to bound the wait. Bound the `serve --bg` invocation to 20s (only that call; `serve status` stays unbounded, and every other `defaultExec` caller is unaffected since the new options parameter is optional), and surface stdout alongside stderr in the thrown diagnostic so a bounded failure is actionable instead of a bare timeout. --- packages/cli/src/setup-tailscale.spec.ts | 26 +++++++++++++++++++++ packages/cli/src/setup.ts | 29 ++++++++++++++++-------- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/setup-tailscale.spec.ts b/packages/cli/src/setup-tailscale.spec.ts index 69d25568..abd7c7eb 100644 --- a/packages/cli/src/setup-tailscale.spec.ts +++ b/packages/cli/src/setup-tailscale.spec.ts @@ -105,4 +105,30 @@ describe('configureTailscaleServe', () => { expect(() => configureTailscaleServe('/usr/bin/tailscale', 'http://127.0.0.1:8137', () => 'no serve config')) .toThrow(/did not report a private HTTPS origin/); }); + + it('includes stdout in the diagnostic when serve --bg times out waiting for consent', () => { + // A tailnet with no HTTPS certificates enabled makes `serve --bg` block on an + // interactive consent prompt instead of failing; Tailscale prints that prompt, + // including the consent URL, to stdout rather than stderr. + const error = Object.assign(new Error('Command failed: /usr/bin/tailscale serve --bg http://127.0.0.1:8137'), { + stdout: 'To enable HTTPS Certificates for your tailnet, visit:\nhttps://login.tailscale.com/f/https', + killed: true, + signal: 'SIGTERM', + }); + expect(() => configureTailscaleServe('/usr/bin/tailscale', 'http://127.0.0.1:8137', (_command, args) => { + if (args[0] === 'serve' && args[1] === '--bg') throw error; + return ''; + })).toThrow(/https:\/\/login\.tailscale\.com\/f\/https/); + }); + + it('bounds the serve --bg call with a timeout but leaves serve status unbounded', () => { + const calls: Array<{ args: string[]; options: { timeoutMs?: number } | undefined }> = []; + configureTailscaleServe('/usr/bin/tailscale', 'http://127.0.0.1:8137', (_command, args, options) => { + calls.push({ args, options }); + if (args.join(' ') === 'serve status') return 'https://host.tail-abc.ts.net (tailnet only)'; + return ''; + }); + expect(calls[0]).toEqual({ args: ['serve', '--bg', 'http://127.0.0.1:8137'], options: { timeoutMs: 20_000 } }); + expect(calls[1]).toEqual({ args: ['serve', 'status'], options: undefined }); + }); }); diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index a22437ce..5243d689 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -37,7 +37,7 @@ const HARNESSES = ['claude', 'codex', 'opencode', 'gemini', 'copilot', 'cursor-a const LAUNCH_AGENT_LABEL = 'app.codor.switchboard'; export interface SetupOverrides { - exec?(command: string, args: string[]): string; + exec?(command: string, args: string[], options?: { timeoutMs?: number }): string; exists?(path: string): boolean; home?: string; kernelRelease?: string; @@ -67,9 +67,14 @@ export interface SetupOptions { yes?: boolean; } -const defaultExec = (command: string, args: string[]): string => execFileSync(command, args, { +const defaultExec = ( + command: string, + args: string[], + options: { timeoutMs?: number } = {}, +): string => execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + ...(options.timeoutMs === undefined ? {} : { timeout: options.timeoutMs }), }).trim(); const defaultWhich = (command: string): string | undefined => { @@ -381,23 +386,29 @@ export function tailscaleServeSupported( export function configureTailscaleServe( tailscalePath: string, localEndpoint: string, - exec: (command: string, args: string[]) => string, + exec: (command: string, args: string[], options?: { timeoutMs?: number }) => string, ): string { - // Preserve the full message and stderr: real permission/operator guidance often - // lands on a later line (the same class of bug fixed for launchctl). Node's - // command error often already embeds stderr in `message`, so never append the - // same block twice. + // Preserve the full message, stdout, and stderr: real permission/operator + // guidance often lands on a later line (the same class of bug fixed for + // launchctl), and Tailscale's own consent/admin-console prompt for Serve is + // printed to stdout rather than stderr. Node's command error often already + // embeds stderr in `message`, so never append the same block twice. const diagnostic = (error: unknown): string => { - const err = error as { message?: string; stderr?: string | Buffer }; + const err = error as { message?: string; stderr?: string | Buffer; stdout?: string | Buffer }; const message = err.message?.trim(); const stderr = err.stderr?.toString().trim(); + const stdout = err.stdout?.toString().trim(); const parts = message === undefined || message === '' ? [] : [message]; if (stderr !== undefined && stderr !== '' && message?.includes(stderr) !== true) parts.push(stderr); + if (stdout !== undefined && stdout !== '' && message?.includes(stdout) !== true) parts.push(stdout); return parts.join('\n').trim() || String(error); }; let status: string; try { - exec(tailscalePath, ['serve', '--bg', localEndpoint]); + // Bounded: when the tailnet has no HTTPS certificates enabled, `serve --bg` + // does not fail — it blocks waiting for interactive consent in a browser. + // `serve status` never blocks this way, so only the first call gets a budget. + exec(tailscalePath, ['serve', '--bg', localEndpoint], { timeoutMs: 20_000 }); status = exec(tailscalePath, ['serve', 'status']); } catch (error) { throw new Error(`Tailscale Serve command failed: ${diagnostic(error)}`);