/** * 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' }; }