PR-1 — claudex proxy preflight + lifecycle (P1 of #790)
First of two sequential PRs for mosaic yolo claudex (GPT models in the Claude Code harness via raine/claude-code-proxy, ChatGPT OAuth). This PR is P1 only — proxy preflight + lifecycle helpers. It ships no session launch; the isolated CLAUDE_CONFIG_DIR composition, env injection, model-tier map, and EXPERIMENTAL banner are PR-2 (branches off main after this merges).
Scope
New module packages/mosaic/src/commands/claudex-proxy.ts (+ co-located .spec.ts). Pure / dependency-injected — every side-effecting boundary (spawn, fetch, which, fs) is an injected dep so the logic is unit-testable without touching a real proxy or the network. Nothing wired into cli.ts/launch.ts yet, so no runtime behavior change for existing commands.
What it adds
checkProxyBinary — resolves claude-code-proxy on PATH.
parseAuthStatus / checkAuthStatus — coarse auth state (valid|expired|unauthenticated|unknown) from claude-code-proxy codex auth status. Carries no token material by construction.
runDeviceReauth — device-code re-auth via stdio: 'inherit'; the launcher never captures or logs the device code or token.
probeLiveness — gotcha #1: ANY HTTP response (incl. non-2xx) means alive; never curl -f. Bounded by an explicit Promise.race timeout so a hung socket can't wedge preflight.
buildSystemdUnitContent / systemdUnitPath / installSystemdUnit — optional user unit claude-code-proxy.service; unit text contains no credentials.
Coverage on new module: 89.6% statements/lines, 87.1% branch (≥85% gate met). Uncovered lines are only the real process-spawn wrappers (systemd/nohup/wait), unsuitable for unit spawning.
TDD: spec authored and run red before implementation.
Review asks
Independent reviewer (author ≠ reviewer), exact-head review of 0e41a5c2.
Light secrev on the four invariants above; route verdict to MS-LEAD (mosaic-100).
No self-merge — merge authorized by coordinator on APPROVE + green CI.
Refs #790 (partial — issue stays open until P2–P4 land).
## PR-1 — claudex proxy preflight + lifecycle (P1 of #790)
First of two sequential PRs for `mosaic yolo claudex` (GPT models in the Claude Code harness via `raine/claude-code-proxy`, ChatGPT OAuth). **This PR is P1 only** — proxy preflight + lifecycle helpers. It ships **no session launch**; the isolated `CLAUDE_CONFIG_DIR` composition, env injection, model-tier map, and EXPERIMENTAL banner are **PR-2** (branches off `main` after this merges).
### Scope
New module `packages/mosaic/src/commands/claudex-proxy.ts` (+ co-located `.spec.ts`). Pure / dependency-injected — every side-effecting boundary (spawn, fetch, which, fs) is an injected dep so the logic is unit-testable without touching a real proxy or the network. Nothing wired into `cli.ts`/`launch.ts` yet, so no runtime behavior change for existing commands.
### What it adds
- `checkProxyBinary` — resolves `claude-code-proxy` on PATH.
- `parseAuthStatus` / `checkAuthStatus` — coarse auth state (`valid|expired|unauthenticated|unknown`) from `claude-code-proxy codex auth status`. Carries **no token material** by construction.
- `runDeviceReauth` — device-code re-auth via `stdio: 'inherit'`; the launcher never captures or logs the device code or token.
- `probeLiveness` — **gotcha #1**: ANY HTTP response (incl. non-2xx) means alive; never `curl -f`. Bounded by an explicit `Promise.race` timeout so a hung socket can't wedge preflight.
- `buildSystemdUnitContent` / `systemdUnitPath` / `installSystemdUnit` — optional user unit `claude-code-proxy.service`; unit text contains no credentials.
- `runProxyPreflight` — structured report (binary/auth/liveness/needsReauth/problems).
- `ensureProxyRunning` — systemd-preferred start with nohup fallback; re-probes before/after each start so it never spawns a duplicate proxy.
### Security invariants (light secrev surface, charter #4)
- **No `curl -f`** — `probeLiveness` treats non-2xx as alive (tested: 404/500 → alive; reject/timeout → dead).
- **Re-auth never logs device code/token** — `runDeviceReauth` uses `stdio:'inherit'`; test asserts inherit.
- **No duplicate proxy** — `ensureProxyRunning` re-probes around every start.
- **`parseAuthStatus` drops token-shaped output** — test asserts `access_token`/`refresh_token`/`sk-` never retained.
### Tests / gates
- 39 unit tests, all green. Full `@mosaicstack/mosaic` suite: 1080 passing.
- `pnpm typecheck`, `pnpm lint`, `pnpm format:check`: green.
- Coverage on new module: **89.6% statements/lines, 87.1% branch** (≥85% gate met). Uncovered lines are only the real process-spawn wrappers (systemd/nohup/wait), unsuitable for unit spawning.
- TDD: spec authored and run **red before** implementation.
### Review asks
- Independent reviewer (author ≠ reviewer), **exact-head** review of `0e41a5c2`.
- Light secrev on the four invariants above; route verdict to MS-LEAD (`mosaic-100`).
- No self-merge — merge authorized by coordinator on APPROVE + green CI.
Refs #790 (partial — issue stays open until P2–P4 land).
P1 of the `mosaic yolo claudex` launcher (#790): pure, dependency-injected
preflight and lifecycle helpers for `raine/claude-code-proxy` (the local
Anthropic->Codex translation proxy on 127.0.0.1:18765). No session launch yet;
the isolated CLAUDE_CONFIG_DIR composition + env-injection land in PR-2.
Authored test-first (spec written and run red before implementation, then
green). 39 unit tests, 89.6% statement/line coverage on the new module; the
only uncovered lines are the process-spawning system wrappers
(systemd/nohup/wait), which are integration glue unsuitable for unit spawning.
Helpers:
- checkProxyBinary — binary presence via an injectable `which` resolver.
- parseAuthStatus / checkAuthStatus — coarse OAuth state from
`codex auth status`. Carries NO token material (state + optional expiry only)
so token-shaped output can never leak downstream.
- runDeviceReauth — `codex auth device` with stdio:'inherit' so the device code
streams to the user's TTY; the launcher never captures/logs it or sees the
resulting token.
- probeLiveness — gotcha #1: ANY HTTP response (incl. non-2xx) = alive; only a
transport failure/timeout = dead. NEVER `curl -f` (that spawned duplicate
proxies). Bounded by an explicit timeout race so a hung socket can't wedge.
- buildSystemdUnitContent / systemdUnitPath / installSystemdUnit — systemd
--user unit management; unit carries no credential material.
- runProxyPreflight — structured binary+auth+liveness report.
- ensureProxyRunning — no-op when live, else systemd-preferred with nohup
fallback, re-probing after each attempt so it never spawns a duplicate.
Refs #790. Not part of the #758 fleet DAG.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
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
Addresses the three items from PR #793 re-review #2, all TDD red-first on this
branch:
F1 (BLOCKER, dup-proxy race) — ensureProxyRunning no longer falls back to nohup
after `systemctl start` is ACCEPTED. An accepted-but-not-yet-healthy systemd
unit may bind late or be restarted; spawning nohup then would create a second
proxy contending for :18765. Once systemd accepts the job we poll to the startup
deadline and, on a miss, report a managed-service startup failure. nohup is now
reachable only when systemd never accepted the job. New test: "does NOT fall back
to nohup after systemd accepts but never binds".
F2 (BLOCKER, CWE-345) — a 2xx on /healthz is liveness, not identity. Added
verifyListenerIdentity(): an OS-level check (via `ss` + /proc) that the process
owning :18765 is the current uid running the expected proxy executable, failing
CLOSED (`unknown`) when identity can't be established — needs no upstream
shared-secret/unix-socket support. ensureProxyRunning now gates EVERY trust point
on it: an already-present responder that fails identity returns `untrusted`
without starting anything; a started proxy is trusted only once it is both live
AND identity-verified. probeLiveness doc-comment updated to record the residual
CWE-345 risk and point at the identity check (multi-user warning deferred).
F3 (SHOULD-FIX) — parseAuthStatus no longer treats a signal-terminated check
(status null) with an auth-looking line as valid; a clean exit 0 is required.
Regression test: { status: null, stdout: 'Authenticated' } → unknown.
Gates green: typecheck, lint, format:check; full mosaic suite 1110 passing;
claudex-proxy.ts coverage 92.3% stmts/lines, 88.13% branch (>= 85%).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Addresses PR #793 re-review #3 (F1/F3 already confirmed closed). Both fixes TDD
red-first on this branch (7 tests red against the prior head, then green).
F2a (BLOCKER) — verifyListenerIdentity no longer accepts a listener by BASENAME.
The old code ran `if (basename(exePath) === CLAUDEX_PROXY_BINARY) return 'ok'`
unconditionally, so a same-uid process running `/tmp/claude-code-proxy` (right
name, wrong path) was trusted. On a shared-uid host that is the squatter vector,
not hypothetical — uid is NOT a trust boundary there, the executable path is.
Now: when the expected proxy path resolves we require an EXACT canonical-path
match (both sides canonicalized via realpath for symlinks); there is NO basename
fallback. If the expected path can't be resolved, or either path can't be
canonicalized, we fail closed (`unknown`). New tests: wrong-path/right-basename →
wrong-exe; unresolved expected → unknown; uncanonicalizable → unknown; and a
symlink-resolution case that canonically matches → ok.
F2b (BLOCKER) — runProxyPreflight now verifies listener identity too. Previously
`ok` was `bin.present && auth==='valid' && live` where `live` is /healthz-2xx
only, so a squatter answering 2xx yielded `ok:true` and any consumer of the
report (the phase-2 launch path) would route Claude traffic to it. Added
`verifyListener` to PreflightDeps + a `listenerVerdict` field; `ok` now requires
a trusted verdict; a live-but-unverified responder emits a non-sensitive problem
(port + verdict only — no listener command line or token). Identity is not
checked when the port is dead (no listener to trust). New tests: each non-ok
verdict fails preflight and leaks no token material; dead proxy skips verify.
Gates green: typecheck, lint, format:check; full mosaic suite 1115 passing;
claudex-proxy.ts coverage 90.57% stmts/lines, 89.06% branch (>= 85%).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
PR-1 — claudex proxy preflight + lifecycle (P1 of #790)
First of two sequential PRs for
mosaic yolo claudex(GPT models in the Claude Code harness viaraine/claude-code-proxy, ChatGPT OAuth). This PR is P1 only — proxy preflight + lifecycle helpers. It ships no session launch; the isolatedCLAUDE_CONFIG_DIRcomposition, env injection, model-tier map, and EXPERIMENTAL banner are PR-2 (branches offmainafter this merges).Scope
New module
packages/mosaic/src/commands/claudex-proxy.ts(+ co-located.spec.ts). Pure / dependency-injected — every side-effecting boundary (spawn, fetch, which, fs) is an injected dep so the logic is unit-testable without touching a real proxy or the network. Nothing wired intocli.ts/launch.tsyet, so no runtime behavior change for existing commands.What it adds
checkProxyBinary— resolvesclaude-code-proxyon PATH.parseAuthStatus/checkAuthStatus— coarse auth state (valid|expired|unauthenticated|unknown) fromclaude-code-proxy codex auth status. Carries no token material by construction.runDeviceReauth— device-code re-auth viastdio: 'inherit'; the launcher never captures or logs the device code or token.probeLiveness— gotcha #1: ANY HTTP response (incl. non-2xx) means alive; nevercurl -f. Bounded by an explicitPromise.racetimeout so a hung socket can't wedge preflight.buildSystemdUnitContent/systemdUnitPath/installSystemdUnit— optional user unitclaude-code-proxy.service; unit text contains no credentials.runProxyPreflight— structured report (binary/auth/liveness/needsReauth/problems).ensureProxyRunning— systemd-preferred start with nohup fallback; re-probes before/after each start so it never spawns a duplicate proxy.Security invariants (light secrev surface, charter #4)
curl -f—probeLivenesstreats non-2xx as alive (tested: 404/500 → alive; reject/timeout → dead).runDeviceReauthusesstdio:'inherit'; test asserts inherit.ensureProxyRunningre-probes around every start.parseAuthStatusdrops token-shaped output — test assertsaccess_token/refresh_token/sk-never retained.Tests / gates
@mosaicstack/mosaicsuite: 1080 passing.pnpm typecheck,pnpm lint,pnpm format:check: green.Review asks
0e41a5c2.mosaic-100).Refs #790 (partial — issue stays open until P2–P4 land).