diff --git a/packages/mosaic/src/commands/claudex-proxy.spec.ts b/packages/mosaic/src/commands/claudex-proxy.spec.ts index b592133..ddc9c4a 100644 --- a/packages/mosaic/src/commands/claudex-proxy.spec.ts +++ b/packages/mosaic/src/commands/claudex-proxy.spec.ts @@ -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 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 => 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); diff --git a/packages/mosaic/src/commands/claudex-proxy.ts b/packages/mosaic/src/commands/claudex-proxy.ts index 9e20935..b162c65 100644 --- a/packages/mosaic/src/commands/claudex-proxy.ts +++ b/packages/mosaic/src/commands/claudex-proxy.ts @@ -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 { @@ -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 { + 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; startSystemd?: () => ProxyRunResult; - startNohup?: () => 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 { @@ -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, + waitMs: (ms: number) => Promise, + intervalMs: number, + deadlineMs: number, +): Promise { + 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 { 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