diff --git a/packages/mosaic/src/commands/claudex-proxy.spec.ts b/packages/mosaic/src/commands/claudex-proxy.spec.ts new file mode 100644 index 0000000..b592133 --- /dev/null +++ b/packages/mosaic/src/commands/claudex-proxy.spec.ts @@ -0,0 +1,412 @@ +import { describe, it, expect, vi } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + CLAUDEX_PROXY_HOST, + CLAUDEX_PROXY_PORT, + CLAUDEX_PROXY_URL, + CLAUDEX_PROXY_BINARY, + buildAuthStatusArgs, + buildDeviceAuthArgs, + buildServeArgs, + parseAuthStatus, + checkProxyBinary, + checkAuthStatus, + runDeviceReauth, + probeLiveness, + buildSystemdUnitContent, + systemdUnitPath, + installSystemdUnit, + runProxyPreflight, + ensureProxyRunning, + type AuthStatus, + type ProxyRunResult, +} from './claudex-proxy.js'; + +/** + * P1 — Proxy preflight + lifecycle helpers for `mosaic yolo claudex`. + * + * Security-relevant invariants exercised here: + * - Liveness probe treats ANY HTTP response (even non-2xx) as "alive" and only + * a transport/connection failure as "dead" (spec gotcha #1: never `curl -f`; + * the original wrapper spawned duplicate proxies because of this). + * - Auth-status parsing NEVER surfaces OAuth token material — only a coarse + * state + optional expiry — even if a token-shaped string appears in output. + */ + +describe('claudex-proxy constants', () => { + it('pins the proxy endpoint to loopback :18765 (spec table)', () => { + expect(CLAUDEX_PROXY_HOST).toBe('127.0.0.1'); + expect(CLAUDEX_PROXY_PORT).toBe(18765); + expect(CLAUDEX_PROXY_URL).toBe('http://127.0.0.1:18765'); + expect(CLAUDEX_PROXY_BINARY).toBe('claude-code-proxy'); + }); + + it('builds the documented codex subcommand argv', () => { + expect(buildAuthStatusArgs()).toEqual(['codex', 'auth', 'status']); + expect(buildDeviceAuthArgs()).toEqual(['codex', 'auth', 'device']); + expect(buildServeArgs()).toEqual(['serve', '--no-monitor']); + }); +}); + +describe('parseAuthStatus', () => { + it('reports valid on exit 0 with an authenticated marker', () => { + const s = parseAuthStatus({ + status: 0, + stdout: 'Authenticated as user; token valid', + stderr: '', + }); + expect(s.state).toBe('valid'); + }); + + it('reports expired when output mentions expiry', () => { + const s = parseAuthStatus({ status: 0, stdout: 'Token expired 2 days ago', stderr: '' }); + expect(s.state).toBe('expired'); + }); + + it('reports unauthenticated when output says not logged in', () => { + const s = parseAuthStatus({ + status: 1, + stdout: '', + stderr: 'not authenticated: run codex auth device', + }); + expect(s.state).toBe('unauthenticated'); + }); + + it('reports unknown on an unrecognized non-zero exit', () => { + const s = parseAuthStatus({ status: 2, stdout: 'weird', stderr: '' }); + expect(s.state).toBe('unknown'); + }); + + it('extracts a best-effort expiry in days when present', () => { + const s = parseAuthStatus({ + status: 0, + stdout: 'Authenticated; expires in 9 days', + stderr: '', + }); + expect(s.state).toBe('valid'); + expect(s.expiresInDays).toBe(9); + }); + + it('treats a clean exit 0 with no explicit markers as valid', () => { + const s = parseAuthStatus({ status: 0, stdout: 'Session active for account foo', stderr: '' }); + expect(s.state).toBe('valid'); + expect(s.expiresInDays).toBeUndefined(); + }); + + it('NEVER retains token-shaped material from output', () => { + const leaky = 'Authenticated. access_token=sk-abc123SECRETdeadbeef refresh_token=rt-9999'; + const s: AuthStatus = parseAuthStatus({ status: 0, stdout: leaky, stderr: '' }); + const serialized = JSON.stringify(s); + expect(serialized).not.toContain('sk-abc123SECRETdeadbeef'); + expect(serialized).not.toContain('rt-9999'); + expect(serialized).not.toContain('access_token'); + expect(serialized).not.toContain('refresh_token'); + }); +}); + +describe('checkAuthStatus', () => { + it('runs the status subcommand and parses the result', () => { + const run = vi.fn( + (_cmd: string, _args: string[]): ProxyRunResult => ({ + status: 0, + stdout: 'Authenticated; expires in 7 days', + stderr: '', + }), + ); + const s = checkAuthStatus(run); + expect(run).toHaveBeenCalledWith(CLAUDEX_PROXY_BINARY, ['codex', 'auth', 'status']); + expect(s.state).toBe('valid'); + expect(s.expiresInDays).toBe(7); + }); + + it('surfaces unknown when the default runner cannot find the binary', () => { + // Exercises the default spawnSync path against an absent binary: no throw, + // status is non-zero/null → unknown. Deterministic on a box without the proxy. + const s = checkAuthStatus(); + expect(['unknown', 'unauthenticated', 'valid', 'expired']).toContain(s.state); + }); +}); + +describe('runDeviceReauth', () => { + it('spawns the device flow with inherited stdio (never captures the code/token)', () => { + const calls: Array<{ cmd: string; args: string[]; opts: { stdio: string } }> = []; + const status = runDeviceReauth((cmd, args, opts) => { + calls.push({ cmd, args, opts }); + return { status: 0 }; + }); + expect(status).toBe(0); + expect(calls).toHaveLength(1); + expect(calls[0]!.cmd).toBe(CLAUDEX_PROXY_BINARY); + expect(calls[0]!.args).toEqual(['codex', 'auth', 'device']); + // stdio 'inherit' is the security-critical bit: the device code streams to + // the user's TTY; the launcher never pipes/captures it. + expect(calls[0]!.opts.stdio).toBe('inherit'); + }); + + it('returns 1 when the child yields no status (absent binary)', () => { + const status = runDeviceReauth(() => ({ status: null })); + expect(status).toBe(1); + }); +}); + +describe('checkProxyBinary', () => { + it('resolves via the default `which` path (proxy absent → null)', () => { + // Covers the default resolver; on CI/dev the proxy is not installed. + const r = checkProxyBinary(); + expect(typeof r.present).toBe('boolean'); + if (!r.present) expect(r.path).toBeNull(); + }); + + it('reports present with the resolved path', () => { + const r = checkProxyBinary(() => '/home/u/.local/bin/claude-code-proxy'); + expect(r.present).toBe(true); + expect(r.path).toBe('/home/u/.local/bin/claude-code-proxy'); + }); + + it('reports absent when the resolver finds nothing', () => { + const r = checkProxyBinary(() => null); + expect(r.present).toBe(false); + expect(r.path).toBeNull(); + }); +}); + +describe('probeLiveness (gotcha #1 — any HTTP response is alive)', () => { + it('treats 200 as alive', async () => { + const live = await probeLiveness(CLAUDEX_PROXY_URL, async () => ({ status: 200 })); + expect(live).toBe(true); + }); + + it('treats a non-2xx (404) response as alive — must NOT behave like curl -f', async () => { + const live = await probeLiveness(CLAUDEX_PROXY_URL, async () => ({ status: 404 })); + expect(live).toBe(true); + }); + + it('treats a 500 response as alive', async () => { + const live = await probeLiveness(CLAUDEX_PROXY_URL, async () => ({ status: 500 })); + expect(live).toBe(true); + }); + + it('treats a connection failure (reject) as dead', async () => { + const live = await probeLiveness(CLAUDEX_PROXY_URL, async () => { + throw new Error('ECONNREFUSED'); + }); + expect(live).toBe(false); + }); + + it('treats a timeout as dead', async () => { + const never = () => new Promise<{ status?: number }>(() => {}); + const live = await probeLiveness(CLAUDEX_PROXY_URL, never, 20); + expect(live).toBe(false); + }); +}); + +describe('buildSystemdUnitContent', () => { + it('emits a user unit that execs the given binary with serve args', () => { + const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy'); + expect(unit).toContain('[Unit]'); + expect(unit).toContain('[Service]'); + expect(unit).toContain('[Install]'); + expect(unit).toContain('/home/u/.local/bin/claude-code-proxy serve --no-monitor'); + expect(unit).toContain('WantedBy=default.target'); + }); + + it('never embeds credential material', () => { + const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy'); + expect(unit).not.toMatch(/token/i); + expect(unit).not.toMatch(/auth\.json/i); + }); +}); + +describe('systemdUnitPath', () => { + it('targets the systemd --user unit dir', () => { + expect(systemdUnitPath('/home/u')).toBe( + '/home/u/.config/systemd/user/claude-code-proxy.service', + ); + }); +}); + +describe('installSystemdUnit', () => { + it('writes the unit and returns true when daemon-reload succeeds', () => { + let written: { path: string; content: string } | null = null; + const ok = installSystemdUnit('/bin/claude-code-proxy', { + home: '/home/u', + writeUnit: (path, content) => { + written = { path, content }; + }, + run: () => ({ status: 0, stdout: '', stderr: '' }), + }); + expect(ok).toBe(true); + expect(written).not.toBeNull(); + expect(written!.path).toBe('/home/u/.config/systemd/user/claude-code-proxy.service'); + expect(written!.content).toContain('ExecStart=/bin/claude-code-proxy serve --no-monitor'); + }); + + it('returns false when daemon-reload fails (systemd --user unavailable)', () => { + const ok = installSystemdUnit('/bin/claude-code-proxy', { + home: '/home/u', + writeUnit: () => {}, + run: () => ({ status: 1, stdout: '', stderr: 'Failed to connect to bus' }), + }); + expect(ok).toBe(false); + }); + + it('returns false when writing the unit throws', () => { + const ok = installSystemdUnit('/bin/claude-code-proxy', { + home: '/home/u', + writeUnit: () => { + throw new Error('EACCES'); + }, + run: () => ({ status: 0, stdout: '', stderr: '' }), + }); + expect(ok).toBe(false); + }); + + it('writes to a real temp dir via the default writer', () => { + const home = mkdtempSync(join(tmpdir(), 'claudex-unit-')); + try { + const ok = installSystemdUnit('/bin/claude-code-proxy', { + home, + run: () => ({ status: 0, stdout: '', stderr: '' }), + }); + expect(ok).toBe(true); + const written = readFileSync(systemdUnitPath(home), 'utf8'); + expect(written).toContain('[Service]'); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); + +describe('runProxyPreflight', () => { + it('is ok when binary present, auth valid, and proxy live', async () => { + const report = await runProxyPreflight({ + checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }), + checkAuth: () => ({ state: 'valid' }), + probe: async () => true, + }); + expect(report.ok).toBe(true); + expect(report.problems).toEqual([]); + }); + + it('flags a missing binary', async () => { + const report = await runProxyPreflight({ + checkBinary: () => ({ present: false, path: null }), + checkAuth: () => ({ state: 'valid' }), + probe: async () => true, + }); + expect(report.ok).toBe(false); + expect(report.problems.some((p) => /binary/i.test(p))).toBe(true); + }); + + it('flags expired auth (re-auth needed)', async () => { + const report = await runProxyPreflight({ + checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }), + checkAuth: () => ({ state: 'expired' }), + probe: async () => true, + }); + expect(report.ok).toBe(false); + expect(report.needsReauth).toBe(true); + expect(report.problems.some((p) => /auth/i.test(p))).toBe(true); + }); + + it('flags a dead proxy', async () => { + const report = await runProxyPreflight({ + checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }), + checkAuth: () => ({ state: 'valid' }), + probe: async () => false, + }); + expect(report.ok).toBe(false); + expect(report.live).toBe(false); + }); + + it('flags an unknown auth state without marking it for re-auth', async () => { + const report = await runProxyPreflight({ + checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }), + checkAuth: () => ({ state: 'unknown' }), + probe: async () => true, + }); + expect(report.ok).toBe(false); + expect(report.needsReauth).toBe(false); + expect(report.problems.some((p) => /could not determine/i.test(p))).toBe(true); + }); + + it('does not leak token material for any auth state', async () => { + const report = await runProxyPreflight({ + checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }), + checkAuth: () => ({ state: 'expired' }), + probe: async () => false, + }); + expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i); + }); + + it('runs end-to-end with all real defaults (no proxy installed → not ok)', async () => { + // Exercises the default checkBinary/checkAuth/probe closures against a box + // with no proxy: absent binary, spawnSync status, real loopback probe that + // fast-fails with ECONNREFUSED. Asserts shape only (never token material). + const report = await runProxyPreflight(); + expect(typeof report.ok).toBe('boolean'); + expect(Array.isArray(report.problems)).toBe(true); + expect(['valid', 'expired', 'unauthenticated', 'unknown']).toContain(report.auth.state); + expect(JSON.stringify(report)).not.toMatch(/access_token|refresh_token|sk-/i); + }); +}); + +describe('ensureProxyRunning', () => { + const ok: ProxyRunResult = { status: 0, stdout: '', stderr: '' }; + + it('is a no-op when the proxy is already live', async () => { + const startSystemd = vi.fn(() => ok); + const startNohup = vi.fn(() => ok); + const r = await ensureProxyRunning({ + probe: async () => true, + startSystemd, + startNohup, + waitMs: async () => {}, + }); + expect(r.method).toBe('already'); + expect(r.live).toBe(true); + expect(startSystemd).not.toHaveBeenCalled(); + expect(startNohup).not.toHaveBeenCalled(); + }); + + it('starts via systemd when available and then becomes live', async () => { + let calls = 0; + const r = await ensureProxyRunning({ + probe: async () => calls++ > 0, // dead first, live after start + startSystemd: () => ok, + startNohup: () => { + throw new Error('should not fall back'); + }, + waitMs: async () => {}, + }); + expect(r.method).toBe('systemd'); + expect(r.live).toBe(true); + }); + + it('falls back to nohup when systemd start fails', async () => { + let calls = 0; + const r = await ensureProxyRunning({ + // A failed systemd start skips its post-start probe, so probes are: + // #0 initial (dead), #1 after nohup (live). + probe: async () => calls++ > 0, + startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }), + startNohup: () => ok, + waitMs: async () => {}, + }); + expect(r.method).toBe('nohup'); + expect(r.live).toBe(true); + }); + + it('reports failed when nothing brings the proxy up', async () => { + const r = await ensureProxyRunning({ + probe: async () => false, + startSystemd: () => ({ status: 1, stdout: '', stderr: '' }), + startNohup: () => ({ status: 1, stdout: '', stderr: '' }), + waitMs: async () => {}, + }); + expect(r.method).toBe('failed'); + expect(r.live).toBe(false); + }); +}); diff --git a/packages/mosaic/src/commands/claudex-proxy.ts b/packages/mosaic/src/commands/claudex-proxy.ts new file mode 100644 index 0000000..9e20935 --- /dev/null +++ b/packages/mosaic/src/commands/claudex-proxy.ts @@ -0,0 +1,400 @@ +/** + * Claudex proxy preflight + lifecycle (P1 of `mosaic yolo claudex`). + * + * `raine/claude-code-proxy` runs a local server on 127.0.0.1:18765 that speaks + * the Anthropic Messages API and translates to the ChatGPT/Codex backend using + * ChatGPT-subscription OAuth. This module owns the *preflight* and *lifecycle* + * concerns for the launcher: is the binary present, is OAuth valid, is the proxy + * listening, and — if not — bring it up (systemd user unit preferred, nohup + * fallback). + * + * Design: every function is pure or dependency-injected so the launch path is + * fully unit-testable without touching a real process, socket, or the OAuth + * token. Nothing here reads `~/.config/claude-code-proxy/codex/auth.json`; the + * proxy holds the real credential and Claude Code only ever sees + * `ANTHROPIC_AUTH_TOKEN=unused`. Parsed auth status is deliberately coarse + * (state + optional expiry) so no token material can be retained or surfaced. + */ + +import { execFileSync, spawn, spawnSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +// ─── Endpoint / command constants (spec table) ────────────────────────────── + +export const CLAUDEX_PROXY_HOST = '127.0.0.1'; +export const CLAUDEX_PROXY_PORT = 18765; +export const CLAUDEX_PROXY_URL = `http://${CLAUDEX_PROXY_HOST}:${CLAUDEX_PROXY_PORT}`; +export const CLAUDEX_PROXY_BINARY = 'claude-code-proxy'; +export const CLAUDEX_SYSTEMD_UNIT = 'claude-code-proxy.service'; + +/** argv for `claude-code-proxy codex auth status`. */ +export function buildAuthStatusArgs(): string[] { + return ['codex', 'auth', 'status']; +} + +/** argv for `claude-code-proxy codex auth device` (device-code re-auth flow). */ +export function buildDeviceAuthArgs(): string[] { + return ['codex', 'auth', 'device']; +} + +/** argv for `claude-code-proxy serve --no-monitor`. */ +export function buildServeArgs(): string[] { + return ['serve', '--no-monitor']; +} + +// ─── Types ────────────────────────────────────────────────────────────────── + +export type AuthState = 'valid' | 'expired' | 'unauthenticated' | 'unknown'; + +/** + * Coarse OAuth status. Intentionally carries NO token material — only a state + * and an optional best-effort expiry-in-days for user-facing messaging. + */ +export interface AuthStatus { + state: AuthState; + expiresInDays?: number; +} + +export interface ProxyRunResult { + status: number | null; + stdout: string; + stderr: string; +} + +/** Runs a command synchronously and returns its captured result. */ +export type CommandRunner = (cmd: string, args: string[]) => ProxyRunResult; + +/** Minimal fetch shape used for the liveness probe (any HTTP response = alive). */ +export type FetchLike = ( + url: string, + init?: { signal?: AbortSignal }, +) => Promise<{ status?: number }>; + +// ─── Binary presence ───────────────────────────────────────────────────────── + +function defaultWhich(cmd: string): string | null { + try { + return execFileSync('which', [cmd], { encoding: 'utf8' }).trim() || null; + } catch { + return null; + } +} + +export function checkProxyBinary(resolve: (cmd: string) => string | null = defaultWhich): { + present: boolean; + path: string | null; +} { + const path = resolve(CLAUDEX_PROXY_BINARY); + return { present: path !== null && path !== '', path: path || null }; +} + +// ─── Auth status ───────────────────────────────────────────────────────────── + +/** + * Parse `claude-code-proxy codex auth status` output into a coarse state. + * + * The proxy's exact wording is not contractually pinned, so this matches + * tolerantly on well-known markers and falls back on the exit code. It never + * copies the raw output onto the result — only a state and an optional expiry — + * so token-shaped strings in the output cannot leak downstream. + */ +export function parseAuthStatus(result: ProxyRunResult): AuthStatus { + const text = `${result.stdout}\n${result.stderr}`.toLowerCase(); + + const expired = /\bexpired\b|token has expired|expires?d? \d+ days? ago/.test(text); + const unauth = + /not authenticated|not logged in|no (?:auth|credentials|token)|please (?:log ?in|authenticate)|run .*auth device/.test( + text, + ); + const authed = /\bauthenticated\b|logged in|token valid|valid until|expires? in/.test(text); + + let state: AuthState; + if (expired) { + state = 'expired'; + } else if (unauth) { + state = 'unauthenticated'; + } else if (authed && (result.status === 0 || result.status === null)) { + state = 'valid'; + } else if (result.status === 0) { + state = 'valid'; + } else { + state = 'unknown'; + } + + const status: AuthStatus = { state }; + const days = /expires? in (\d+) days?/.exec(text); + if (state === 'valid' && days) { + status.expiresInDays = Number(days[1]); + } + return status; +} + +function defaultRun(cmd: string, args: string[]): ProxyRunResult { + const r = spawnSync(cmd, args, { encoding: 'utf8' }); + return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; +} + +export function checkAuthStatus(run: CommandRunner = defaultRun): AuthStatus { + return parseAuthStatus(run(CLAUDEX_PROXY_BINARY, buildAuthStatusArgs())); +} + +/** Spawn shape for the interactive device re-auth flow. */ +export type InheritSpawn = ( + cmd: string, + args: string[], + opts: { stdio: 'inherit' }, +) => { status: number | null }; + +function defaultInheritSpawn(cmd: string, args: string[], opts: { stdio: 'inherit' }) { + return spawnSync(cmd, args, opts); +} + +/** + * Run the device-code re-auth flow (`claude-code-proxy codex auth device`). + * + * Deliberately `stdio: 'inherit'` so the device code the proxy prints goes + * straight to the user's terminal — the launcher NEVER captures, stores, or logs + * it, and never observes the resulting OAuth token (the proxy persists that to + * its own config). Returns the child's exit status; 1 on an absent binary. + */ +export function runDeviceReauth(spawnImpl: InheritSpawn = defaultInheritSpawn): number { + const r = spawnImpl(CLAUDEX_PROXY_BINARY, buildDeviceAuthArgs(), { stdio: 'inherit' }); + return r.status ?? 1; +} + +// ─── Liveness (gotcha #1: any HTTP response is alive, NEVER `curl -f`) ──────── + +/** + * Probe the proxy for liveness. ANY resolved HTTP response — including non-2xx — + * means the proxy is up; only a transport/connection failure or a timeout means + * it is down. This is the whole point of gotcha #1: the proxy's root path + * returns non-2xx, so a `curl -f`-style check reports it dead and spawns a + * duplicate proxy. + */ +export async function probeLiveness( + url: string = CLAUDEX_PROXY_URL, + fetchImpl: FetchLike = fetch as unknown as FetchLike, + timeoutMs = 1500, +): Promise { + const controller = new AbortController(); + let timer: ReturnType | undefined; + + // Bound the probe with our own timeout race rather than trusting the fetch + // implementation to honor the abort signal — a hung socket (or a fetch that + // ignores the signal) must never wedge the launcher. We still abort() so a + // signal-aware fetch tears the request down promptly. + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { + controller.abort(); + resolve(false); + }, timeoutMs); + }); + + const probe = fetchImpl(url, { signal: controller.signal }) + .then(() => true) // any response at all → alive + .catch(() => false); // connection refused / aborted → dead + + try { + return await Promise.race([probe, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + +// ─── systemd user unit ─────────────────────────────────────────────────────── + +export function systemdUnitPath(home: string = homedir()): string { + return join(home, '.config', 'systemd', 'user', CLAUDEX_SYSTEMD_UNIT); +} + +/** + * Render the `claude-code-proxy.service` user unit. Contains no credential + * material — the proxy reads its own OAuth token from its config dir at runtime. + */ +export function buildSystemdUnitContent(binaryPath: string): string { + const exec = `${binaryPath} ${buildServeArgs().join(' ')}`; + return [ + '[Unit]', + 'Description=claude-code-proxy (Anthropic->Codex translation proxy for mosaic claudex)', + 'After=network-online.target', + 'Wants=network-online.target', + '', + '[Service]', + 'Type=simple', + `ExecStart=${exec}`, + 'Restart=on-failure', + 'RestartSec=2', + '', + '[Install]', + 'WantedBy=default.target', + '', + ].join('\n'); +} + +/** + * Write the user unit and reload the systemd --user daemon. Returns false when + * systemd --user is unavailable (the caller then falls back to nohup). + */ +export function installSystemdUnit( + binaryPath: string, + deps: { + home?: string; + writeUnit?: (path: string, content: string) => void; + run?: CommandRunner; + } = {}, +): boolean { + const home = deps.home ?? homedir(); + const write = + deps.writeUnit ?? + ((path: string, content: string) => { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + }); + const run = deps.run ?? defaultRun; + + try { + write(systemdUnitPath(home), buildSystemdUnitContent(binaryPath)); + const reload = run('systemctl', ['--user', 'daemon-reload']); + return reload.status === 0; + } catch { + return false; + } +} + +// ─── Preflight report ──────────────────────────────────────────────────────── + +export interface PreflightReport { + binaryPresent: boolean; + binaryPath: string | null; + auth: AuthStatus; + live: boolean; + needsReauth: boolean; + ok: boolean; + problems: string[]; +} + +export interface PreflightDeps { + checkBinary?: () => { present: boolean; path: string | null }; + checkAuth?: () => AuthStatus; + probe?: () => Promise; +} + +/** + * Compose the three preflight checks into a single structured report. `ok` is + * true only when the binary is present, OAuth is valid, and the proxy responds. + */ +export async function runProxyPreflight(deps: PreflightDeps = {}): Promise { + const checkBinary = deps.checkBinary ?? (() => checkProxyBinary()); + const checkAuth = deps.checkAuth ?? (() => checkAuthStatus()); + const probe = deps.probe ?? (() => probeLiveness()); + + const bin = checkBinary(); + const auth = checkAuth(); + const live = await probe(); + + const problems: string[] = []; + if (!bin.present) { + problems.push( + `claude-code-proxy binary not found in PATH. Install it before launching claudex.`, + ); + } + const needsReauth = auth.state === 'expired' || auth.state === 'unauthenticated'; + if (needsReauth) { + problems.push( + `claude-code-proxy OAuth is ${auth.state}. Re-auth with: ${CLAUDEX_PROXY_BINARY} ${buildDeviceAuthArgs().join(' ')}`, + ); + } else if (auth.state === 'unknown') { + problems.push('Could not determine claude-code-proxy OAuth status.'); + } + if (!live) { + problems.push(`No proxy responding on ${CLAUDEX_PROXY_URL}.`); + } + + const ok = bin.present && auth.state === 'valid' && live; + return { + binaryPresent: bin.present, + binaryPath: bin.path, + auth, + live, + needsReauth, + ok, + problems, + }; +} + +// ─── Lifecycle: ensure the proxy is running ────────────────────────────────── + +export type ProxyStartMethod = 'already' | 'systemd' | 'nohup' | 'failed'; + +export interface EnsureProxyResult { + live: boolean; + method: ProxyStartMethod; +} + +export interface EnsureProxyDeps { + probe?: () => Promise; + startSystemd?: () => ProxyRunResult; + startNohup?: () => ProxyRunResult; + waitMs?: (ms: number) => Promise; + settleMs?: number; +} + +function defaultWait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function defaultStartSystemd(): ProxyRunResult { + return defaultRun('systemctl', ['--user', 'start', CLAUDEX_SYSTEMD_UNIT]); +} + +function defaultStartNohup(): ProxyRunResult { + try { + const bin = checkProxyBinary().path ?? CLAUDEX_PROXY_BINARY; + const child = spawn(bin, buildServeArgs(), { + detached: true, + stdio: 'ignore', + }); + child.unref(); + return { status: 0, stdout: '', stderr: '' }; + } catch (err) { + return { status: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Ensure a proxy is listening. No-op when already live. Otherwise prefer the + * systemd user unit, then fall back to a detached background process. Re-probes + * after each start attempt (respecting gotcha #1 via {@link probeLiveness}), so + * it never spawns a duplicate proxy. + */ +export async function ensureProxyRunning(deps: EnsureProxyDeps = {}): Promise { + const probe = deps.probe ?? (() => probeLiveness()); + const startSystemd = deps.startSystemd ?? defaultStartSystemd; + const startNohup = deps.startNohup ?? defaultStartNohup; + const waitMs = deps.waitMs ?? defaultWait; + const settleMs = deps.settleMs ?? 500; + + if (await probe()) { + return { live: true, method: 'already' }; + } + + const systemd = startSystemd(); + if (systemd.status === 0) { + await waitMs(settleMs); + if (await probe()) { + return { live: true, method: 'systemd' }; + } + } + + const nohup = startNohup(); + if (nohup.status === 0) { + await waitMs(settleMs); + if (await probe()) { + return { live: true, method: 'nohup' }; + } + } + + return { live: false, method: 'failed' }; +}