Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/cli/src/setup-tailscale.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
29 changes: 20 additions & 9 deletions packages/cli/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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)}`);
Expand Down