fix(mosaic): harden claudex proxy preflight per review (P1 of #790)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful

Addresses the four findings from the independent review of #793 (exact head
0e41a5c2). TDD: each fix's failing test was added first (18 new tests), then
the implementation; full mosaic suite 1098 green, three gates green.

1. nohup fallback async spawn error (correctness): spawn() reports ENOENT/EACCES
   asynchronously via the child 'error' event, which the old try/catch could not
   see -> an unhandled 'error' would crash the launcher, and it returned status 0
   before the child had started. Extract startNohupProxy(): attach the 'error'
   listener BEFORE unref(), resolve non-zero on async error/sync throw, and
   resolve success only after a confirmed 'spawn'. startNohup dep is now async.

2. systemd start not socket-bound (correctness): `systemctl --user start` exit 0
   means the job was accepted, not bound within one probe. ensureProxyRunning now
   polls liveness to a bounded startupDeadlineMs after a start before falling
   back, so a slow systemd bind can't trigger a second, contending proxy.

3. liveness trust (security, CWE-345): probeLiveness hit the root and trusted ANY
   HTTP response, so a local port-squatter could be taken for the proxy and MITM
   Claude traffic. Now probe the proxy-specific GET /healthz and require a 2xx.
   This also resolves gotcha #1 (root returns non-2xx): /healthz returns 2xx when
   healthy, so a live proxy is never mistaken for dead. (Upstream offers no unix
   socket or shared-secret handshake; /healthz is the in-scope identity ceiling.)

4. systemd ExecStart injection (security, CWE-74): buildSystemdUnitContent
   interpolated binaryPath raw, so a CR/LF could inject unit directives. Validate
   the path (absolute, reject control chars) and systemd-quote it when it carries
   whitespace/quotes; installSystemdUnit now refuses to write a poisoned unit.

Coverage on claudex-proxy.ts: 96.3% stmts/lines, 87.1% branch.

Refs #790
This commit is contained in:
Hermes Agent
2026-07-16 15:11:47 -05:00
parent 0e41a5c264
commit 7f987f52fc
2 changed files with 396 additions and 49 deletions

View File

