From 59f5f51ffd98cc2edb6d4799324586d60d16be29 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Thu, 16 Jul 2026 21:15:33 +0000 Subject: [PATCH] feat(mosaic): claudex proxy preflight + lifecycle (P1 of #790) (#793) --- .../mosaic/src/commands/claudex-proxy.spec.ts | 862 ++++++++++++++++++ packages/mosaic/src/commands/claudex-proxy.ts | 700 ++++++++++++++ 2 files changed, 1562 insertions(+) create mode 100644 packages/mosaic/src/commands/claudex-proxy.spec.ts create mode 100644 packages/mosaic/src/commands/claudex-proxy.ts 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..f5c076f --- /dev/null +++ b/packages/mosaic/src/commands/claudex-proxy.spec.ts @@ -0,0 +1,862 @@ +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, + CLAUDEX_HEALTH_PATH, + CLAUDEX_HEALTH_URL, + buildAuthStatusArgs, + buildDeviceAuthArgs, + buildServeArgs, + parseAuthStatus, + checkProxyBinary, + checkAuthStatus, + runDeviceReauth, + probeLiveness, + buildSystemdUnitContent, + systemdUnitPath, + installSystemdUnit, + startNohupProxy, + verifyListenerIdentity, + runProxyPreflight, + ensureProxyRunning, + type AuthStatus, + type ProxyRunResult, + type SpawnedChild, + type ListenerIdentity, +} from './claudex-proxy.js'; + +/** + * P1 — Proxy preflight + lifecycle helpers for `mosaic yolo claudex`. + * + * Security-relevant invariants exercised here: + * - Liveness probe hits the proxy's dedicated `GET /healthz` and treats only a + * 2xx as "alive" — a *proxy-specific* health contract, not arbitrary HTTP on + * the port (CWE-345: a local port-squatter must not be trusted as the proxy). + * This also honors spec gotcha #1 (never `curl -f` the root, which returns + * non-2xx): `/healthz` returns 2xx when the proxy is up, so a healthy proxy is + * never mistaken for dead and no duplicate proxy is spawned. + * - Auth-status parsing NEVER surfaces OAuth token material — only a coarse + * state + optional expiry — even if a token-shaped string appears in output. + * - The systemd unit's ExecStart never interpolates an unvalidated path + * (CWE-74: a CR/LF in the path could inject arbitrary systemd directives). + * - The nohup fallback captures spawn's *async* error event instead of crashing. + */ + +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('exposes the dedicated /healthz liveness endpoint (not the root path)', () => { + expect(CLAUDEX_HEALTH_PATH).toBe('/healthz'); + expect(CLAUDEX_HEALTH_URL).toBe('http://127.0.0.1:18765/healthz'); + }); + + 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('does NOT trust a signal-terminated check (status null) even with an auth-looking line', () => { + // status: null means the process was killed by a signal — an INCOMPLETE + // check. An auth-looking line that happened to be flushed must not be read + // as valid, or preflight passes on a check that never finished. + const s = parseAuthStatus({ status: null, stdout: 'Authenticated', 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 (proxy-specific /healthz, not arbitrary HTTP)', () => { + it('defaults to probing the /healthz endpoint, never the root path', async () => { + const seen: string[] = []; + await probeLiveness(undefined, async (u) => { + seen.push(u); + return { status: 200 }; + }); + expect(seen[0]).toBe(CLAUDEX_HEALTH_URL); + expect(seen[0]).toContain('/healthz'); + }); + + it('treats a 200 on /healthz as alive', async () => { + const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 200 })); + expect(live).toBe(true); + }); + + it('treats a 204 on /healthz as alive', async () => { + const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 204 })); + expect(live).toBe(true); + }); + + it('treats a 404 as DEAD — does not trust an arbitrary responder on the port (CWE-345)', async () => { + // The whole point: a random local process squatting :18765 will not honor the + // proxy's /healthz contract, so a non-2xx there must not be mistaken for the proxy. + const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 404 })); + expect(live).toBe(false); + }); + + it('treats a 500 as DEAD (unhealthy / not the proxy health contract)', async () => { + const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 500 })); + expect(live).toBe(false); + }); + + it('treats a missing status as dead', async () => { + const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({})); + expect(live).toBe(false); + }); + + it('treats a connection failure (reject) as dead', async () => { + const live = await probeLiveness(CLAUDEX_HEALTH_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_HEALTH_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); + }); + + it('rejects a path containing a newline (CWE-74 systemd directive injection)', () => { + // A raw newline in ExecStart would let an attacker append arbitrary unit + // directives — e.g. `ExecStartPost=curl evil`. Must be rejected outright. + expect(() => + buildSystemdUnitContent('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /'), + ).toThrow(); + }); + + it('rejects a path containing a carriage return', () => { + expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\rmalicious')).toThrow(); + }); + + it('rejects a path with other control characters', () => { + expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\x00nul')).toThrow(); + }); + + it('rejects a non-absolute path', () => { + expect(() => buildSystemdUnitContent('claude-code-proxy')).toThrow(); + expect(() => buildSystemdUnitContent('')).toThrow(); + }); + + it('systemd-quotes a path that contains spaces', () => { + const unit = buildSystemdUnitContent('/home/u/my apps/claude-code-proxy'); + expect(unit).toContain('ExecStart="/home/u/my apps/claude-code-proxy" serve --no-monitor'); + }); + + it('escapes embedded quotes and backslashes when quoting', () => { + const unit = buildSystemdUnitContent('/home/u/we"ird\\dir/claude-code-proxy'); + // No unescaped closing quote can terminate the token early. + expect(unit).toContain('ExecStart="/home/u/we\\"ird\\\\dir/claude-code-proxy" serve'); + }); + + it('leaves a clean absolute path unquoted (no needless churn)', () => { + const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy'); + expect(unit).toContain('ExecStart=/home/u/.local/bin/claude-code-proxy serve --no-monitor'); + }); +}); + +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('refuses to write a unit for an injection-bearing path (never writes a poisoned unit)', () => { + const writeUnit = vi.fn(); + const ok = installSystemdUnit('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /', { + home: '/home/u', + writeUnit, + run: () => ({ status: 0, stdout: '', stderr: '' }), + }); + expect(ok).toBe(false); + // The poisoned unit content is never even produced, so nothing is written. + expect(writeUnit).not.toHaveBeenCalled(); + }); + + 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', () => { + const trustedListener = () => 'ok' as const; + + it('is ok when binary present, auth valid, proxy live, and listener identity-verified', async () => { + const report = await runProxyPreflight({ + checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }), + checkAuth: () => ({ state: 'valid' }), + probe: async () => true, + verifyListener: trustedListener, + }); + expect(report.ok).toBe(true); + expect(report.listenerVerdict).toBe('ok'); + 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, + verifyListener: trustedListener, + }); + 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, + verifyListener: trustedListener, + }); + 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, + verifyListener: trustedListener, + }); + 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, + verifyListener: trustedListener, + }); + 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 pass preflight when the live responder fails identity verification (F2b)', async () => { + // A squatter answering /healthz-2xx must not yield ok:true just because the + // binary is installed and OAuth is valid — the identity gate holds here too. + for (const verdict of ['foreign-user', 'wrong-exe', 'unknown'] as const) { + const report = await runProxyPreflight({ + checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }), + checkAuth: () => ({ state: 'valid' }), + probe: async () => true, + verifyListener: () => verdict, + }); + expect(report.ok).toBe(false); + expect(report.live).toBe(true); + expect(report.listenerVerdict).toBe(verdict); + expect(report.problems.some((p) => /identity could not be verified/i.test(p))).toBe(true); + // The identity problem is non-sensitive: port + verdict only, no token. + expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i); + } + }); + + it('does not verify listener identity when the proxy is dead (no listener to trust)', async () => { + const verifyListener = vi.fn(() => 'ok' as const); + const report = await runProxyPreflight({ + checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }), + checkAuth: () => ({ state: 'valid' }), + probe: async () => false, + verifyListener, + }); + expect(verifyListener).not.toHaveBeenCalled(); + expect(report.listenerVerdict).toBe('unknown'); + expect(report.ok).toBe(false); + }); + + 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, + verifyListener: trustedListener, + }); + 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); + }); +}); + +/** + * A minimal fake ChildProcess for the nohup-fallback tests: records once() + * handlers so a test can drive the async 'spawn'/'error' events, and tracks + * whether the 'error' listener was already attached at the moment unref() ran + * (the security-critical ordering from finding #1). + */ +function fakeChild() { + const handlers: Record void> = {}; + const state = { unreffed: false, errorHandlerAtUnref: false }; + const child = { + once(event: string, listener: (arg?: unknown) => void) { + handlers[event] = listener; + return child; + }, + unref() { + state.unreffed = true; + state.errorHandlerAtUnref = typeof handlers.error === 'function'; + }, + emit(event: string, arg?: unknown) { + handlers[event]?.(arg); + }, + }; + return { + child: child as unknown as SpawnedChild & { emit(e: string, a?: unknown): void }, + state, + }; +} + +describe('startNohupProxy (finding #1 — async spawn error must not crash)', () => { + it('resolves status 0 only after a confirmed spawn, and unrefs the child', async () => { + const { child, state } = fakeChild(); + const spawnImpl = vi.fn((_cmd: string, _args: string[]) => { + queueMicrotask(() => child.emit('spawn')); + return child; + }); + const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl }); + expect(r.status).toBe(0); + expect(state.unreffed).toBe(true); + // The error listener MUST be registered before unref(), so an ENOENT that + // arrives asynchronously can never become an unhandled 'error' crash. + expect(state.errorHandlerAtUnref).toBe(true); + expect(spawnImpl).toHaveBeenCalledWith('/bin/claude-code-proxy', ['serve', '--no-monitor'], { + detached: true, + stdio: 'ignore', + }); + }); + + it('captures an async spawn error (ENOENT) as a failed start instead of crashing', async () => { + const { child, state } = fakeChild(); + const spawnImpl = () => { + queueMicrotask(() => child.emit('error', new Error('spawn claude-code-proxy ENOENT'))); + return child; + }; + const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl }); + expect(r.status).toBe(1); + expect(r.stderr).toContain('ENOENT'); + expect(state.unreffed).toBe(false); // never unref a child that failed to start + }); + + it('captures a synchronous spawn throw as a failed start', async () => { + const spawnImpl = () => { + throw new Error('EACCES'); + }; + const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl }); + expect(r.status).toBe(1); + expect(r.stderr).toContain('EACCES'); + }); + + it('ignores a late error after a successful spawn (settles once)', async () => { + const { child } = fakeChild(); + const spawnImpl = () => { + queueMicrotask(() => { + child.emit('spawn'); + child.emit('error', new Error('late boom')); + }); + return child; + }; + const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl }); + expect(r.status).toBe(0); // first settle wins; the late error cannot flip it + }); +}); + +describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE-345)', () => { + const me: ListenerIdentity = { + pid: 4242, + uid: 1000, + exePath: '/home/me/.local/bin/claude-code-proxy', + }; + // Identity canonicalize for tests: fake paths don't exist on disk, so we map + // each path to itself and exercise symlink resolution explicitly where needed. + const idc = (p: string) => p; + + it('accepts a listener owned by the current uid whose exe is the expected proxy path', () => { + const verdict = verifyListenerIdentity({ + identify: () => me, + currentUid: () => 1000, + expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: idc, + }); + expect(verdict).toBe('ok'); + }); + + it('resolves symlinks on BOTH sides before comparing (canonical match → ok)', () => { + // The listener exe and our resolved binary reach the same real file via + // different symlink paths — a canonical comparison must accept it. + const canon: Record = { + '/var/run/proxy.link': '/opt/proxy/bin/claude-code-proxy', + '/home/me/.local/bin/claude-code-proxy': '/opt/proxy/bin/claude-code-proxy', + }; + const verdict = verifyListenerIdentity({ + identify: () => ({ ...me, exePath: '/var/run/proxy.link' }), + currentUid: () => 1000, + expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: (p) => canon[p] ?? null, + }); + expect(verdict).toBe('ok'); + }); + + it('does NOT trust a same-uid process at the WRONG path with the right basename (F2a)', () => { + // The squatter vector on a shared-uid host: right basename, wrong path. The + // basename must NEVER be a trust signal when an expected exact path resolved. + const verdict = verifyListenerIdentity({ + identify: () => ({ ...me, exePath: '/tmp/claude-code-proxy' }), + currentUid: () => 1000, + expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: idc, + }); + expect(verdict).toBe('wrong-exe'); + }); + + it('fails closed (unknown) when our own proxy binary path cannot be resolved (F2a)', () => { + // No expected path → we cannot assert identity → refuse to trust (no basename + // acceptance). Previously this returned `ok` by basename; that was a bypass. + const verdict = verifyListenerIdentity({ + identify: () => me, + currentUid: () => 1000, + expectedExe: () => null, + canonicalize: idc, + }); + expect(verdict).toBe('unknown'); + }); + + it('fails closed (unknown) when a path cannot be canonicalized (F2a)', () => { + const verdict = verifyListenerIdentity({ + identify: () => me, + currentUid: () => 1000, + expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: () => null, // e.g. binary deleted out from under the listener + }); + expect(verdict).toBe('unknown'); + }); + + it('rejects a listener owned by a DIFFERENT uid (foreign-user) — fail closed', () => { + const verdict = verifyListenerIdentity({ + identify: () => ({ ...me, uid: 0 }), + currentUid: () => 1000, + expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: idc, + }); + expect(verdict).toBe('foreign-user'); + }); + + it('rejects a same-user listener whose exe is NOT the proxy (wrong-exe)', () => { + const verdict = verifyListenerIdentity({ + identify: () => ({ ...me, exePath: '/usr/bin/nc' }), + currentUid: () => 1000, + expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: idc, + }); + expect(verdict).toBe('wrong-exe'); + }); + + it('returns unknown (fail closed) when the listener cannot be identified', () => { + const verdict = verifyListenerIdentity({ + identify: () => null, + currentUid: () => 1000, + expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: idc, + }); + expect(verdict).toBe('unknown'); + }); + + it('returns unknown when the current uid is unavailable (non-posix)', () => { + const verdict = verifyListenerIdentity({ + identify: () => me, + currentUid: () => -1, + expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: idc, + }); + expect(verdict).toBe('unknown'); + }); + + it('returns unknown when the listener exe path cannot be read', () => { + const verdict = verifyListenerIdentity({ + identify: () => ({ ...me, exePath: null }), + currentUid: () => 1000, + expectedExe: () => '/home/me/.local/bin/claude-code-proxy', + canonicalize: idc, + }); + expect(verdict).toBe('unknown'); + }); + + it('runs with real defaults without throwing (identity may be unresolved → verdict)', () => { + const verdict = verifyListenerIdentity(); + expect(['ok', 'foreign-user', 'wrong-exe', 'unknown']).toContain(verdict); + }); +}); + +describe('ensureProxyRunning', () => { + const ok: ProxyRunResult = { status: 0, stdout: '', stderr: '' }; + const nohupOk = async (): Promise => ok; + const trusted = () => 'ok' as const; + + it('is a no-op when the proxy is already live AND identity-verified', async () => { + const startSystemd = vi.fn(() => ok); + const startNohup = vi.fn(nohupOk); + const r = await ensureProxyRunning({ + probe: async () => true, + verifyListener: trusted, + startSystemd, + startNohup, + waitMs: async () => {}, + }); + expect(r.method).toBe('already'); + expect(r.live).toBe(true); + expect(startSystemd).not.toHaveBeenCalled(); + expect(startNohup).not.toHaveBeenCalled(); + }); + + it('fails closed as untrusted when a responder holds :18765 but identity is NOT ours', async () => { + // A foreign process answers /healthz but the listener is not our proxy + // (foreign uid / wrong exe / unidentifiable). We must NOT trust it and must + // NOT start a second proxy (the port is already taken) — fail closed. + const startSystemd = vi.fn(() => ok); + const startNohup = vi.fn(nohupOk); + const r = await ensureProxyRunning({ + probe: async () => true, + verifyListener: () => 'foreign-user', + startSystemd, + startNohup, + waitMs: async () => {}, + }); + expect(r.method).toBe('untrusted'); + expect(r.live).toBe(false); + expect(startSystemd).not.toHaveBeenCalled(); + expect(startNohup).not.toHaveBeenCalled(); + }); + + it('starts via systemd when available and then becomes trusted-live', async () => { + let calls = 0; + const r = await ensureProxyRunning({ + probe: async () => calls++ > 0, // dead first, live after start + verifyListener: trusted, + startSystemd: () => ok, + startNohup: async () => { + throw new Error('should not fall back'); + }, + waitMs: async () => {}, + }); + expect(r.method).toBe('systemd'); + expect(r.live).toBe(true); + }); + + it('waits past a slow systemd bind before giving up (finding #2 — no duplicate proxy)', async () => { + // systemd `start` returns 0 (job accepted) but the socket only binds on the + // 4th probe — still well within the startup deadline. nohup must NOT run, + // or two proxies would contend for :18765. + let probes = 0; + const startNohup = vi.fn(nohupOk); + const r = await ensureProxyRunning({ + probe: async () => probes++ >= 3, + verifyListener: trusted, + startSystemd: () => ok, + startNohup, + waitMs: async () => {}, + settleMs: 10, + startupDeadlineMs: 200, + }); + expect(r.method).toBe('systemd'); + expect(r.live).toBe(true); + expect(startNohup).not.toHaveBeenCalled(); + }); + + it('does NOT fall back to nohup after systemd accepts but never binds (finding #1 — dup-proxy race)', async () => { + // systemctl start exit 0 means the job was ACCEPTED, not bound. If it binds + // just after our deadline (or systemd restarts it), a nohup fallback would + // create a SECOND proxy contending for :18765. Once systemd has accepted the + // job we never spawn nohup — we report a managed-service startup failure. + const startNohup = vi.fn(nohupOk); + const r = await ensureProxyRunning({ + probe: async () => false, // never becomes live within the deadline + verifyListener: trusted, + startSystemd: () => ok, + startNohup, + waitMs: async () => {}, + settleMs: 10, + startupDeadlineMs: 30, + }); + expect(startNohup).not.toHaveBeenCalled(); + expect(r.method).toBe('failed'); + expect(r.live).toBe(false); + }); + + it('does NOT trust a systemd-started responder whose identity cannot be verified', async () => { + // Dead at first (so we reach the systemd start), then the socket binds — but + // identity never verifies (e.g. a squatter beat systemd to the port). A live + // responder that fails identity must never be reported as a successful start. + let calls = 0; + const startNohup = vi.fn(nohupOk); + const r = await ensureProxyRunning({ + probe: async () => calls++ > 0, + verifyListener: () => 'wrong-exe', + startSystemd: () => ok, + startNohup, + waitMs: async () => {}, + settleMs: 10, + startupDeadlineMs: 30, + }); + expect(startNohup).not.toHaveBeenCalled(); + expect(r.method).toBe('failed'); + expect(r.live).toBe(false); + }); + + it('falls back to nohup only when systemd start FAILS outright (not accepted)', async () => { + let calls = 0; + const r = await ensureProxyRunning({ + // A failed systemd start skips its post-start poll, so probes are: + // #0 initial (dead), #1 after nohup (live). nohup fallback is reachable + // ONLY because systemd never accepted the job (status 1). + probe: async () => calls++ > 0, + verifyListener: trusted, + startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }), + startNohup: nohupOk, + waitMs: async () => {}, + settleMs: 10, + startupDeadlineMs: 30, + }); + expect(r.method).toBe('nohup'); + expect(r.live).toBe(true); + }); + + it('does NOT trust a nohup-started responder whose identity cannot be verified', async () => { + let calls = 0; + const r = await ensureProxyRunning({ + probe: async () => calls++ > 0, + verifyListener: () => 'unknown', + startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }), + startNohup: nohupOk, + waitMs: async () => {}, + settleMs: 10, + startupDeadlineMs: 30, + }); + expect(r.method).toBe('failed'); + expect(r.live).toBe(false); + }); + + it('reports failed when nothing brings the proxy up', async () => { + const r = await ensureProxyRunning({ + probe: async () => false, + verifyListener: trusted, + startSystemd: () => ({ status: 1, stdout: '', stderr: '' }), + startNohup: async () => ({ status: 1, stdout: '', stderr: '' }), + waitMs: async () => {}, + settleMs: 10, + startupDeadlineMs: 30, + }); + 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..cd867ec --- /dev/null +++ b/packages/mosaic/src/commands/claudex-proxy.ts @@ -0,0 +1,700 @@ +/** + * 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, readFileSync, readlinkSync, realpathSync, 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'; + +/** + * The proxy's dedicated liveness endpoint. We probe this — NOT the root path — + * for two reasons: (1) the root returns non-2xx (spec gotcha #1), which is why + * the original `curl -f` check spawned duplicate proxies; `/healthz` returns 2xx + * when the proxy is healthy. (2) It is a *proxy-specific* contract, so a 2xx here + * is a much stronger signal that the responder on :18765 is actually our proxy + * and not some other local process squatting the port (CWE-345). + */ +export const CLAUDEX_HEALTH_PATH = '/healthz'; +export const CLAUDEX_HEALTH_URL = `${CLAUDEX_PROXY_URL}${CLAUDEX_HEALTH_PATH}`; + +/** 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) { + // A `null` status means the check was killed by a signal — an INCOMPLETE + // run. We require a clean exit 0 for `valid`; a partially-flushed auth line + // from a signal-terminated check must never be trusted (finding #3). + 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 (probe the proxy-specific /healthz; require 2xx) ──────────────── + +/** + * Probe the proxy for liveness by hitting its dedicated `GET /healthz` endpoint + * and requiring a 2xx response. + * + * This is a LIVENESS check only — it answers "is a healthy proxy responding?", + * not "is that responder actually ours?". Requiring a 2xx on the proxy's own + * `/healthz` contract (rather than "any HTTP response = alive") resolves spec + * gotcha #1: the root path returns non-2xx, but `/healthz` returns 2xx when + * healthy, so a live proxy is never mistaken for dead and no duplicate proxy is + * spawned. + * + * Residual risk (CWE-345): the proxy binds loopback with NO client + * authentication, so on a shared host a local process could occupy :18765 and + * serve a 2xx here. A 2xx therefore does NOT by itself establish that the + * listener is our proxy. Identity is verified SEPARATELY and at every trust + * point by {@link verifyListenerIdentity} (OS-level uid + executable check), + * which fails closed when identity can't be established. See + * {@link ensureProxyRunning}. (Broader multi-user hardening — a persistent + * warning when a foreign listener is seen — is tracked for a later phase.) + */ +export async function probeLiveness( + url: string = CLAUDEX_HEALTH_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((res) => typeof res.status === 'number' && res.status >= 200 && res.status < 300) + .catch(() => false); // connection refused / aborted → dead + + try { + return await Promise.race([probe, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + +// ─── Listener identity (OS-level, CWE-345 mitigation) ───────────────────────── + +/** + * The result of verifying who actually owns the :18765 listener. + * - `ok` — same-user process running the expected proxy binary. + * - `foreign-user` — a process owned by a DIFFERENT uid holds the port. + * - `wrong-exe` — same-user, but the executable is not the proxy. + * - `unknown` — identity could not be established (fail closed). + */ +export type ListenerVerdict = 'ok' | 'foreign-user' | 'wrong-exe' | 'unknown'; + +/** OS-level identity of the process bound to the proxy port. */ +export interface ListenerIdentity { + pid: number; + uid: number; + /** Absolute path of the process executable, or null if unreadable. */ + exePath: string | null; +} + +export interface VerifyListenerDeps { + /** Resolve the process bound to the proxy port (null → unidentifiable). */ + identify?: () => ListenerIdentity | null; + /** The current process uid (-1 when unavailable, e.g. non-posix). */ + currentUid?: () => number; + /** The expected proxy executable path (null when it can't be resolved). */ + expectedExe?: () => string | null; + /** Canonicalize a path (resolve symlinks); null when it can't be resolved. */ + canonicalize?: (p: string) => string | null; +} + +/** Resolve a path through symlinks to its canonical form; null on any failure. */ +function defaultCanonicalize(p: string): string | null { + try { + return realpathSync(p); + } catch { + return null; + } +} + +/** + * Identify the process listening on the proxy port via `ss` + `/proc`. Every + * failure path returns null so the caller fails closed. Reads no credential + * material — only pid/uid/exe path of the listener. + */ +function defaultIdentifyListener(port: number = CLAUDEX_PROXY_PORT): ListenerIdentity | null { + try { + const out = execFileSync('ss', ['-H', '-ltnp', `sport = :${port}`], { encoding: 'utf8' }); + const pidMatch = /pid=(\d+)/.exec(out); + if (!pidMatch) return null; + const pid = Number(pidMatch[1]); + if (!Number.isInteger(pid) || pid <= 0) return null; + + const status = readFileSync(`/proc/${pid}/status`, 'utf8'); + const uidLine = /^Uid:\s*(\d+)/m.exec(status); + if (!uidLine) return null; + const uid = Number(uidLine[1]); + + let exePath: string | null = null; + try { + exePath = readlinkSync(`/proc/${pid}/exe`); + } catch { + exePath = null; + } + return { pid, uid, exePath }; + } catch { + return null; + } +} + +/** + * Verify that the process owning :18765 is genuinely OUR proxy before trusting + * it. The proxy binds loopback with NO client authentication, so on a shared + * host any local process could squat the port and a liveness 2xx alone does not + * prove identity (CWE-345). We FAIL CLOSED (`unknown`) whenever identity cannot + * be established. This needs no upstream shared-secret/unix-socket support from + * `claude-code-proxy`. + * + * The executable path is the trust boundary that matters: on a shared-uid host + * (every agent session runs as the same operator) same-uid is NOT sufficient, so + * we require an EXACT canonical-path match against our resolved proxy binary and + * canonicalize both sides for symlinks. There is deliberately NO basename + * fallback — a same-uid process running `/tmp/claude-code-proxy` (right name, + * wrong path) must never be trusted. If our own binary path can't be resolved, + * or either path can't be canonicalized, we fail closed rather than downgrade to + * a weaker check. + */ +export function verifyListenerIdentity(deps: VerifyListenerDeps = {}): ListenerVerdict { + const identify = deps.identify ?? (() => defaultIdentifyListener()); + const currentUid = + deps.currentUid ?? (() => (typeof process.getuid === 'function' ? process.getuid() : -1)); + const expectedExe = deps.expectedExe ?? (() => checkProxyBinary().path); + const canonicalize = deps.canonicalize ?? defaultCanonicalize; + + const id = identify(); + if (!id) return 'unknown'; // can't see the listener → don't trust it + const uid = currentUid(); + if (uid < 0) return 'unknown'; // can't establish our own identity → fail closed + if (id.uid !== uid) return 'foreign-user'; // someone else's process holds the port + if (!id.exePath) return 'unknown'; // can't confirm the executable → fail closed + + const expected = expectedExe(); + if (!expected) return 'unknown'; // can't resolve our own binary → fail closed + const expectedReal = canonicalize(expected); + const actualReal = canonicalize(id.exePath); + if (!expectedReal || !actualReal) return 'unknown'; // uncanonicalizable → fail closed + return actualReal === expectedReal ? 'ok' : 'wrong-exe'; +} + +// ─── systemd user unit ─────────────────────────────────────────────────────── + +export function systemdUnitPath(home: string = homedir()): string { + return join(home, '.config', 'systemd', 'user', CLAUDEX_SYSTEMD_UNIT); +} + +/** + * Validate a path destined for a systemd `ExecStart=` line. A raw newline (or + * other control character) in the path would let an attacker inject arbitrary + * unit directives (e.g. an extra `ExecStartPost=`), a CWE-74 command injection. + * We require a plain absolute path and reject any control character outright. + */ +function validateExecPath(binaryPath: string): string { + if (typeof binaryPath !== 'string' || binaryPath.length === 0) { + throw new Error('systemd ExecStart: binary path is empty'); + } + if (!binaryPath.startsWith('/')) { + throw new Error( + `systemd ExecStart: binary path must be absolute: ${JSON.stringify(binaryPath)}`, + ); + } + + if (/[\x00-\x1f\x7f]/.test(binaryPath)) { + throw new Error('systemd ExecStart: binary path contains control characters'); + } + return binaryPath; +} + +/** + * Encode a validated path for a systemd `ExecStart=` token. systemd only needs + * quoting when the token carries whitespace or quote/backslash characters; a + * clean path is emitted verbatim. When quoting, we escape backslashes and double + * quotes per systemd's C-style rules so the token cannot be terminated early. + */ +function systemdQuoteExec(path: string): string { + if (!/[\s"'\\]/.test(path)) { + return path; + } + const escaped = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escaped}"`; +} + +/** + * 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. + * The binary path is validated (absolute, no control characters) and systemd- + * quoted so it cannot inject unit directives. + */ +export function buildSystemdUnitContent(binaryPath: string): string { + const exec = `${systemdQuoteExec(validateExecPath(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; + /** OS-level identity verdict for the :18765 listener (`unknown` when dead). */ + listenerVerdict: ListenerVerdict; + needsReauth: boolean; + ok: boolean; + problems: string[]; +} + +export interface PreflightDeps { + checkBinary?: () => { present: boolean; path: string | null }; + checkAuth?: () => AuthStatus; + probe?: () => Promise; + verifyListener?: () => ListenerVerdict; +} + +/** + * Compose the preflight checks into a single structured report. `ok` is true + * only when the binary is present, OAuth is valid, the proxy responds, AND the + * responding listener's OS-level identity verifies as our proxy. + * + * The identity gate lives here too, not only in {@link ensureProxyRunning}: any + * consumer of this report (notably the phase-2 launch path) would otherwise + * treat a `/healthz`-2xx squatter as healthy and route Claude traffic to it + * (CWE-345). A liveness 2xx is necessary but not sufficient — a live responder + * that fails identity fails the preflight. + */ +export async function runProxyPreflight(deps: PreflightDeps = {}): Promise { + const checkBinary = deps.checkBinary ?? (() => checkProxyBinary()); + const checkAuth = deps.checkAuth ?? (() => checkAuthStatus()); + const probe = deps.probe ?? (() => probeLiveness()); + const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity()); + + const bin = checkBinary(); + const auth = checkAuth(); + const live = await probe(); + // Only meaningful when something is actually responding; a dead port has no + // listener identity to establish. + const listenerVerdict: ListenerVerdict = live ? verifyListener() : 'unknown'; + + 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}.`); + } else if (listenerVerdict !== 'ok') { + // Non-sensitive: names the port and the verdict only — never any listener + // command line, token, or other process detail. + problems.push( + `A process is listening on ${CLAUDEX_PROXY_URL} but its identity could not be verified as ${CLAUDEX_PROXY_BINARY} (${listenerVerdict}). Refusing to trust it.`, + ); + } + + const ok = bin.present && auth.state === 'valid' && live && listenerVerdict === 'ok'; + return { + binaryPresent: bin.present, + binaryPath: bin.path, + auth, + live, + listenerVerdict, + needsReauth, + ok, + problems, + }; +} + +// ─── Lifecycle: ensure the proxy is running ────────────────────────────────── + +export type ProxyStartMethod = 'already' | 'systemd' | 'nohup' | 'untrusted' | 'failed'; + +export interface EnsureProxyResult { + live: boolean; + method: ProxyStartMethod; +} + +/** Minimal spawned-child shape used by the nohup fallback (testable seam). */ +export interface SpawnedChild { + once(event: string, listener: (arg?: unknown) => void): unknown; + unref(): void; +} + +/** Spawn shape for the detached fallback process. */ +export type SpawnLike = ( + cmd: string, + args: string[], + opts: { detached: boolean; stdio: 'ignore' }, +) => SpawnedChild; + +export interface StartNohupDeps { + resolveBin?: () => string; + spawnImpl?: SpawnLike; +} + +/** + * Start the proxy as a detached background process (the fallback when no systemd + * user unit is available). + * + * `spawn()` reports launch failures (ENOENT/EACCES) ASYNCHRONOUSLY via the + * child's `error` event, which a `try/catch` cannot see. If left unhandled that + * event throws and crashes the launcher. So we: (1) attach the `error` listener + * BEFORE `unref()`, capturing a failed launch as a non-zero result instead of a + * crash; and (2) resolve success only after the child's `spawn` event fires — + * never optimistically before the process is known to have started. + */ +export function startNohupProxy(deps: StartNohupDeps = {}): Promise { + const resolveBin = deps.resolveBin ?? (() => checkProxyBinary().path ?? CLAUDEX_PROXY_BINARY); + const spawnImpl = + deps.spawnImpl ?? ((cmd, args, opts) => spawn(cmd, args, opts) as unknown as SpawnedChild); + + return new Promise((resolve) => { + let settled = false; + const finish = (r: ProxyRunResult) => { + if (!settled) { + settled = true; + resolve(r); + } + }; + + let child: SpawnedChild; + try { + child = spawnImpl(resolveBin(), buildServeArgs(), { detached: true, stdio: 'ignore' }); + } catch (err) { + finish({ status: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }); + return; + } + + // Register error handling BEFORE unref so an async spawn failure is caught. + child.once('error', (err) => { + finish({ + status: 1, + stdout: '', + stderr: err instanceof Error ? err.message : String(err), + }); + }); + child.once('spawn', () => { + child.unref(); + finish({ status: 0, stdout: '', stderr: '' }); + }); + }); +} + +export interface EnsureProxyDeps { + probe?: () => Promise; + /** OS-level identity check for the process holding the proxy port. */ + verifyListener?: () => ListenerVerdict; + startSystemd?: () => ProxyRunResult; + startNohup?: () => Promise; + waitMs?: (ms: number) => Promise; + /** Interval between liveness polls while waiting for a start to bind. */ + settleMs?: number; + /** Total budget to wait for a started proxy to bind its socket. */ + startupDeadlineMs?: number; +} + +function defaultWait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function defaultStartSystemd(): ProxyRunResult { + return defaultRun('systemctl', ['--user', 'start', CLAUDEX_SYSTEMD_UNIT]); +} + +/** + * Poll for a TRUSTED-live proxy up to a bounded startup deadline. A start command + * returning 0 only means the job was ACCEPTED, not that the socket is bound — so + * we keep probing at `intervalMs` until either the deadline elapses or the port + * both responds AND passes the OS-level identity check. Liveness alone is not + * enough: a responder that fails identity (a squatter) must never be trusted. + */ +async function waitForTrusted( + probe: () => Promise, + verifyListener: () => ListenerVerdict, + waitMs: (ms: number) => Promise, + intervalMs: number, + deadlineMs: number, +): Promise { + let elapsed = 0; + while (elapsed < deadlineMs) { + await waitMs(intervalMs); + elapsed += intervalMs; + if ((await probe()) && verifyListener() === 'ok') { + return true; + } + } + return false; +} + +/** + * Ensure a proxy is listening. No-op when already live. Otherwise prefer the + * systemd user unit, then fall back to a detached background process. + * + * Every trust point is gated on OS-level listener identity, not just liveness: + * the proxy has no client authentication, so on a shared host a local process + * could squat :18765 and a 2xx `/healthz` alone would not prove it is our proxy + * (CWE-345, finding #2). We only trust a responder whose owning process is the + * current uid running the expected proxy binary; otherwise we fail closed. + * + * If a responder is already present but its identity does NOT verify, we return + * `untrusted` WITHOUT starting anything — the port is taken, so spawning would + * only create contention, and we must never route Claude traffic through an + * unverified listener. + * + * After a start command is accepted we poll to a bounded startup deadline before + * giving up: `systemctl start` exit 0 means the job was accepted, not that the + * socket bound within one probe interval. Critically, once systemd ACCEPTS the + * job we do NOT fall back to nohup even if it never becomes trusted-live in the + * deadline (finding #1): the accepted unit may bind late or be restarted by + * systemd, and a second proxy would then contend for :18765 — the very + * duplicate-proxy outcome this function exists to prevent. nohup is reachable + * only when systemd never accepted the job at all. + */ +export async function ensureProxyRunning(deps: EnsureProxyDeps = {}): Promise { + const probe = deps.probe ?? (() => probeLiveness()); + const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity()); + const startSystemd = deps.startSystemd ?? defaultStartSystemd; + const startNohup = deps.startNohup ?? (() => startNohupProxy()); + const waitMs = deps.waitMs ?? defaultWait; + const settleMs = deps.settleMs ?? 500; + const startupDeadlineMs = deps.startupDeadlineMs ?? 5000; + + if (await probe()) { + // Something answers on :18765 — trust it ONLY if it is provably our proxy. + return verifyListener() === 'ok' + ? { live: true, method: 'already' } + : { live: false, method: 'untrusted' }; + } + + const systemd = startSystemd(); + if (systemd.status === 0) { + // systemd accepted the job. Wait for a trusted-live bind, but never fall + // back to nohup afterward — that would risk a duplicate proxy (finding #1). + if (await waitForTrusted(probe, verifyListener, waitMs, settleMs, startupDeadlineMs)) { + return { live: true, method: 'systemd' }; + } + return { live: false, method: 'failed' }; + } + + const nohup = await startNohup(); + if (nohup.status === 0) { + if (await waitForTrusted(probe, verifyListener, waitMs, settleMs, startupDeadlineMs)) { + return { live: true, method: 'nohup' }; + } + } + + return { live: false, method: 'failed' }; +}