Compare commits
4 Commits
feat/791-u
...
feat/790-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f61d16f16 | ||
|
|
6bef94ea0d | ||
|
|
7f987f52fc | ||
|
|
0e41a5c264 |
862
packages/mosaic/src/commands/claudex-proxy.spec.ts
Normal file
862
packages/mosaic/src/commands/claudex-proxy.spec.ts
Normal file
@@ -0,0 +1,862 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
CLAUDEX_PROXY_HOST,
|
||||
CLAUDEX_PROXY_PORT,
|
||||
CLAUDEX_PROXY_URL,
|
||||
CLAUDEX_PROXY_BINARY,
|
||||
CLAUDEX_HEALTH_PATH,
|
||||
CLAUDEX_HEALTH_URL,
|
||||
buildAuthStatusArgs,
|
||||
buildDeviceAuthArgs,
|
||||
buildServeArgs,
|
||||
parseAuthStatus,
|
||||
checkProxyBinary,
|
||||
checkAuthStatus,
|
||||
runDeviceReauth,
|
||||
probeLiveness,
|
||||
buildSystemdUnitContent,
|
||||
systemdUnitPath,
|
||||
installSystemdUnit,
|
||||
startNohupProxy,
|
||||
verifyListenerIdentity,
|
||||
runProxyPreflight,
|
||||
ensureProxyRunning,
|
||||
type AuthStatus,
|
||||
type ProxyRunResult,
|
||||
type SpawnedChild,
|
||||
type ListenerIdentity,
|
||||
} from './claudex-proxy.js';
|
||||
|
||||
/**
|
||||
* P1 — Proxy preflight + lifecycle helpers for `mosaic yolo claudex`.
|
||||
*
|
||||
* Security-relevant invariants exercised here:
|
||||
* - Liveness probe hits the proxy's dedicated `GET /healthz` and treats only a
|
||||
* 2xx as "alive" — a *proxy-specific* health contract, not arbitrary HTTP on
|
||||
* the port (CWE-345: a local port-squatter must not be trusted as the proxy).
|
||||
* This also honors spec gotcha #1 (never `curl -f` the root, which returns
|
||||
* non-2xx): `/healthz` returns 2xx when the proxy is up, so a healthy proxy is
|
||||
* never mistaken for dead and no duplicate proxy is spawned.
|
||||
* - Auth-status parsing NEVER surfaces OAuth token material — only a coarse
|
||||
* state + optional expiry — even if a token-shaped string appears in output.
|
||||
* - The systemd unit's ExecStart never interpolates an unvalidated path
|
||||
* (CWE-74: a CR/LF in the path could inject arbitrary systemd directives).
|
||||
* - The nohup fallback captures spawn's *async* error event instead of crashing.
|
||||
*/
|
||||
|
||||
describe('claudex-proxy constants', () => {
|
||||
it('pins the proxy endpoint to loopback :18765 (spec table)', () => {
|
||||
expect(CLAUDEX_PROXY_HOST).toBe('127.0.0.1');
|
||||
expect(CLAUDEX_PROXY_PORT).toBe(18765);
|
||||
expect(CLAUDEX_PROXY_URL).toBe('http://127.0.0.1:18765');
|
||||
expect(CLAUDEX_PROXY_BINARY).toBe('claude-code-proxy');
|
||||
});
|
||||
|
||||
it('exposes the dedicated /healthz liveness endpoint (not the root path)', () => {
|
||||
expect(CLAUDEX_HEALTH_PATH).toBe('/healthz');
|
||||
expect(CLAUDEX_HEALTH_URL).toBe('http://127.0.0.1:18765/healthz');
|
||||
});
|
||||
|
||||
it('builds the documented codex subcommand argv', () => {
|
||||
expect(buildAuthStatusArgs()).toEqual(['codex', 'auth', 'status']);
|
||||
expect(buildDeviceAuthArgs()).toEqual(['codex', 'auth', 'device']);
|
||||
expect(buildServeArgs()).toEqual(['serve', '--no-monitor']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAuthStatus', () => {
|
||||
it('reports valid on exit 0 with an authenticated marker', () => {
|
||||
const s = parseAuthStatus({
|
||||
status: 0,
|
||||
stdout: 'Authenticated as user; token valid',
|
||||
stderr: '',
|
||||
});
|
||||
expect(s.state).toBe('valid');
|
||||
});
|
||||
|
||||
it('reports expired when output mentions expiry', () => {
|
||||
const s = parseAuthStatus({ status: 0, stdout: 'Token expired 2 days ago', stderr: '' });
|
||||
expect(s.state).toBe('expired');
|
||||
});
|
||||
|
||||
it('reports unauthenticated when output says not logged in', () => {
|
||||
const s = parseAuthStatus({
|
||||
status: 1,
|
||||
stdout: '',
|
||||
stderr: 'not authenticated: run codex auth device',
|
||||
});
|
||||
expect(s.state).toBe('unauthenticated');
|
||||
});
|
||||
|
||||
it('reports unknown on an unrecognized non-zero exit', () => {
|
||||
const s = parseAuthStatus({ status: 2, stdout: 'weird', stderr: '' });
|
||||
expect(s.state).toBe('unknown');
|
||||
});
|
||||
|
||||
it('does NOT trust a signal-terminated check (status null) even with an auth-looking line', () => {
|
||||
// status: null means the process was killed by a signal — an INCOMPLETE
|
||||
// check. An auth-looking line that happened to be flushed must not be read
|
||||
// as valid, or preflight passes on a check that never finished.
|
||||
const s = parseAuthStatus({ status: null, stdout: 'Authenticated', stderr: '' });
|
||||
expect(s.state).toBe('unknown');
|
||||
});
|
||||
|
||||
it('extracts a best-effort expiry in days when present', () => {
|
||||
const s = parseAuthStatus({
|
||||
status: 0,
|
||||
stdout: 'Authenticated; expires in 9 days',
|
||||
stderr: '',
|
||||
});
|
||||
expect(s.state).toBe('valid');
|
||||
expect(s.expiresInDays).toBe(9);
|
||||
});
|
||||
|
||||
it('treats a clean exit 0 with no explicit markers as valid', () => {
|
||||
const s = parseAuthStatus({ status: 0, stdout: 'Session active for account foo', stderr: '' });
|
||||
expect(s.state).toBe('valid');
|
||||
expect(s.expiresInDays).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NEVER retains token-shaped material from output', () => {
|
||||
const leaky = 'Authenticated. access_token=sk-abc123SECRETdeadbeef refresh_token=rt-9999';
|
||||
const s: AuthStatus = parseAuthStatus({ status: 0, stdout: leaky, stderr: '' });
|
||||
const serialized = JSON.stringify(s);
|
||||
expect(serialized).not.toContain('sk-abc123SECRETdeadbeef');
|
||||
expect(serialized).not.toContain('rt-9999');
|
||||
expect(serialized).not.toContain('access_token');
|
||||
expect(serialized).not.toContain('refresh_token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAuthStatus', () => {
|
||||
it('runs the status subcommand and parses the result', () => {
|
||||
const run = vi.fn(
|
||||
(_cmd: string, _args: string[]): ProxyRunResult => ({
|
||||
status: 0,
|
||||
stdout: 'Authenticated; expires in 7 days',
|
||||
stderr: '',
|
||||
}),
|
||||
);
|
||||
const s = checkAuthStatus(run);
|
||||
expect(run).toHaveBeenCalledWith(CLAUDEX_PROXY_BINARY, ['codex', 'auth', 'status']);
|
||||
expect(s.state).toBe('valid');
|
||||
expect(s.expiresInDays).toBe(7);
|
||||
});
|
||||
|
||||
it('surfaces unknown when the default runner cannot find the binary', () => {
|
||||
// Exercises the default spawnSync path against an absent binary: no throw,
|
||||
// status is non-zero/null → unknown. Deterministic on a box without the proxy.
|
||||
const s = checkAuthStatus();
|
||||
expect(['unknown', 'unauthenticated', 'valid', 'expired']).toContain(s.state);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runDeviceReauth', () => {
|
||||
it('spawns the device flow with inherited stdio (never captures the code/token)', () => {
|
||||
const calls: Array<{ cmd: string; args: string[]; opts: { stdio: string } }> = [];
|
||||
const status = runDeviceReauth((cmd, args, opts) => {
|
||||
calls.push({ cmd, args, opts });
|
||||
return { status: 0 };
|
||||
});
|
||||
expect(status).toBe(0);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.cmd).toBe(CLAUDEX_PROXY_BINARY);
|
||||
expect(calls[0]!.args).toEqual(['codex', 'auth', 'device']);
|
||||
// stdio 'inherit' is the security-critical bit: the device code streams to
|
||||
// the user's TTY; the launcher never pipes/captures it.
|
||||
expect(calls[0]!.opts.stdio).toBe('inherit');
|
||||
});
|
||||
|
||||
it('returns 1 when the child yields no status (absent binary)', () => {
|
||||
const status = runDeviceReauth(() => ({ status: null }));
|
||||
expect(status).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkProxyBinary', () => {
|
||||
it('resolves via the default `which` path (proxy absent → null)', () => {
|
||||
// Covers the default resolver; on CI/dev the proxy is not installed.
|
||||
const r = checkProxyBinary();
|
||||
expect(typeof r.present).toBe('boolean');
|
||||
if (!r.present) expect(r.path).toBeNull();
|
||||
});
|
||||
|
||||
it('reports present with the resolved path', () => {
|
||||
const r = checkProxyBinary(() => '/home/u/.local/bin/claude-code-proxy');
|
||||
expect(r.present).toBe(true);
|
||||
expect(r.path).toBe('/home/u/.local/bin/claude-code-proxy');
|
||||
});
|
||||
|
||||
it('reports absent when the resolver finds nothing', () => {
|
||||
const r = checkProxyBinary(() => null);
|
||||
expect(r.present).toBe(false);
|
||||
expect(r.path).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeLiveness (proxy-specific /healthz, not arbitrary HTTP)', () => {
|
||||
it('defaults to probing the /healthz endpoint, never the root path', async () => {
|
||||
const seen: string[] = [];
|
||||
await probeLiveness(undefined, async (u) => {
|
||||
seen.push(u);
|
||||
return { status: 200 };
|
||||
});
|
||||
expect(seen[0]).toBe(CLAUDEX_HEALTH_URL);
|
||||
expect(seen[0]).toContain('/healthz');
|
||||
});
|
||||
|
||||
it('treats a 200 on /healthz as alive', async () => {
|
||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 200 }));
|
||||
expect(live).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a 204 on /healthz as alive', async () => {
|
||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 204 }));
|
||||
expect(live).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a 404 as DEAD — does not trust an arbitrary responder on the port (CWE-345)', async () => {
|
||||
// The whole point: a random local process squatting :18765 will not honor the
|
||||
// proxy's /healthz contract, so a non-2xx there must not be mistaken for the proxy.
|
||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 404 }));
|
||||
expect(live).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a 500 as DEAD (unhealthy / not the proxy health contract)', async () => {
|
||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 500 }));
|
||||
expect(live).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a missing status as dead', async () => {
|
||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({}));
|
||||
expect(live).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a connection failure (reject) as dead', async () => {
|
||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
expect(live).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a timeout as dead', async () => {
|
||||
const never = () => new Promise<{ status?: number }>(() => {});
|
||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, never, 20);
|
||||
expect(live).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemdUnitContent', () => {
|
||||
it('emits a user unit that execs the given binary with serve args', () => {
|
||||
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
|
||||
expect(unit).toContain('[Unit]');
|
||||
expect(unit).toContain('[Service]');
|
||||
expect(unit).toContain('[Install]');
|
||||
expect(unit).toContain('/home/u/.local/bin/claude-code-proxy serve --no-monitor');
|
||||
expect(unit).toContain('WantedBy=default.target');
|
||||
});
|
||||
|
||||
it('never embeds credential material', () => {
|
||||
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
|
||||
expect(unit).not.toMatch(/token/i);
|
||||
expect(unit).not.toMatch(/auth\.json/i);
|
||||
});
|
||||
|
||||
it('rejects a path containing a newline (CWE-74 systemd directive injection)', () => {
|
||||
// A raw newline in ExecStart would let an attacker append arbitrary unit
|
||||
// directives — e.g. `ExecStartPost=curl evil`. Must be rejected outright.
|
||||
expect(() =>
|
||||
buildSystemdUnitContent('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /'),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a path containing a carriage return', () => {
|
||||
expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\rmalicious')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a path with other control characters', () => {
|
||||
expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\x00nul')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a non-absolute path', () => {
|
||||
expect(() => buildSystemdUnitContent('claude-code-proxy')).toThrow();
|
||||
expect(() => buildSystemdUnitContent('')).toThrow();
|
||||
});
|
||||
|
||||
it('systemd-quotes a path that contains spaces', () => {
|
||||
const unit = buildSystemdUnitContent('/home/u/my apps/claude-code-proxy');
|
||||
expect(unit).toContain('ExecStart="/home/u/my apps/claude-code-proxy" serve --no-monitor');
|
||||
});
|
||||
|
||||
it('escapes embedded quotes and backslashes when quoting', () => {
|
||||
const unit = buildSystemdUnitContent('/home/u/we"ird\\dir/claude-code-proxy');
|
||||
// No unescaped closing quote can terminate the token early.
|
||||
expect(unit).toContain('ExecStart="/home/u/we\\"ird\\\\dir/claude-code-proxy" serve');
|
||||
});
|
||||
|
||||
it('leaves a clean absolute path unquoted (no needless churn)', () => {
|
||||
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
|
||||
expect(unit).toContain('ExecStart=/home/u/.local/bin/claude-code-proxy serve --no-monitor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('systemdUnitPath', () => {
|
||||
it('targets the systemd --user unit dir', () => {
|
||||
expect(systemdUnitPath('/home/u')).toBe(
|
||||
'/home/u/.config/systemd/user/claude-code-proxy.service',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installSystemdUnit', () => {
|
||||
it('writes the unit and returns true when daemon-reload succeeds', () => {
|
||||
let written: { path: string; content: string } | null = null;
|
||||
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
||||
home: '/home/u',
|
||||
writeUnit: (path, content) => {
|
||||
written = { path, content };
|
||||
},
|
||||
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(written).not.toBeNull();
|
||||
expect(written!.path).toBe('/home/u/.config/systemd/user/claude-code-proxy.service');
|
||||
expect(written!.content).toContain('ExecStart=/bin/claude-code-proxy serve --no-monitor');
|
||||
});
|
||||
|
||||
it('returns false when daemon-reload fails (systemd --user unavailable)', () => {
|
||||
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
||||
home: '/home/u',
|
||||
writeUnit: () => {},
|
||||
run: () => ({ status: 1, stdout: '', stderr: 'Failed to connect to bus' }),
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when writing the unit throws', () => {
|
||||
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
||||
home: '/home/u',
|
||||
writeUnit: () => {
|
||||
throw new Error('EACCES');
|
||||
},
|
||||
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to write a unit for an injection-bearing path (never writes a poisoned unit)', () => {
|
||||
const writeUnit = vi.fn();
|
||||
const ok = installSystemdUnit('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /', {
|
||||
home: '/home/u',
|
||||
writeUnit,
|
||||
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
// The poisoned unit content is never even produced, so nothing is written.
|
||||
expect(writeUnit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes to a real temp dir via the default writer', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'claudex-unit-'));
|
||||
try {
|
||||
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
||||
home,
|
||||
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
const written = readFileSync(systemdUnitPath(home), 'utf8');
|
||||
expect(written).toContain('[Service]');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('runProxyPreflight', () => {
|
||||
const trustedListener = () => 'ok' as const;
|
||||
|
||||
it('is ok when binary present, auth valid, proxy live, and listener identity-verified', async () => {
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||
checkAuth: () => ({ state: 'valid' }),
|
||||
probe: async () => true,
|
||||
verifyListener: trustedListener,
|
||||
});
|
||||
expect(report.ok).toBe(true);
|
||||
expect(report.listenerVerdict).toBe('ok');
|
||||
expect(report.problems).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a missing binary', async () => {
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: false, path: null }),
|
||||
checkAuth: () => ({ state: 'valid' }),
|
||||
probe: async () => true,
|
||||
verifyListener: trustedListener,
|
||||
});
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.problems.some((p) => /binary/i.test(p))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags expired auth (re-auth needed)', async () => {
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||
checkAuth: () => ({ state: 'expired' }),
|
||||
probe: async () => true,
|
||||
verifyListener: trustedListener,
|
||||
});
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.needsReauth).toBe(true);
|
||||
expect(report.problems.some((p) => /auth/i.test(p))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags a dead proxy', async () => {
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||
checkAuth: () => ({ state: 'valid' }),
|
||||
probe: async () => false,
|
||||
verifyListener: trustedListener,
|
||||
});
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.live).toBe(false);
|
||||
});
|
||||
|
||||
it('flags an unknown auth state without marking it for re-auth', async () => {
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||
checkAuth: () => ({ state: 'unknown' }),
|
||||
probe: async () => true,
|
||||
verifyListener: trustedListener,
|
||||
});
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.needsReauth).toBe(false);
|
||||
expect(report.problems.some((p) => /could not determine/i.test(p))).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT pass preflight when the live responder fails identity verification (F2b)', async () => {
|
||||
// A squatter answering /healthz-2xx must not yield ok:true just because the
|
||||
// binary is installed and OAuth is valid — the identity gate holds here too.
|
||||
for (const verdict of ['foreign-user', 'wrong-exe', 'unknown'] as const) {
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||
checkAuth: () => ({ state: 'valid' }),
|
||||
probe: async () => true,
|
||||
verifyListener: () => verdict,
|
||||
});
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.live).toBe(true);
|
||||
expect(report.listenerVerdict).toBe(verdict);
|
||||
expect(report.problems.some((p) => /identity could not be verified/i.test(p))).toBe(true);
|
||||
// The identity problem is non-sensitive: port + verdict only, no token.
|
||||
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not verify listener identity when the proxy is dead (no listener to trust)', async () => {
|
||||
const verifyListener = vi.fn(() => 'ok' as const);
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||
checkAuth: () => ({ state: 'valid' }),
|
||||
probe: async () => false,
|
||||
verifyListener,
|
||||
});
|
||||
expect(verifyListener).not.toHaveBeenCalled();
|
||||
expect(report.listenerVerdict).toBe('unknown');
|
||||
expect(report.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('does not leak token material for any auth state', async () => {
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||
checkAuth: () => ({ state: 'expired' }),
|
||||
probe: async () => false,
|
||||
verifyListener: trustedListener,
|
||||
});
|
||||
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
|
||||
});
|
||||
|
||||
it('runs end-to-end with all real defaults (no proxy installed → not ok)', async () => {
|
||||
// Exercises the default checkBinary/checkAuth/probe closures against a box
|
||||
// with no proxy: absent binary, spawnSync status, real loopback probe that
|
||||
// fast-fails with ECONNREFUSED. Asserts shape only (never token material).
|
||||
const report = await runProxyPreflight();
|
||||
expect(typeof report.ok).toBe('boolean');
|
||||
expect(Array.isArray(report.problems)).toBe(true);
|
||||
expect(['valid', 'expired', 'unauthenticated', 'unknown']).toContain(report.auth.state);
|
||||
expect(JSON.stringify(report)).not.toMatch(/access_token|refresh_token|sk-/i);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A minimal fake ChildProcess for the nohup-fallback tests: records once()
|
||||
* handlers so a test can drive the async 'spawn'/'error' events, and tracks
|
||||
* whether the 'error' listener was already attached at the moment unref() ran
|
||||
* (the security-critical ordering from finding #1).
|
||||
*/
|
||||
function fakeChild() {
|
||||
const handlers: Record<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('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE-345)', () => {
|
||||
const me: ListenerIdentity = {
|
||||
pid: 4242,
|
||||
uid: 1000,
|
||||
exePath: '/home/me/.local/bin/claude-code-proxy',
|
||||
};
|
||||
// Identity canonicalize for tests: fake paths don't exist on disk, so we map
|
||||
// each path to itself and exercise symlink resolution explicitly where needed.
|
||||
const idc = (p: string) => p;
|
||||
|
||||
it('accepts a listener owned by the current uid whose exe is the expected proxy path', () => {
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => me,
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('ok');
|
||||
});
|
||||
|
||||
it('resolves symlinks on BOTH sides before comparing (canonical match → ok)', () => {
|
||||
// The listener exe and our resolved binary reach the same real file via
|
||||
// different symlink paths — a canonical comparison must accept it.
|
||||
const canon: Record<string, string> = {
|
||||
'/var/run/proxy.link': '/opt/proxy/bin/claude-code-proxy',
|
||||
'/home/me/.local/bin/claude-code-proxy': '/opt/proxy/bin/claude-code-proxy',
|
||||
};
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => ({ ...me, exePath: '/var/run/proxy.link' }),
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: (p) => canon[p] ?? null,
|
||||
});
|
||||
expect(verdict).toBe('ok');
|
||||
});
|
||||
|
||||
it('does NOT trust a same-uid process at the WRONG path with the right basename (F2a)', () => {
|
||||
// The squatter vector on a shared-uid host: right basename, wrong path. The
|
||||
// basename must NEVER be a trust signal when an expected exact path resolved.
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => ({ ...me, exePath: '/tmp/claude-code-proxy' }),
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('wrong-exe');
|
||||
});
|
||||
|
||||
it('fails closed (unknown) when our own proxy binary path cannot be resolved (F2a)', () => {
|
||||
// No expected path → we cannot assert identity → refuse to trust (no basename
|
||||
// acceptance). Previously this returned `ok` by basename; that was a bypass.
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => me,
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => null,
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('unknown');
|
||||
});
|
||||
|
||||
it('fails closed (unknown) when a path cannot be canonicalized (F2a)', () => {
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => me,
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: () => null, // e.g. binary deleted out from under the listener
|
||||
});
|
||||
expect(verdict).toBe('unknown');
|
||||
});
|
||||
|
||||
it('rejects a listener owned by a DIFFERENT uid (foreign-user) — fail closed', () => {
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => ({ ...me, uid: 0 }),
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('foreign-user');
|
||||
});
|
||||
|
||||
it('rejects a same-user listener whose exe is NOT the proxy (wrong-exe)', () => {
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => ({ ...me, exePath: '/usr/bin/nc' }),
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('wrong-exe');
|
||||
});
|
||||
|
||||
it('returns unknown (fail closed) when the listener cannot be identified', () => {
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => null,
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns unknown when the current uid is unavailable (non-posix)', () => {
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => me,
|
||||
currentUid: () => -1,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns unknown when the listener exe path cannot be read', () => {
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => ({ ...me, exePath: null }),
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('unknown');
|
||||
});
|
||||
|
||||
it('runs with real defaults without throwing (identity may be unresolved → verdict)', () => {
|
||||
const verdict = verifyListenerIdentity();
|
||||
expect(['ok', 'foreign-user', 'wrong-exe', 'unknown']).toContain(verdict);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureProxyRunning', () => {
|
||||
const ok: ProxyRunResult = { status: 0, stdout: '', stderr: '' };
|
||||
const nohupOk = async (): Promise<ProxyRunResult> => ok;
|
||||
const trusted = () => 'ok' as const;
|
||||
|
||||
it('is a no-op when the proxy is already live AND identity-verified', async () => {
|
||||
const startSystemd = vi.fn(() => ok);
|
||||
const startNohup = vi.fn(nohupOk);
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => true,
|
||||
verifyListener: trusted,
|
||||
startSystemd,
|
||||
startNohup,
|
||||
waitMs: async () => {},
|
||||
});
|
||||
expect(r.method).toBe('already');
|
||||
expect(r.live).toBe(true);
|
||||
expect(startSystemd).not.toHaveBeenCalled();
|
||||
expect(startNohup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed as untrusted when a responder holds :18765 but identity is NOT ours', async () => {
|
||||
// A foreign process answers /healthz but the listener is not our proxy
|
||||
// (foreign uid / wrong exe / unidentifiable). We must NOT trust it and must
|
||||
// NOT start a second proxy (the port is already taken) — fail closed.
|
||||
const startSystemd = vi.fn(() => ok);
|
||||
const startNohup = vi.fn(nohupOk);
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => true,
|
||||
verifyListener: () => 'foreign-user',
|
||||
startSystemd,
|
||||
startNohup,
|
||||
waitMs: async () => {},
|
||||
});
|
||||
expect(r.method).toBe('untrusted');
|
||||
expect(r.live).toBe(false);
|
||||
expect(startSystemd).not.toHaveBeenCalled();
|
||||
expect(startNohup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts via systemd when available and then becomes trusted-live', async () => {
|
||||
let calls = 0;
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => calls++ > 0, // dead first, live after start
|
||||
verifyListener: trusted,
|
||||
startSystemd: () => ok,
|
||||
startNohup: async () => {
|
||||
throw new Error('should not fall back');
|
||||
},
|
||||
waitMs: async () => {},
|
||||
});
|
||||
expect(r.method).toBe('systemd');
|
||||
expect(r.live).toBe(true);
|
||||
});
|
||||
|
||||
it('waits past a slow systemd bind before giving up (finding #2 — no duplicate proxy)', async () => {
|
||||
// systemd `start` returns 0 (job accepted) but the socket only binds on the
|
||||
// 4th probe — still well within the startup deadline. nohup must NOT run,
|
||||
// or two proxies would contend for :18765.
|
||||
let probes = 0;
|
||||
const startNohup = vi.fn(nohupOk);
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => probes++ >= 3,
|
||||
verifyListener: trusted,
|
||||
startSystemd: () => ok,
|
||||
startNohup,
|
||||
waitMs: async () => {},
|
||||
settleMs: 10,
|
||||
startupDeadlineMs: 200,
|
||||
});
|
||||
expect(r.method).toBe('systemd');
|
||||
expect(r.live).toBe(true);
|
||||
expect(startNohup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT fall back to nohup after systemd accepts but never binds (finding #1 — dup-proxy race)', async () => {
|
||||
// systemctl start exit 0 means the job was ACCEPTED, not bound. If it binds
|
||||
// just after our deadline (or systemd restarts it), a nohup fallback would
|
||||
// create a SECOND proxy contending for :18765. Once systemd has accepted the
|
||||
// job we never spawn nohup — we report a managed-service startup failure.
|
||||
const startNohup = vi.fn(nohupOk);
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => false, // never becomes live within the deadline
|
||||
verifyListener: trusted,
|
||||
startSystemd: () => ok,
|
||||
startNohup,
|
||||
waitMs: async () => {},
|
||||
settleMs: 10,
|
||||
startupDeadlineMs: 30,
|
||||
});
|
||||
expect(startNohup).not.toHaveBeenCalled();
|
||||
expect(r.method).toBe('failed');
|
||||
expect(r.live).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT trust a systemd-started responder whose identity cannot be verified', async () => {
|
||||
// Dead at first (so we reach the systemd start), then the socket binds — but
|
||||
// identity never verifies (e.g. a squatter beat systemd to the port). A live
|
||||
// responder that fails identity must never be reported as a successful start.
|
||||
let calls = 0;
|
||||
const startNohup = vi.fn(nohupOk);
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => calls++ > 0,
|
||||
verifyListener: () => 'wrong-exe',
|
||||
startSystemd: () => ok,
|
||||
startNohup,
|
||||
waitMs: async () => {},
|
||||
settleMs: 10,
|
||||
startupDeadlineMs: 30,
|
||||
});
|
||||
expect(startNohup).not.toHaveBeenCalled();
|
||||
expect(r.method).toBe('failed');
|
||||
expect(r.live).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to nohup only when systemd start FAILS outright (not accepted)', async () => {
|
||||
let calls = 0;
|
||||
const r = await ensureProxyRunning({
|
||||
// A failed systemd start skips its post-start poll, so probes are:
|
||||
// #0 initial (dead), #1 after nohup (live). nohup fallback is reachable
|
||||
// ONLY because systemd never accepted the job (status 1).
|
||||
probe: async () => calls++ > 0,
|
||||
verifyListener: trusted,
|
||||
startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }),
|
||||
startNohup: nohupOk,
|
||||
waitMs: async () => {},
|
||||
settleMs: 10,
|
||||
startupDeadlineMs: 30,
|
||||
});
|
||||
expect(r.method).toBe('nohup');
|
||||
expect(r.live).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT trust a nohup-started responder whose identity cannot be verified', async () => {
|
||||
let calls = 0;
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => calls++ > 0,
|
||||
verifyListener: () => 'unknown',
|
||||
startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }),
|
||||
startNohup: nohupOk,
|
||||
waitMs: async () => {},
|
||||
settleMs: 10,
|
||||
startupDeadlineMs: 30,
|
||||
});
|
||||
expect(r.method).toBe('failed');
|
||||
expect(r.live).toBe(false);
|
||||
});
|
||||
|
||||
it('reports failed when nothing brings the proxy up', async () => {
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => false,
|
||||
verifyListener: trusted,
|
||||
startSystemd: () => ({ status: 1, stdout: '', stderr: '' }),
|
||||
startNohup: async () => ({ status: 1, stdout: '', stderr: '' }),
|
||||
waitMs: async () => {},
|
||||
settleMs: 10,
|
||||
startupDeadlineMs: 30,
|
||||
});
|
||||
expect(r.method).toBe('failed');
|
||||
expect(r.live).toBe(false);
|
||||
});
|
||||
});
|
||||
700
packages/mosaic/src/commands/claudex-proxy.ts
Normal file
700
packages/mosaic/src/commands/claudex-proxy.ts
Normal file
@@ -0,0 +1,700 @@
|
||||
/**
|
||||
* Claudex proxy preflight + lifecycle (P1 of `mosaic yolo claudex`).
|
||||
*
|
||||
* `raine/claude-code-proxy` runs a local server on 127.0.0.1:18765 that speaks
|
||||
* the Anthropic Messages API and translates to the ChatGPT/Codex backend using
|
||||
* ChatGPT-subscription OAuth. This module owns the *preflight* and *lifecycle*
|
||||
* concerns for the launcher: is the binary present, is OAuth valid, is the proxy
|
||||
* listening, and — if not — bring it up (systemd user unit preferred, nohup
|
||||
* fallback).
|
||||
*
|
||||
* Design: every function is pure or dependency-injected so the launch path is
|
||||
* fully unit-testable without touching a real process, socket, or the OAuth
|
||||
* token. Nothing here reads `~/.config/claude-code-proxy/codex/auth.json`; the
|
||||
* proxy holds the real credential and Claude Code only ever sees
|
||||
* `ANTHROPIC_AUTH_TOKEN=unused`. Parsed auth status is deliberately coarse
|
||||
* (state + optional expiry) so no token material can be retained or surfaced.
|
||||
*/
|
||||
|
||||
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
// ─── Endpoint / command constants (spec table) ──────────────────────────────
|
||||
|
||||
export const CLAUDEX_PROXY_HOST = '127.0.0.1';
|
||||
export const CLAUDEX_PROXY_PORT = 18765;
|
||||
export const CLAUDEX_PROXY_URL = `http://${CLAUDEX_PROXY_HOST}:${CLAUDEX_PROXY_PORT}`;
|
||||
export const CLAUDEX_PROXY_BINARY = 'claude-code-proxy';
|
||||
export const CLAUDEX_SYSTEMD_UNIT = 'claude-code-proxy.service';
|
||||
|
||||
/**
|
||||
* The proxy's dedicated liveness endpoint. We probe this — NOT the root path —
|
||||
* for two reasons: (1) the root returns non-2xx (spec gotcha #1), which is why
|
||||
* the original `curl -f` check spawned duplicate proxies; `/healthz` returns 2xx
|
||||
* when the proxy is healthy. (2) It is a *proxy-specific* contract, so a 2xx here
|
||||
* is a much stronger signal that the responder on :18765 is actually our proxy
|
||||
* and not some other local process squatting the port (CWE-345).
|
||||
*/
|
||||
export const CLAUDEX_HEALTH_PATH = '/healthz';
|
||||
export const CLAUDEX_HEALTH_URL = `${CLAUDEX_PROXY_URL}${CLAUDEX_HEALTH_PATH}`;
|
||||
|
||||
/** argv for `claude-code-proxy codex auth status`. */
|
||||
export function buildAuthStatusArgs(): string[] {
|
||||
return ['codex', 'auth', 'status'];
|
||||
}
|
||||
|
||||
/** argv for `claude-code-proxy codex auth device` (device-code re-auth flow). */
|
||||
export function buildDeviceAuthArgs(): string[] {
|
||||
return ['codex', 'auth', 'device'];
|
||||
}
|
||||
|
||||
/** argv for `claude-code-proxy serve --no-monitor`. */
|
||||
export function buildServeArgs(): string[] {
|
||||
return ['serve', '--no-monitor'];
|
||||
}
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AuthState = 'valid' | 'expired' | 'unauthenticated' | 'unknown';
|
||||
|
||||
/**
|
||||
* Coarse OAuth status. Intentionally carries NO token material — only a state
|
||||
* and an optional best-effort expiry-in-days for user-facing messaging.
|
||||
*/
|
||||
export interface AuthStatus {
|
||||
state: AuthState;
|
||||
expiresInDays?: number;
|
||||
}
|
||||
|
||||
export interface ProxyRunResult {
|
||||
status: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/** Runs a command synchronously and returns its captured result. */
|
||||
export type CommandRunner = (cmd: string, args: string[]) => ProxyRunResult;
|
||||
|
||||
/** Minimal fetch shape used for the liveness probe (any HTTP response = alive). */
|
||||
export type FetchLike = (
|
||||
url: string,
|
||||
init?: { signal?: AbortSignal },
|
||||
) => Promise<{ status?: number }>;
|
||||
|
||||
// ─── Binary presence ─────────────────────────────────────────────────────────
|
||||
|
||||
function defaultWhich(cmd: string): string | null {
|
||||
try {
|
||||
return execFileSync('which', [cmd], { encoding: 'utf8' }).trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function checkProxyBinary(resolve: (cmd: string) => string | null = defaultWhich): {
|
||||
present: boolean;
|
||||
path: string | null;
|
||||
} {
|
||||
const path = resolve(CLAUDEX_PROXY_BINARY);
|
||||
return { present: path !== null && path !== '', path: path || null };
|
||||
}
|
||||
|
||||
// ─── Auth status ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse `claude-code-proxy codex auth status` output into a coarse state.
|
||||
*
|
||||
* The proxy's exact wording is not contractually pinned, so this matches
|
||||
* tolerantly on well-known markers and falls back on the exit code. It never
|
||||
* copies the raw output onto the result — only a state and an optional expiry —
|
||||
* so token-shaped strings in the output cannot leak downstream.
|
||||
*/
|
||||
export function parseAuthStatus(result: ProxyRunResult): AuthStatus {
|
||||
const text = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
||||
|
||||
const expired = /\bexpired\b|token has expired|expires?d? \d+ days? ago/.test(text);
|
||||
const unauth =
|
||||
/not authenticated|not logged in|no (?:auth|credentials|token)|please (?:log ?in|authenticate)|run .*auth device/.test(
|
||||
text,
|
||||
);
|
||||
const authed = /\bauthenticated\b|logged in|token valid|valid until|expires? in/.test(text);
|
||||
|
||||
let state: AuthState;
|
||||
if (expired) {
|
||||
state = 'expired';
|
||||
} else if (unauth) {
|
||||
state = 'unauthenticated';
|
||||
} else if (authed && result.status === 0) {
|
||||
// A `null` status means the check was killed by a signal — an INCOMPLETE
|
||||
// run. We require a clean exit 0 for `valid`; a partially-flushed auth line
|
||||
// from a signal-terminated check must never be trusted (finding #3).
|
||||
state = 'valid';
|
||||
} else if (result.status === 0) {
|
||||
state = 'valid';
|
||||
} else {
|
||||
state = 'unknown';
|
||||
}
|
||||
|
||||
const status: AuthStatus = { state };
|
||||
const days = /expires? in (\d+) days?/.exec(text);
|
||||
if (state === 'valid' && days) {
|
||||
status.expiresInDays = Number(days[1]);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function defaultRun(cmd: string, args: string[]): ProxyRunResult {
|
||||
const r = spawnSync(cmd, args, { encoding: 'utf8' });
|
||||
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
||||
}
|
||||
|
||||
export function checkAuthStatus(run: CommandRunner = defaultRun): AuthStatus {
|
||||
return parseAuthStatus(run(CLAUDEX_PROXY_BINARY, buildAuthStatusArgs()));
|
||||
}
|
||||
|
||||
/** Spawn shape for the interactive device re-auth flow. */
|
||||
export type InheritSpawn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: { stdio: 'inherit' },
|
||||
) => { status: number | null };
|
||||
|
||||
function defaultInheritSpawn(cmd: string, args: string[], opts: { stdio: 'inherit' }) {
|
||||
return spawnSync(cmd, args, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the device-code re-auth flow (`claude-code-proxy codex auth device`).
|
||||
*
|
||||
* Deliberately `stdio: 'inherit'` so the device code the proxy prints goes
|
||||
* straight to the user's terminal — the launcher NEVER captures, stores, or logs
|
||||
* it, and never observes the resulting OAuth token (the proxy persists that to
|
||||
* its own config). Returns the child's exit status; 1 on an absent binary.
|
||||
*/
|
||||
export function runDeviceReauth(spawnImpl: InheritSpawn = defaultInheritSpawn): number {
|
||||
const r = spawnImpl(CLAUDEX_PROXY_BINARY, buildDeviceAuthArgs(), { stdio: 'inherit' });
|
||||
return r.status ?? 1;
|
||||
}
|
||||
|
||||
// ─── Liveness (probe the proxy-specific /healthz; require 2xx) ────────────────
|
||||
|
||||
/**
|
||||
* Probe the proxy for liveness by hitting its dedicated `GET /healthz` endpoint
|
||||
* and requiring a 2xx response.
|
||||
*
|
||||
* This is a LIVENESS check only — it answers "is a healthy proxy responding?",
|
||||
* not "is that responder actually ours?". Requiring a 2xx on the proxy's own
|
||||
* `/healthz` contract (rather than "any HTTP response = alive") resolves spec
|
||||
* gotcha #1: the root path returns non-2xx, but `/healthz` returns 2xx when
|
||||
* healthy, so a live proxy is never mistaken for dead and no duplicate proxy is
|
||||
* spawned.
|
||||
*
|
||||
* Residual risk (CWE-345): the proxy binds loopback with NO client
|
||||
* authentication, so on a shared host a local process could occupy :18765 and
|
||||
* serve a 2xx here. A 2xx therefore does NOT by itself establish that the
|
||||
* listener is our proxy. Identity is verified SEPARATELY and at every trust
|
||||
* point by {@link verifyListenerIdentity} (OS-level uid + executable check),
|
||||
* which fails closed when identity can't be established. See
|
||||
* {@link ensureProxyRunning}. (Broader multi-user hardening — a persistent
|
||||
* warning when a foreign listener is seen — is tracked for a later phase.)
|
||||
*/
|
||||
export async function probeLiveness(
|
||||
url: string = CLAUDEX_HEALTH_URL,
|
||||
fetchImpl: FetchLike = fetch as unknown as FetchLike,
|
||||
timeoutMs = 1500,
|
||||
): Promise<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' };
|
||||
}
|
||||
Reference in New Issue
Block a user