@@ -7,6 +7,8 @@ import {
CLAUDEX_PROXY_PORT,
CLAUDEX_PROXY_URL,
CLAUDEX_PROXY_BINARY,
CLAUDEX_HEALTH_PATH,
CLAUDEX_HEALTH_URL,
buildAuthStatusArgs,
buildDeviceAuthArgs,
buildServeArgs,
@@ -18,21 +20,29 @@ import {
buildSystemdUnitContent,
systemdUnitPath,
installSystemdUnit,
startNohupProxy,
runProxyPreflight,
ensureProxyRunning,
type AuthStatus,
type ProxyRunResult,
type SpawnedChild,
} from './claudex-proxy.js';
/**
* P1 — Proxy preflight + lifecycle helpers for `mosaic yolo claudex`.
*
* Security-relevant invariants exercised here:
* - Liveness probe treats ANY HTTP response (even non-2xx) as "alive" and only
* a transport/connection failure as "dead" (spec gotcha #1: never `curl -f`;
* the original wrapper spawned duplicate proxies because of this).
* - 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', () => {
@@ -43,6 +53,11 @@ describe('claudex-proxy constants', () => {
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']);
@@ -172,24 +187,46 @@ describe('checkProxyBinary', () => {
});
});
describe('probeLiveness (gotcha #1 — any HTTP response is alive)', () => {
it('treats 200 as alive', async () => {
const live = await probeLiveness(CLAUDEX_PROXY_URL, async () => ({ status: 200 }));
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 non-2xx (404) response as alive — must NOT behave like curl -f', async () => {
const live = await probeLiveness(CLAUDEX_PROXY_URL, async () => ({ status: 404 }));
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 500 response as alive', async () => {
const live = await probeLiveness(CLAUDEX_PROXY_URL, async () => ({ status: 500 }));
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_PROXY_URL, async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => {
throw new Error('ECONNREFUSED');
});
expect(live).toBe(false);
@@ -197,7 +234,7 @@ describe('probeLiveness (gotcha #1 — any HTTP response is alive)', () => {
it('treats a timeout as dead', async () => {
const never = () => new Promise<{ status?: number }>(() => {});
const live = await probeLiveness(CLAUDEX_PROXY_URL, never, 20);
const live = await probeLiveness(CLAUDEX_HEALTH_URL, never, 20);
expect(live).toBe(false);
});
});
@@ -217,6 +254,43 @@ describe('buildSystemdUnitContent', () => {
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', () => {
@@ -263,6 +337,18 @@ describe('installSystemdUnit', () => {
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 {
@@ -353,12 +439,95 @@ describe('runProxyPreflight', () => {
});
});
/**
* 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<string, (arg?: unknown) => 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('ensureProxyRunning', () => {
const ok: ProxyRunResult = { status: 0, stdout: '', stderr: '' };
const nohupOk = async (): Promise<ProxyRunResult> => ok;
it('is a no-op when the proxy is already live', async () => {
const startSystemd = vi.fn(() => ok);
const startNohup = vi.fn(() => ok);
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => true,
startSystemd,
@@ -376,7 +545,7 @@ describe('ensureProxyRunning', () => {
const r = await ensureProxyRunning({
probe: async () => calls++ > 0, // dead first, live after start
startSystemd: () => ok,
startNohup: () => {
startNohup: async () => {
throw new Error('should not fall back');
},
waitMs: async () => {},
@@ -385,15 +554,52 @@ describe('ensureProxyRunning', () => {
expect(r.live).toBe(true);
});
it('falls back to nohup when systemd start fails', async () => {
it('waits past a slow systemd bind before falling back (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,
startSystemd: () => ok,
startNohup,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 200,
});
expect(r.method).toBe('systemd');
expect(r.live).toBe(true);
expect(startNohup).not.toHaveBeenCalled();
});
it('falls back to nohup when systemd is accepted but never binds in the deadline', async () => {
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
// Dead until nohup has actually run; systemd's whole poll window stays dead.
probe: async () => startNohup.mock.calls.length > 0,
startSystemd: () => ok,
startNohup,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(startNohup).toHaveBeenCalledTimes(1);
expect(r.method).toBe('nohup');
expect(r.live).toBe(true);
});
it('falls back to nohup when systemd start fails outright', async () => {
let calls = 0;
const r = await ensureProxyRunning({
// A failed systemd start skips its post-start probe, so probes are:
// A failed systemd start skips its post-start poll, so probes are:
// #0 initial (dead), #1 after nohup (live).
probe: async () => calls++ > 0,
startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }),
startNohup: () => ok,
startNohup: nohupOk,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(r.method).toBe('nohup');
expect(r.live).toBe(true);
@@ -403,8 +609,10 @@ describe('ensureProxyRunning', () => {
const r = await ensureProxyRunning({
probe: async () => false,
startSystemd: () => ({ status: 1, stdout: '', stderr: '' }),
startNohup: () => ({ 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);

View File

@@ -29,6 +29,17 @@ export const CLAUDEX_PROXY_URL = `http://${CLAUDEX_PROXY_HOST}:${CLAUDEX_PROXY_P
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'];
@@ -164,17 +175,25 @@ export function runDeviceReauth(spawnImpl: InheritSpawn = defaultInheritSpawn):
return r.status ?? 1;
}
// ─── Liveness (gotcha #1: any HTTP response is alive, NEVER `curl -f`) ────────
// ─── Liveness (probe the proxy-specific /healthz; require 2xx) ────────────────
/**
* Probe the proxy for liveness. ANY resolved HTTP response — including non-2xx —
* means the proxy is up; only a transport/connection failure or a timeout means
* it is down. This is the whole point of gotcha #1: the proxy's root path
* returns non-2xx, so a `curl -f`-style check reports it dead and spawns a
* duplicate proxy.
* Probe the proxy for liveness by hitting its dedicated `GET /healthz` endpoint
* and requiring a 2xx response.
*
* This deliberately does NOT trust an arbitrary HTTP response on the port. The
* proxy binds loopback with no client authentication, so on a shared host any
* local process could occupy :18765; trusting "any response = alive" (as an
* earlier revision did) would let such a squatter be mistaken for the proxy and
* intercept Claude traffic (CWE-345). Requiring a 2xx on the proxy's own
* `/healthz` contract is the strongest identity signal available without an
* upstream shared-secret/unix-socket handshake (which `claude-code-proxy` does
* not provide). It also 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 is spawned.
*/
export async function probeLiveness(
url: string = CLAUDEX_PROXY_URL,
url: string = CLAUDEX_HEALTH_URL,
fetchImpl: FetchLike = fetch as unknown as FetchLike,
timeoutMs = 1500,
): Promise<boolean> {
@@ -193,7 +212,7 @@ export async function probeLiveness(
});
const probe = fetchImpl(url, { signal: controller.signal })
.then(() => true) // any response at all → alive
.then((res) => typeof res.status === 'number' && res.status >= 200 && res.status < 300)
.catch(() => false); // connection refused / aborted → dead
try {
@@ -209,12 +228,50 @@ 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 = `${binaryPath} ${buildServeArgs().join(' ')}`;
const exec = `${systemdQuoteExec(validateExecPath(binaryPath))} ${buildServeArgs().join(' ')}`;
return [
'[Unit]',
'Description=claude-code-proxy (Anthropic->Codex translation proxy for mosaic claudex)',
@@ -333,12 +390,81 @@ export interface EnsureProxyResult {
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>;
startSystemd?: () => ProxyRunResult;
startNohup?: () => 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> {
@@ -349,32 +475,47 @@ function defaultStartSystemd(): ProxyRunResult {
return defaultRun('systemctl', ['--user', 'start', CLAUDEX_SYSTEMD_UNIT]);
}
function defaultStartNohup(): ProxyRunResult {
try {
const bin = checkProxyBinary().path ?? CLAUDEX_PROXY_BINARY;
const child = spawn(bin, buildServeArgs(), {
detached: true,
stdio: 'ignore',
});
child.unref();
return { status: 0, stdout: '', stderr: '' };
} catch (err) {
return { status: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) };
/**
* Poll liveness 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 a probe succeeds or the deadline elapses.
*/
async function waitForLive(
probe: () => Promise<boolean>,
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()) {
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. Re-probes
* after each start attempt (respecting gotcha #1 via {@link probeLiveness}), so
* it never spawns a duplicate proxy.
* systemd user unit, then fall back to a detached background process.
*
* After a start command is accepted we poll liveness to a bounded startup
* deadline BEFORE considering the next method: `systemctl start` exit 0 means
* the job was accepted, not that the socket bound within one probe interval. If
* we fell back on the first miss we could launch a second proxy that then
* contends with the (slower) systemd one for :18765 — the very duplicate-proxy
* outcome this function exists to prevent. Liveness itself is the proxy-specific
* `/healthz` check (see {@link probeLiveness}).
*/
export async function ensureProxyRunning(deps: EnsureProxyDeps = {}): Promise<EnsureProxyResult> {
const probe = deps.probe ?? (() => probeLiveness());
const startSystemd = deps.startSystemd ?? defaultStartSystemd;
const startNohup = deps.startNohup ?? defaultStartNohup;
const startNohup = deps.startNohup ?? (() => startNohupProxy());
const waitMs = deps.waitMs ?? defaultWait;
const settleMs = deps.settleMs ?? 500;
const startupDeadlineMs = deps.startupDeadlineMs ?? 5000;
if (await probe()) {
return { live: true, method: 'already' };
@@ -382,16 +523,14 @@ export async function ensureProxyRunning(deps: EnsureProxyDeps = {}): Promise<En
const systemd = startSystemd();
if (systemd.status === 0) {
await waitMs(settleMs);
if (await probe()) {
if (await waitForLive(probe, waitMs, settleMs, startupDeadlineMs)) {
return { live: true, method: 'systemd' };
}
}
const nohup = startNohup();
const nohup = await startNohup();
if (nohup.status === 0) {
await waitMs(settleMs);
if (await probe()) {
if (await waitForLive(probe, waitMs, settleMs, startupDeadlineMs)) {
return { live: true, method: 'nohup' };
}
}