701 lines
27 KiB
TypeScript
701 lines
27 KiB
TypeScript
/**
|
|
* 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<boolean> {
|
|
const controller = new AbortController();
|
|
let timer: ReturnType<typeof setTimeout> | 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<boolean>((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<boolean>;
|
|
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<PreflightReport> {
|
|
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<ProxyRunResult> {
|
|
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<ProxyRunResult>((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<boolean>;
|
|
/** OS-level identity check for the process holding the proxy port. */
|
|
verifyListener?: () => ListenerVerdict;
|
|
startSystemd?: () => ProxyRunResult;
|
|
startNohup?: () => Promise<ProxyRunResult>;
|
|
waitMs?: (ms: number) => Promise<void>;
|
|
/** 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<void> {
|
|
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<boolean>,
|
|
verifyListener: () => ListenerVerdict,
|
|
waitMs: (ms: number) => Promise<void>,
|
|
intervalMs: number,
|
|
deadlineMs: number,
|
|
): Promise<boolean> {
|
|
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<EnsureProxyResult> {
|
|
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' };
|
|
}
|