Compare commits

..

4 Commits

Author SHA1 Message Date
Hermes Agent
1f61d16f16 fix(mosaic): close two CWE-345 identity bypasses in claudex preflight (re-review #3, #790)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
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 <noreply@anthropic.com>
2026-07-16 16:02:31 -05:00
Hermes Agent
6bef94ea0d fix(mosaic): close dup-proxy race + verify listener identity (re-review #2, #790)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
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 <noreply@anthropic.com>
2026-07-16 15:43:16 -05:00
Hermes Agent
7f987f52fc fix(mosaic): harden claudex proxy preflight per review (P1 of #790)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Addresses the four findings from the independent review of #793 (exact head
0e41a5c2). TDD: each fix's failing test was added first (18 new tests), then
the implementation; full mosaic suite 1098 green, three gates green.

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

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

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

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

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

Refs #790
2026-07-16 15:13:46 -05:00
Hermes Agent
0e41a5c264 feat(mosaic): claudex proxy preflight + lifecycle helpers (P1 of #790)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
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 <noreply@anthropic.com>
2026-07-16 14:37:09 -05:00
8 changed files with 12 additions and 1476 deletions

View File

@@ -1,37 +0,0 @@
# Issue #808 — agent-send sender identity
## Objective
Fix cross-socket `agent-send.sh` preambles so replies route to the real sender rather than a destination-socket holder session.
## Scope and acceptance criteria
- Prefer exported `MOSAIC_AGENT_NAME` as the authoritative sender session name.
- If it is unset, query the sender's local/default tmux socket for `#S` without destination `-L` arguments.
- Preserve `?` when sender identity cannot be determined.
- Do not alter destination socket dispatch.
- Add red-first regressions for all three identity paths.
## Plan
1. Extend `agent-send.test.sh` with deterministic fake-tmux coverage.
2. Run the test against the unpatched implementation and record RED evidence.
3. Apply the minimal sender lookup fix only.
4. Run the focused suite and repository quality gates.
5. Commit, queue-guard, push, and open an author-only PR for independent review.
## Constraints and risks
- Worker lane is author-only: no self-review or merge.
- `docs/TASKS.md` and mission state are orchestrator-owned and will not be modified.
- Pre-existing runtime changes under `.mosaic/orchestrator/` are excluded from this work.
- Budget: no explicit token cap; keep changes limited to the shell tool, sibling regression test, and this scratchpad.
## Evidence
- RED: `bash packages/mosaic/framework/tools/tmux/agent-send.test.sh` failed on the unpatched implementation with `PASS=12 FAIL=3`; it selected `destination-holder` instead of both `MOSAIC_AGENT_NAME=authoritative-agent` and local session `local-agent`. The genuinely unavailable sender case already exercised and preserved `?`.
- GREEN: `bash packages/mosaic/framework/tools/tmux/agent-send.test.sh` passed with `PASS=15 FAIL=0`; coverage includes env authority, local/default tmux fallback across a destination `-L`, explicit rejection of the destination holder, and `?` fallback.
- Syntax: `bash -n packages/mosaic/framework/tools/tmux/agent-send.sh packages/mosaic/framework/tools/tmux/agent-send.test.sh` passed.
- Quality gates: `pnpm typecheck` (42/42 tasks), `pnpm lint` (23/23 tasks), and `pnpm format:check` all passed after installing the frozen lockfile dependencies. The first install attempt failed because pnpm's configured store pointed at `/root`; retrying with the existing user-owned store (`--store-dir /home/hermes/.local/share/pnpm/store`) succeeded without changing tracked dependency files.
- Documentation: no public API or operator workflow changed; the source comment, regression-test contract, and this implementation record cover the internal bug fix.
- Independent review: intentionally pending for the reviewer assigned by `mosaic-100`; this author-only lane will not self-review or merge.

View File

@@ -47,61 +47,6 @@ export MOSAIC_ADMIN_PASSWORD="securepass123"
mosaic gateway install
```
## Runtime launchers
```bash
mosaic claude # Launch Claude Code with Mosaic injection
mosaic yolo claude # …with --dangerously-skip-permissions
mosaic codex | opencode | pi
```
### `mosaic claudex` (EXPERIMENTAL)
Runs GPT models **inside the Claude Code harness** by pointing Claude Code at a
local [`claude-code-proxy`](https://github.com/raine/claude-code-proxy) that
translates the Anthropic Messages API to a ChatGPT-subscription (Codex OAuth)
backend. This is **not Anthropic Claude** — model behavior, tool use, and output
quality may differ. Intended for evaluation, not production delivery.
```bash
mosaic claudex # launch (prompts through the proxy readiness gate)
mosaic yolo claudex # …with --dangerously-skip-permissions
mosaic claudex --print "hello" # trailing args are forwarded to Claude Code
```
**Prerequisite:** the `claude-code-proxy` binary must be installed and
authenticated (`claude-code-proxy codex auth …`). `mosaic claudex` runs a
preflight that verifies the binary, the OAuth state (triggering a device re-auth
if needed), and a trusted local listener before launching; it **fails closed**
if the proxy cannot be brought up with a verified identity.
**Isolation (never touches your real Claude state).** claudex always launches
against an isolated `CLAUDE_CONFIG_DIR` (default `~/.config/mosaic/claudex/home`).
The ambient `CLAUDE_CONFIG_DIR` is deliberately ignored, and a guard proves the
resolved dir can never be — or live under — the real `~/.claude`. A claudex
session therefore cannot mutate your normal Claude Code config.
**No token leakage.** claudex never reads the proxy's credential file. Claude
Code is handed only `ANTHROPIC_AUTH_TOKEN=unused` pointed at the loopback proxy;
the entire credential-bearing env family (`ANTHROPIC_*`, `AWS_*`, `GOOGLE_CLOUD_*`,
`GOOGLE_APPLICATION_CREDENTIALS`, `*_TOKEN`, `*_KEY`, `*_SECRET`, …) is stripped
from the composed environment. The Bedrock/Vertex routing switches
(`CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_USE_VERTEX`, and the `_SKIP_*_AUTH`
pair) are force-removed regardless of value — otherwise their mere presence
would route Claude Code to the real Anthropic API via AWS/GCP and bypass the
proxy. The proxy holds the real OAuth credential.
**Model tiers (override via env).**
| Tier | Env var | Default |
| --------------------- | ---------------------------- | -------------- |
| primary (opus/sonnet) | `ANTHROPIC_MODEL` | `gpt-5.6-sol` |
| small/fast (haiku) | `ANTHROPIC_SMALL_FAST_MODEL` | `gpt-5.6-luna` |
Operator-provided values win over the defaults. Additional overrides:
`MOSAIC_CLAUDEX_CONFIG_DIR` (isolated config dir), `ANTHROPIC_BASE_URL` (proxy
endpoint).
## Hooks management
After running `mosaic wizard`, Claude hooks are installed in `~/.claude/hooks-config.json`.

View File

@@ -122,11 +122,12 @@ fi
# Source label: this agent's host:session (auto-detected, overridable).
if [ -z "$SRC_LABEL" ]; then
src_host=$(hostname -s 2>/dev/null || echo "?")
src_sess=${MOSAIC_AGENT_NAME:-}
if [ -z "$src_sess" ]; then
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
tmux_cmd=(tmux)
if [ -n "$SOCKET_NAME" ]; then
tmux_cmd+=(-L "$SOCKET_NAME")
fi
src_host=$(hostname -s 2>/dev/null || echo "?")
src_sess=$("${tmux_cmd[@]}" display-message -p '#S' 2>/dev/null || echo "?")
SRC_LABEL="${src_host}:${src_sess}"
fi

View File

@@ -14,9 +14,6 @@
# 5. invalid class => exit 3, nothing sent.
# 6. --class with no value => exit 3.
# 7. the documented consumer regex parses producer output for every class.
# 8. MOSAIC_AGENT_NAME is authoritative for sender identity.
# 9. sender fallback queries local tmux, never the destination -L socket.
# 10. an undeterminable sender is stamped as "?".
set -uo pipefail
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
@@ -24,45 +21,22 @@ TOOL="$HERE/agent-send.sh"
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
STUB=$(mktemp)
FAKE_BIN=$(mktemp -d)
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN"' EXIT
trap 'rm -f "$STUB"' EXIT
cat >"$STUB" <<'STUB_EOF'
#!/usr/bin/env bash
set -uo pipefail
b64=""
while getopts "L:t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
while getopts "t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
printf '%s' "$b64" | base64 -d
STUB_EOF
chmod +x "$STUB"
# Fake tmux distinguishes the sender's default socket from a destination socket.
cat >"$FAKE_BIN/tmux" <<'TMUX_EOF'
#!/usr/bin/env bash
set -uo pipefail
case "${FAKE_TMUX_MODE:-sessions}" in
unavailable) exit 1 ;;
sessions)
if [ "${1:-}" = "-L" ]; then
printf '%s\n' 'destination-holder'
else
printf '%s\n' 'local-agent'
fi
;;
esac
TMUX_EOF
chmod +x "$FAKE_BIN/tmux"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
# Run the tool with the stub injected; echoes captured payload on stdout.
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
run_auto() {
env -u MOSAIC_AGENT_NAME \
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
bash "$TOOL" -n dsthost "$@"
}
# Documented consumer grammar — the daemon will mirror exactly this.
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction)\] (.*)$'
@@ -118,30 +92,6 @@ classic=$(run -s mos -m "plain body")
[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \
&& ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]"
# 8. Exported pane identity wins even when dispatch targets another tmux socket.
src_host=$(hostname -s)
got=$(MOSAIC_AGENT_NAME=authoritative-agent FAKE_TMUX_MODE=sessions \
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
bash "$TOOL" -L destination-socket -n dsthost -s mos -m "env identity")
want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
[ "$got" = "$want" ] && ok "MOSAIC_AGENT_NAME is authoritative across sockets" \
|| no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]"
# 9. Without the env identity, self-lookup uses local tmux, not destination -L.
got=$(FAKE_TMUX_MODE=sessions run_auto -L destination-socket -s mos -m "local fallback")
want="[$src_host:local-agent -> dsthost:mos] local fallback"
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
[[ "$got" != *":destination-holder ->"* ]] \
&& ok "cross-socket fallback rejects destination holder identity" \
|| no "cross-socket fallback rejects destination holder identity" "got=[$got]"
# 10. If neither env nor local tmux identifies the sender, preserve '?'.
got=$(FAKE_TMUX_MODE=unavailable run_auto -L destination-socket -s mos -m "unknown fallback")
want="[$src_host:? -> dsthost:mos] unknown fallback"
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]"
echo "---"
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]

View File

@@ -1,732 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { mkdtempSync, mkdirSync, symlinkSync, rmSync, lstatSync, writeFileSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
import {
CLAUDEX_CONFIG_DIR_ENV,
CLAUDEX_DEFAULT_PRIMARY_MODEL,
CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
CLAUDEX_CREDENTIAL_ENV_RE,
defaultClaudexConfigDir,
assertIsolatedConfigDir,
resolveClaudexConfigDir,
resolveClaudexModels,
buildClaudexEnv,
buildClaudexBanner,
buildClaudexContractNote,
runClaudexProxyGate,
launchClaudex,
type ClaudexHarnessAdapter,
} from './claudex.js';
import { CLAUDEX_PROXY_URL, type PreflightReport } from './claudex-proxy.js';
// ─── helpers ─────────────────────────────────────────────────────────────────
function makeReport(overrides: Partial<PreflightReport> = {}): PreflightReport {
return {
binaryPresent: true,
binaryPath: '/usr/bin/claude-code-proxy',
auth: { state: 'valid' },
live: true,
listenerVerdict: 'ok',
needsReauth: false,
ok: true,
problems: [],
...overrides,
};
}
function okAdapter(overrides: Partial<ClaudexHarnessAdapter> = {}): ClaudexHarnessAdapter {
return {
harnessPreflight: () => {},
composePrompt: () => '# Composed Claude contract',
exec: () => {},
...overrides,
};
}
// Identity canonicalizer + no-op FS deps so config-dir logic is tested purely.
const idCanon = (p: string): string => p;
const noFsDeps = { canonicalize: idCanon, mkdir: () => {}, isSymlink: () => false };
// ─── isolated config dir (HARD SECURITY REQ 1 — provable isolation) ───────────
describe('defaultClaudexConfigDir', () => {
it('is namespaced under the mosaic home, never ~/.claude', () => {
const dir = defaultClaudexConfigDir('/home/agent/.config/mosaic');
expect(dir).toBe(join('/home/agent/.config/mosaic', 'claudex', 'home'));
expect(dir).not.toBe(join(homedir(), '.claude'));
});
});
describe('assertIsolatedConfigDir — the isolation guard is provable', () => {
const realClaude = '/home/agent/.claude';
it('accepts a dir that does not resolve to ~/.claude', () => {
const safe = '/home/agent/.config/mosaic/claudex/home';
expect(
assertIsolatedConfigDir(safe, { realClaudeDir: realClaude, canonicalize: idCanon }),
).toBe(safe);
});
it('REJECTS a candidate that is literally ~/.claude', () => {
expect(() =>
assertIsolatedConfigDir(realClaude, { realClaudeDir: realClaude, canonicalize: idCanon }),
).toThrow(/refusing/i);
});
it('REJECTS a descendant of ~/.claude (would pollute the real tree)', () => {
expect(() =>
assertIsolatedConfigDir('/home/agent/.claude/projects/x', {
realClaudeDir: realClaude,
canonicalize: idCanon,
}),
).toThrow(/refusing/i);
});
it('REJECTS a candidate that canonically resolves to ~/.claude (symlink, both sides canonicalized)', () => {
const canon = (p: string): string => (p === '/home/agent/link' ? realClaude : p);
expect(() =>
assertIsolatedConfigDir('/home/agent/link', {
realClaudeDir: realClaude,
canonicalize: canon,
}),
).toThrow(/refusing/i);
});
it('canonicalizes the ~/.claude side too (real dir itself may be a symlink)', () => {
// realClaudeDir is a symlink whose canonical target equals the candidate's target.
const canon = (p: string): string =>
p === '/home/agent/.claude' || p === '/home/agent/link' ? '/canonical/claude' : p;
expect(() =>
assertIsolatedConfigDir('/home/agent/link', {
realClaudeDir: realClaude,
canonicalize: canon,
}),
).toThrow(/refusing/i);
});
it('REJECTS an empty or whitespace candidate (fail closed)', () => {
expect(() =>
assertIsolatedConfigDir('', { realClaudeDir: realClaude, canonicalize: idCanon }),
).toThrow();
expect(() =>
assertIsolatedConfigDir(' ', { realClaudeDir: realClaude, canonicalize: idCanon }),
).toThrow();
});
it('REJECTS a relative candidate (must be absolute)', () => {
expect(() =>
assertIsolatedConfigDir('relative/dir', { realClaudeDir: realClaude, canonicalize: idCanon }),
).toThrow(/absolute/i);
});
});
describe('resolveClaudexConfigDir', () => {
it('uses the namespaced default and never the ambient CLAUDE_CONFIG_DIR', () => {
// Ambient CLAUDE_CONFIG_DIR is deliberately ignored (it could be ~/.claude).
const env = { CLAUDE_CONFIG_DIR: join(homedir(), '.claude') };
const dir = resolveClaudexConfigDir(env, {
mosaicHome: '/home/agent/.config/mosaic',
realClaudeDir: '/home/agent/.claude',
...noFsDeps,
});
expect(dir).toBe(join('/home/agent/.config/mosaic', 'claudex', 'home'));
});
it('honors the dedicated override env when it is safe', () => {
const env = { [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/custom-claudex' };
const dir = resolveClaudexConfigDir(env, {
mosaicHome: '/home/agent/.config/mosaic',
realClaudeDir: '/home/agent/.claude',
...noFsDeps,
});
expect(dir).toBe('/home/agent/custom-claudex');
});
it('REJECTS a dedicated override that points at ~/.claude (before creating anything)', () => {
const mkdir = vi.fn();
const env = { [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/.claude' };
expect(() =>
resolveClaudexConfigDir(env, {
mosaicHome: '/home/agent/.config/mosaic',
realClaudeDir: '/home/agent/.claude',
canonicalize: idCanon,
mkdir,
isSymlink: () => false,
}),
).toThrow(/refusing/i);
expect(mkdir).not.toHaveBeenCalled();
});
it('TOCTOU: REJECTS when the created target is itself a symlink (pre-created race)', () => {
const env = {};
expect(() =>
resolveClaudexConfigDir(env, {
mosaicHome: '/home/agent/.config/mosaic',
realClaudeDir: '/home/agent/.claude',
canonicalize: idCanon,
mkdir: () => {},
isSymlink: () => true, // the just-ensured dir is a symlink → fail closed
}),
).toThrow(/refusing|symlink/i);
});
it('real-FS: creates the isolated dir 0700 and returns its canonical path', () => {
const root = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
try {
const mosaicHome = join(root, '.config', 'mosaic');
const dir = resolveClaudexConfigDir({}, { mosaicHome, realClaudeDir: join(root, '.claude') });
expect(dir).toBe(join(mosaicHome, 'claudex', 'home'));
const st = lstatSync(dir);
expect(st.isDirectory()).toBe(true);
// 0700 (owner-only) — mask off the type bits.
expect(st.mode & 0o777).toBe(0o700);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('real-FS: catches an override whose ancestor symlinks into ~/.claude', () => {
const root = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
try {
const realClaudeDir = join(root, 'dot-claude');
mkdirSync(realClaudeDir, { recursive: true });
const link = join(root, 'link'); // link -> dot-claude
symlinkSync(realClaudeDir, link, 'dir');
const override = join(link, 'sub'); // resolves under ~/.claude
expect(() =>
resolveClaudexConfigDir(
{ [CLAUDEX_CONFIG_DIR_ENV]: override },
{ mosaicHome: join(root, '.config', 'mosaic'), realClaudeDir },
),
).toThrow(/refusing/i);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('FAIL CLOSED: default canonicalizer rethrows a non-ENOENT error (ELOOP) instead of a literal fallback', () => {
// A symlink loop makes realpathSync throw ELOOP. The guard must NOT swallow
// it as "does not exist yet, keep walking up" and return a literal path —
// it must fail closed. (REQ 1: fails CLOSED on any uncertainty.)
const root = mkdtempSync(join(tmpdir(), 'claudex-loop-'));
try {
const a = join(root, 'a');
const b = join(root, 'b');
symlinkSync(b, a, 'dir'); // a -> b
symlinkSync(a, b, 'dir'); // b -> a (loop)
const looped = join(a, 'home'); // canonicalizing this hits ELOOP
// No canonicalize dep → the real defaultCanonicalizeIntended runs.
expect(() =>
assertIsolatedConfigDir(looped, { realClaudeDir: join(root, '.claude') }),
).toThrow();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('FAIL CLOSED: default isSymlink rethrows a non-ENOENT error (ENOTDIR) rather than reporting "not a symlink"', () => {
// A candidate whose parent is a regular FILE makes lstat throw ENOTDIR.
// The post-create symlink check must fail closed, not treat it as safe.
const root = mkdtempSync(join(tmpdir(), 'claudex-notdir-'));
try {
const file = join(root, 'afile');
writeFileSync(file, 'x');
const candidate = join(file, 'child'); // parent is a file → ENOTDIR on lstat
expect(() =>
// Bypass the guard/mkdir side-effects; only the default isSymlink runs live.
resolveClaudexConfigDir(
{ [CLAUDEX_CONFIG_DIR_ENV]: candidate },
{
realClaudeDir: join(root, '.claude'),
canonicalize: idCanon,
mkdir: () => {},
// isSymlink omitted → real defaultIsSymlink runs on the ENOTDIR path.
},
),
).toThrow();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('FAIL CLOSED: an injected canonicalize throwing EACCES is not swallowed', () => {
const eacces = Object.assign(new Error('permission denied'), { code: 'EACCES' });
expect(() =>
resolveClaudexConfigDir(
{ [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/custom-claudex' },
{
realClaudeDir: '/home/agent/.claude',
canonicalize: () => {
throw eacces;
},
mkdir: () => {},
isSymlink: () => false,
},
),
).toThrow(/permission denied/);
});
});
// ─── model-tier map (P3) ──────────────────────────────────────────────────────
describe('resolveClaudexModels', () => {
it('defaults primary=sol / smallFast=luna', () => {
expect(resolveClaudexModels({})).toEqual({
primary: CLAUDEX_DEFAULT_PRIMARY_MODEL,
smallFast: CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
});
expect(CLAUDEX_DEFAULT_PRIMARY_MODEL).toBe('gpt-5.6-sol');
expect(CLAUDEX_DEFAULT_SMALL_FAST_MODEL).toBe('gpt-5.6-luna');
});
it('env-provided values WIN over defaults', () => {
expect(
resolveClaudexModels({ ANTHROPIC_MODEL: 'gpt-x', ANTHROPIC_SMALL_FAST_MODEL: 'gpt-y' }),
).toEqual({ primary: 'gpt-x', smallFast: 'gpt-y' });
});
it('ignores blank env values (falls back to defaults)', () => {
expect(
resolveClaudexModels({ ANTHROPIC_MODEL: ' ', ANTHROPIC_SMALL_FAST_MODEL: '' }),
).toEqual({
primary: CLAUDEX_DEFAULT_PRIMARY_MODEL,
smallFast: CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
});
});
});
// ─── env injection (HARD SECURITY REQ 2 — zero token leakage) ─────────────────
describe('buildClaudexEnv — zero token leakage', () => {
const models = { primary: 'gpt-5.6-sol', smallFast: 'gpt-5.6-luna' };
const configDir = '/home/agent/.config/mosaic/claudex/home';
it('sets only ANTHROPIC_AUTH_TOKEN=unused and points at the loopback proxy', () => {
const env = buildClaudexEnv({}, { configDir, models });
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
expect(env.CLAUDE_CONFIG_DIR).toBe(configDir);
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol');
expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-5.6-luna');
});
it('OVERWRITES an inherited real auth token with the literal "unused"', () => {
const env = buildClaudexEnv(
{ ANTHROPIC_AUTH_TOKEN: 'sk-ant-realsecret-should-never-flow' },
{ configDir, models },
);
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
});
it('DELETES ANTHROPIC_API_KEY so no real Anthropic key reaches the local proxy', () => {
const env = buildClaudexEnv(
{ ANTHROPIC_API_KEY: 'sk-ant-api03-realkey' },
{ configDir, models },
);
expect('ANTHROPIC_API_KEY' in env).toBe(false);
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
});
it('sweeps the WHOLE credential-bearing env family (token/api-key/secret/oauth), not just two', () => {
const env = buildClaudexEnv(
{
ANTHROPIC_API_KEY: 'sk-ant-api03-leak',
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-leak',
SOME_SERVICE_TOKEN: 'tok-leak',
VENDOR_API_KEY: 'key-leak',
DB_SECRET: 'secret-leak',
HARMLESS: 'kept',
PATH: '/usr/bin',
},
{ configDir, models },
);
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
expect(env.SOME_SERVICE_TOKEN).toBeUndefined();
expect(env.VENDOR_API_KEY).toBeUndefined();
expect(env.DB_SECRET).toBeUndefined();
// Non-credential vars the harness needs are preserved.
expect(env.HARMLESS).toBe('kept');
expect(env.PATH).toBe('/usr/bin');
});
it('neutralizes Bedrock/Vertex provider switches so Claude cannot bypass the proxy (REQ 2)', () => {
// CLAUDE_CODE_USE_BEDROCK / _USE_VERTEX are ROUTING switches: their mere
// presence makes Claude Code route to AWS Bedrock / GCP Vertex against the
// ambient cloud credential chain — reaching the real Anthropic API and
// bypassing ANTHROPIC_BASE_URL (the loopback proxy) entirely. They MUST be
// gone from the composed env regardless of the launching env.
const env = buildClaudexEnv(
{
CLAUDE_CODE_USE_BEDROCK: '1',
CLAUDE_CODE_USE_VERTEX: '1',
CLAUDE_CODE_SKIP_BEDROCK_AUTH: '1',
CLAUDE_CODE_SKIP_VERTEX_AUTH: '1',
AWS_ACCESS_KEY_ID: 'AKIAREAL',
AWS_SECRET_ACCESS_KEY: 'realsecret',
AWS_SESSION_TOKEN: 'realsession',
AWS_BEARER_TOKEN_BEDROCK: 'bearer-bedrock-real',
AWS_REGION: 'us-east-1',
GOOGLE_APPLICATION_CREDENTIALS: '/home/agent/gcp.json',
GOOGLE_CLOUD_ACCESS_TOKEN: 'gcp-token-real',
PATH: '/usr/bin',
},
{ configDir, models },
);
// Routing switches gone by construction.
expect('CLAUDE_CODE_USE_BEDROCK' in env).toBe(false);
expect('CLAUDE_CODE_USE_VERTEX' in env).toBe(false);
expect('CLAUDE_CODE_SKIP_BEDROCK_AUTH' in env).toBe(false);
expect('CLAUDE_CODE_SKIP_VERTEX_AUTH' in env).toBe(false);
// Cloud credentials swept — none of the Claude-capable creds survive.
expect(env.AWS_ACCESS_KEY_ID).toBeUndefined();
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined();
expect(env.AWS_SESSION_TOKEN).toBeUndefined();
expect(env.AWS_BEARER_TOKEN_BEDROCK).toBeUndefined();
expect(env.AWS_REGION).toBeUndefined();
expect(env.GOOGLE_APPLICATION_CREDENTIALS).toBeUndefined();
expect(env.GOOGLE_CLOUD_ACCESS_TOKEN).toBeUndefined();
// The proxy routing is still the only path.
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
expect(env.PATH).toBe('/usr/bin');
});
it('closes the mid-string _KEY / _SECRET gap (STRIPE_SECRET_KEY, SSH_PRIVATE_KEY)', () => {
const env = buildClaudexEnv(
{
STRIPE_SECRET_KEY: 'sk-live-real',
SSH_PRIVATE_KEY: '-----BEGIN OPENSSH PRIVATE KEY-----',
HARMLESS: 'kept',
},
{ configDir, models },
);
expect(env.STRIPE_SECRET_KEY).toBeUndefined();
expect(env.SSH_PRIVATE_KEY).toBeUndefined();
expect(env.HARMLESS).toBe('kept');
});
it('no credential-NAMED key in the composed env carries a real-looking value', () => {
const env = buildClaudexEnv(
{
ANTHROPIC_API_KEY: 'sk-ant-api03-leak',
ANTHROPIC_AUTH_TOKEN: 'access_token_leak',
SOME_JWT_TOKEN: 'eyJhbGciOiJIUzI1NiJ9.payload.sig',
REFRESH_SECRET: 'refresh_token_value',
},
{ configDir, models },
);
for (const [name, value] of Object.entries(env)) {
if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) {
// Any surviving credential-named var must carry only a safe sentinel value.
expect(value).not.toMatch(/sk-(ant|proj)-/);
expect(value).not.toMatch(/access_token|refresh_token/);
expect(value).not.toMatch(/eyJ[A-Za-z0-9_-]+\./); // JWT
}
}
});
it('honors a caller-provided baseUrl override (loopback default otherwise)', () => {
const env = buildClaudexEnv({}, { configDir, models, baseUrl: 'http://127.0.0.1:9999' });
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9999');
});
it('returns a fresh object without mutating the base env', () => {
const base = { EXISTING: 'kept' };
const env = buildClaudexEnv(base, { configDir, models });
expect(env.EXISTING).toBe('kept');
expect(base).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN');
});
});
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
describe('buildClaudexBanner / buildClaudexContractNote', () => {
const models = { primary: 'gpt-5.6-sol', smallFast: 'gpt-5.6-luna' };
it('banner marks EXPERIMENTAL and names the models + proxy', () => {
const banner = buildClaudexBanner(models);
expect(banner).toMatch(/EXPERIMENTAL/);
expect(banner).toMatch(/gpt-5\.6-sol/);
expect(banner).toMatch(/gpt-5\.6-luna/);
expect(banner).toMatch(/claude-code-proxy/);
expect(banner).not.toMatch(/unused/); // no token material in the banner
});
it('contract note classifies the runtime as EXPERIMENTAL GPT-via-proxy', () => {
const note = buildClaudexContractNote(models);
expect(note).toMatch(/EXPERIMENTAL/);
expect(note).toMatch(/gpt-5\.6-sol/);
expect(note).toMatch(/not.*Anthropic/i);
});
});
// ─── proxy gate ───────────────────────────────────────────────────────────────
describe('runClaudexProxyGate', () => {
it('is ok when the first preflight already passes', async () => {
const preflight = vi.fn().mockResolvedValue(makeReport());
const ensureProxy = vi.fn();
const reauth = vi.fn();
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth });
expect(gate.ok).toBe(true);
expect(ensureProxy).not.toHaveBeenCalled();
expect(reauth).not.toHaveBeenCalled();
});
it('fails fast when the binary is missing (no reauth, no start)', async () => {
const preflight = vi
.fn()
.mockResolvedValue(
makeReport({ binaryPresent: false, ok: false, problems: ['binary not found'] }),
);
const ensureProxy = vi.fn();
const reauth = vi.fn();
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth });
expect(gate.ok).toBe(false);
expect(reauth).not.toHaveBeenCalled();
expect(ensureProxy).not.toHaveBeenCalled();
});
it('runs device reauth then re-preflights when OAuth needs it', async () => {
const preflight = vi
.fn()
.mockResolvedValueOnce(
makeReport({
auth: { state: 'expired' },
needsReauth: true,
ok: false,
problems: ['expired'],
}),
)
.mockResolvedValueOnce(makeReport());
const reauth = vi.fn().mockReturnValue(0);
const gate = await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
expect(reauth).toHaveBeenCalledTimes(1);
expect(preflight).toHaveBeenCalledTimes(2);
expect(gate.ok).toBe(true);
});
it('does NOT reauth when auth is already valid', async () => {
const preflight = vi.fn().mockResolvedValue(makeReport());
const reauth = vi.fn();
await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
expect(reauth).not.toHaveBeenCalled();
});
it('aborts when device reauth fails', async () => {
const preflight = vi.fn().mockResolvedValue(
makeReport({
auth: { state: 'unauthenticated' },
needsReauth: true,
ok: false,
problems: ['unauth'],
}),
);
const reauth = vi.fn().mockReturnValue(1);
const gate = await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
expect(gate.ok).toBe(false);
expect(gate.problems.join(' ')).toMatch(/re-auth/i);
});
it('starts the proxy then re-preflights when nothing is live', async () => {
const preflight = vi
.fn()
.mockResolvedValueOnce(
makeReport({ live: false, listenerVerdict: 'unknown', ok: false, problems: ['dead'] }),
)
.mockResolvedValueOnce(makeReport());
const ensureProxy = vi.fn().mockResolvedValue({ live: true, method: 'systemd' });
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth: vi.fn() });
expect(ensureProxy).toHaveBeenCalledTimes(1);
expect(gate.ok).toBe(true);
});
it('aborts (non-sensitive) when the proxy cannot come up trusted', async () => {
const preflight = vi
.fn()
.mockResolvedValue(
makeReport({ live: false, listenerVerdict: 'unknown', ok: false, problems: ['dead'] }),
);
const ensureProxy = vi.fn().mockResolvedValue({ live: false, method: 'untrusted' });
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth: vi.fn() });
expect(gate.ok).toBe(false);
expect(gate.problems.join(' ')).toMatch(/untrusted/);
// Non-sensitive: no token material in surfaced problems.
expect(gate.problems.join(' ')).not.toMatch(/access_token|refresh_token|sk-/);
});
});
// ─── launch orchestration (fail-closed ordering) ──────────────────────────────
describe('launchClaudex', () => {
const baseDeps = {
baseEnv: {},
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
resolveConfigDir: () => '/home/agent/.config/mosaic/claudex/home',
log: () => {},
errorLog: () => {},
fail: (() => {
throw new Error('exit');
}) as (code: number) => never,
};
it('yolo=true passes --dangerously-skip-permissions + injected env to claude', async () => {
const exec = vi.fn();
await launchClaudex(['--print', 'hi'], true, okAdapter({ exec }), baseDeps);
expect(exec).toHaveBeenCalledTimes(1);
const [cmd, args, env] = exec.mock.calls[0]!;
expect(cmd).toBe('claude');
expect(args[0]).toBe('--dangerously-skip-permissions');
expect(args).toContain('--append-system-prompt');
expect(args).toContain('--print');
expect(args).toContain('hi');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
expect(env.CLAUDE_CONFIG_DIR).toBe('/home/agent/.config/mosaic/claudex/home');
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol');
});
it('non-yolo omits --dangerously-skip-permissions but still injects the proxy env', async () => {
const exec = vi.fn();
await launchClaudex([], false, okAdapter({ exec }), baseDeps);
const [, args, env] = exec.mock.calls[0]!;
expect(args).not.toContain('--dangerously-skip-permissions');
expect(args[0]).toBe('--append-system-prompt');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
});
it('appends the EXPERIMENTAL contract note to the composed prompt', async () => {
const exec = vi.fn();
await launchClaudex([], true, okAdapter({ exec, composePrompt: () => '# BASE' }), baseDeps);
const args = exec.mock.calls[0]![1] as string[];
const promptIdx = args.indexOf('--append-system-prompt') + 1;
expect(args[promptIdx]).toContain('# BASE');
expect(args[promptIdx]).toMatch(/EXPERIMENTAL/);
});
it('runs the harness preflight BEFORE the proxy gate and exec', async () => {
const order: string[] = [];
const adapter = okAdapter({
harnessPreflight: () => order.push('preflight'),
exec: () => order.push('exec'),
});
await launchClaudex([], true, adapter, {
...baseDeps,
proxyGate: () => {
order.push('gate');
return Promise.resolve({ ok: true, report: makeReport(), problems: [] });
},
});
expect(order).toEqual(['preflight', 'gate', 'exec']);
});
it('FAIL CLOSED: exits WITHOUT exec when the proxy gate fails', async () => {
const exec = vi.fn();
const errors: string[] = [];
await expect(
launchClaudex([], true, okAdapter({ exec }), {
...baseDeps,
proxyGate: () =>
Promise.resolve({ ok: false, report: makeReport({ ok: false }), problems: ['no proxy'] }),
errorLog: (m: string) => errors.push(m),
}),
).rejects.toThrow('exit');
expect(exec).not.toHaveBeenCalled();
expect(errors.join('\n')).toMatch(/no proxy/);
});
it('FAIL CLOSED: exits WITHOUT exec when the config-dir guard throws', async () => {
const exec = vi.fn();
await expect(
launchClaudex([], true, okAdapter({ exec }), {
...baseDeps,
resolveConfigDir: () => {
throw new Error('refusing to use ~/.claude');
},
}),
).rejects.toThrow('exit');
expect(exec).not.toHaveBeenCalled();
});
it('FAIL CLOSED: reports a non-Error throw via String() and still aborts', async () => {
const exec = vi.fn();
const errors: string[] = [];
await expect(
launchClaudex([], true, okAdapter({ exec }), {
...baseDeps,
resolveConfigDir: () => {
// A non-Error throw exercises the String(err) branch of the catch.
throw { toString: () => 'string-shaped failure' };
},
errorLog: (m: string) => errors.push(m),
}),
).rejects.toThrow('exit');
expect(exec).not.toHaveBeenCalled();
expect(errors.join('\n')).toMatch(/string-shaped failure/);
});
});
// ─── production DI defaults (fallback-branch coverage; no real proxy touched) ──
describe('production dependency defaults', () => {
it('assertIsolatedConfigDir defaults realClaudeDir to ~/.claude', () => {
const safe = join(tmpdir(), 'mosaic-claudex-default-real', 'home');
// Only canonicalize injected; realClaudeDir falls back to ~/.claude.
expect(assertIsolatedConfigDir(safe, { canonicalize: idCanon })).toBe(safe);
});
it('assertIsolatedConfigDir default canonicalizer resolves a non-existent path', () => {
// No canonicalize dep → exercises the real realpath-longest-ancestor walk
// (including the not-yet-existing tail), on a path safely outside ~/.claude.
const safe = join(tmpdir(), 'mosaic-claudex-canon', 'nested', 'home');
expect(assertIsolatedConfigDir(safe)).toContain('mosaic-claudex-canon');
});
it('resolveClaudexConfigDir defaults mosaicHome when not injected', () => {
const dir = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
try {
const target = join(dir, 'home');
// Override env points elsewhere; mosaicHome dep omitted → MOSAIC_HOME default path is exercised.
const out = resolveClaudexConfigDir(
{ [CLAUDEX_CONFIG_DIR_ENV]: target },
{ canonicalize: idCanon },
);
expect(out).toBe(target);
expect(lstatSync(target).isDirectory()).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('runClaudexProxyGate defaults ensureProxy/reauth/log without invoking them on a missing binary', async () => {
// Only preflight injected; binary missing → returns before the default
// ensureProxy/reauth thunks could ever reach the real proxy.
const preflight = vi
.fn()
.mockResolvedValue(makeReport({ binaryPresent: false, ok: false, problems: ['missing'] }));
const gate = await runClaudexProxyGate({ preflight });
expect(gate.ok).toBe(false);
});
it('launchClaudex defaults log/errorLog/fail/baseEnv/models/buildEnv on the success path', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
const exec = vi.fn();
// Inject only the boundaries that would touch the real proxy/FS; let the
// rest default. Success path never calls fail/errorLog.
await launchClaudex([], true, okAdapter({ exec }), {
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
resolveConfigDir: () => join(tmpdir(), 'claudex-default-launch'),
});
expect(exec).toHaveBeenCalledTimes(1);
const [, , env] = exec.mock.calls[0]!;
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
expect(env.ANTHROPIC_MODEL).toBe(CLAUDEX_DEFAULT_PRIMARY_MODEL);
} finally {
logSpy.mockRestore();
}
});
});

View File

@@ -1,464 +0,0 @@
/**
* Claudex launch composition (P2P4 of `mosaic yolo claudex`).
*
* Builds the isolated launch environment for running GPT models inside the
* Claude Code harness via `raine/claude-code-proxy` (ChatGPT-subscription OAuth).
* PR-1 (`claudex-proxy.ts`) owns the proxy preflight/lifecycle; this module owns
* the *composition* the launcher hands to Claude Code:
*
* P2 isolated CLAUDE_CONFIG_DIR (provably never the real ~/.claude) + env
* injection that leaks ZERO token material;
* P3 the model-tier map (primary → gpt-5.6-sol, small/fast → gpt-5.6-luna,
* operator env values win);
* P4 the EXPERIMENTAL classification banner + composed-contract note.
*
* Two hard security invariants (secrev-enforced):
* REQ 1 — Provable isolation. {@link assertIsolatedConfigDir} makes the
* CLAUDE_CONFIG_DIR seam incapable of resolving to `~/.claude` (or any
* descendant of it); it canonicalizes both sides, rejects descendants,
* and — after ensuring the dir — re-checks and rejects a symlinked
* target (TOCTOU). Fails CLOSED on any uncertainty.
* REQ 2 — Zero token leakage. This module never reads the proxy's
* `auth.json`; {@link buildClaudexEnv} strips the ENTIRE
* credential-bearing env family and hands Claude Code only
* `ANTHROPIC_AUTH_TOKEN=unused`. The proxy holds the real credential.
*
* Every side-effecting boundary is dependency-injected so the launch path is
* unit-testable without spawning Claude Code or touching a real config dir.
*/
import { lstatSync, mkdirSync, realpathSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import {
CLAUDEX_PROXY_URL,
ensureProxyRunning,
runDeviceReauth,
runProxyPreflight,
type EnsureProxyResult,
type PreflightReport,
} from './claudex-proxy.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
// ─── Isolated CLAUDE_CONFIG_DIR (HARD SECURITY REQ 1) ─────────────────────────
/** Dedicated override env for the isolated config dir. The ambient
* `CLAUDE_CONFIG_DIR` is deliberately NOT honored — it may already point at the
* real `~/.claude` of the launching session. */
export const CLAUDEX_CONFIG_DIR_ENV = 'MOSAIC_CLAUDEX_CONFIG_DIR';
/** The default isolated config dir — structurally under the mosaic home, so it
* can never equal `~/.claude`. */
export function defaultClaudexConfigDir(mosaicHome: string = MOSAIC_HOME): string {
return join(mosaicHome, 'claudex', 'home');
}
export interface ConfigDirDeps {
/** The real Claude state dir to protect (default `~/.claude`). */
realClaudeDir?: string;
/** Resolve a path to canonical form, resolving symlinks on the longest
* existing ancestor (so a not-yet-created dir still canonicalizes). */
canonicalize?: (p: string) => string;
/** Ensure the isolated dir exists (mkdir -p, owner-only 0700). */
mkdir?: (p: string) => void;
/** Whether a path is itself a symlink (lstat). */
isSymlink?: (p: string) => boolean;
}
/** Resolve a path to canonical form, resolving symlinks on the LONGEST EXISTING
* ancestor and re-appending the not-yet-existing tail. A symlinked ancestor that
* points into `~/.claude` is therefore caught even before the leaf exists. */
function defaultCanonicalizeIntended(p: string): string {
const abs = resolve(p);
let existing = abs;
const tail: string[] = [];
// Walk up until we hit an existing ancestor (or the filesystem root).
for (;;) {
try {
const real = realpathSync(existing);
return tail.length > 0 ? join(real, ...tail) : real;
} catch (err) {
// ONLY a genuine "does not exist yet" (ENOENT) justifies walking up to an
// existing ancestor. Any other errno (ELOOP, EACCES, ENOTDIR, …) means we
// cannot establish the canonical form — fail CLOSED rather than fall back
// to a possibly-wrong literal path.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
const parent = dirname(existing);
if (parent === existing) return abs; // reached root without an existing prefix
tail.unshift(existing.slice(parent.length + 1));
existing = parent;
}
}
}
function defaultMkdir(p: string): void {
mkdirSync(p, { recursive: true, mode: 0o700 });
}
function defaultIsSymlink(p: string): boolean {
try {
return lstatSync(p).isSymbolicLink();
} catch (err) {
// A missing path is genuinely "not a symlink"; anything else (EACCES, ELOOP,
// ENOTDIR, …) is uncertainty the guard must not swallow — fail CLOSED.
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw err;
}
}
/** True when `child` is `parent` itself or a descendant of it (path-wise). */
function isWithin(child: string, parent: string): boolean {
if (child === parent) return true;
const rel = relative(parent, child);
return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel);
}
/**
* Guard: prove that `candidate` is a legitimate ISOLATED config dir and can
* never be, resolve to, or live under the real `~/.claude`. Canonicalizes BOTH
* sides (either may be a symlink), rejects `~/.claude` and any descendant, and
* fails CLOSED (throws) on an empty/relative candidate. Returns the canonical
* isolated path on success.
*/
export function assertIsolatedConfigDir(candidate: string, deps: ConfigDirDeps = {}): string {
const canonicalize = deps.canonicalize ?? defaultCanonicalizeIntended;
const realClaudeDir = deps.realClaudeDir ?? join(homedir(), '.claude');
if (typeof candidate !== 'string' || candidate.trim() === '') {
throw new Error('claudex: isolated CLAUDE_CONFIG_DIR must be a non-empty path (fail closed).');
}
if (!isAbsolute(candidate)) {
throw new Error(
`claudex: isolated CLAUDE_CONFIG_DIR must be an absolute path: ${JSON.stringify(candidate)}`,
);
}
const canonCandidate = canonicalize(candidate);
const canonReal = canonicalize(realClaudeDir);
// Compare canonical forms AND raw resolved forms — belt and suspenders so a
// canonicalizer that no-ops on a nonexistent real dir still catches the literal.
if (isWithin(canonCandidate, canonReal) || isWithin(resolve(candidate), resolve(realClaudeDir))) {
throw new Error(
`claudex: refusing to use the real Claude config dir (or a descendant of it) as the ` +
`isolated CLAUDE_CONFIG_DIR. Resolved to ${JSON.stringify(canonCandidate)}.`,
);
}
return canonCandidate;
}
/**
* Resolve the isolated CLAUDE_CONFIG_DIR: pick the dedicated override or the
* namespaced default, run the pre-create guard, ensure the dir (0700), then
* RE-CHECK after creation — reject a symlinked target and re-run the guard on
* the now-existing (fully canonicalizable) path. This closes the pre-created
* symlink race (TOCTOU). Every failure throws (fail closed).
*/
export function resolveClaudexConfigDir(
env: NodeJS.ProcessEnv = process.env,
deps: ConfigDirDeps & { mosaicHome?: string } = {},
): string {
const mosaicHome = deps.mosaicHome ?? MOSAIC_HOME;
const mkdir = deps.mkdir ?? defaultMkdir;
const isSymlink = deps.isSymlink ?? defaultIsSymlink;
const override = env[CLAUDEX_CONFIG_DIR_ENV]?.trim();
const candidate =
override && override.length > 0 ? override : defaultClaudexConfigDir(mosaicHome);
// Pre-create guard (before touching the filesystem).
assertIsolatedConfigDir(candidate, deps);
// Ensure the dir, then re-verify against the post-create reality.
mkdir(candidate);
if (isSymlink(candidate)) {
throw new Error(
'claudex: refusing to use the isolated CLAUDE_CONFIG_DIR — the target is a symlink ' +
'(possible pre-created race). Fail closed.',
);
}
// Re-run the guard now that the leaf exists so canonicalization reflects any
// symlinked ancestor introduced between the pre-check and mkdir.
return assertIsolatedConfigDir(candidate, deps);
}
// ─── Model-tier map (P3) ──────────────────────────────────────────────────────
export const CLAUDEX_DEFAULT_PRIMARY_MODEL = 'gpt-5.6-sol';
export const CLAUDEX_DEFAULT_SMALL_FAST_MODEL = 'gpt-5.6-luna';
export interface ClaudexModels {
/** Primary tier (opus/sonnet) → ANTHROPIC_MODEL. */
primary: string;
/** Small/fast tier (haiku) → ANTHROPIC_SMALL_FAST_MODEL. */
smallFast: string;
}
/** Resolve the model-tier map. Operator-provided env values WIN over defaults;
* blank values fall back to the defaults. */
export function resolveClaudexModels(env: NodeJS.ProcessEnv = process.env): ClaudexModels {
const primary = env['ANTHROPIC_MODEL']?.trim() || CLAUDEX_DEFAULT_PRIMARY_MODEL;
const smallFast = env['ANTHROPIC_SMALL_FAST_MODEL']?.trim() || CLAUDEX_DEFAULT_SMALL_FAST_MODEL;
return { primary, smallFast };
}
// ─── Env injection (HARD SECURITY REQ 2 — zero token leakage) ─────────────────
/**
* Names of env vars considered credential-bearing. The whole family is stripped
* from the composed env so no real Anthropic key, OAuth token, or third-party /
* cloud credential can reach the local proxy or be used by Claude Code to bypass
* it. We then re-add ONLY the safe claudex vars (`ANTHROPIC_MODEL`,
* `_SMALL_FAST_MODEL`, `_BASE_URL`, `_AUTH_TOKEN=unused`). A name-pattern sweep
* can't miss a specific var a short denylist forgot, while still preserving the
* arbitrary non-credential env the harness/MCP/hooks require (PATH, HOME, XDG,
* terminal, proxies, …), which a strict allowlist would fragilely drop.
*
* The cloud-provider families (`AWS_*`, `GOOGLE_APPLICATION_CREDENTIALS`,
* `GOOGLE_CLOUD_*`, `GCP_*`) are included because Claude Code can route to the
* real Anthropic API via AWS Bedrock / GCP Vertex using the ambient cloud
* credential chain — a Claude-capable credential that must never survive into a
* claudex launch. `_KEY$` / `_SECRET` (not just the `_API_KEY$` tail) close the
* mid-string gap (`STRIPE_SECRET_KEY`, `SSH_PRIVATE_KEY`, `AWS_SECRET_ACCESS_KEY`).
*/
export const CLAUDEX_CREDENTIAL_ENV_RE =
/^ANTHROPIC_|^CLAUDE_CODE_OAUTH|^AWS_|^GOOGLE_APPLICATION_CREDENTIALS$|^GOOGLE_CLOUD_|^GCP_|_API_?KEY$|_KEY$|_TOKEN$|_SECRET/i;
/**
* Provider ROUTING switches whose mere PRESENCE (independent of any credential)
* makes Claude Code bypass `ANTHROPIC_BASE_URL` (the loopback proxy) and talk to
* the real Anthropic API via Bedrock/Vertex. A name-pattern is the wrong model
* for a boolean switch, so these are force-deleted by exact name — REGARDLESS of
* value — after the credential sweep. (REQ 2: isolation must hold for any
* launching env, including a Bedrock/Vertex-configured enterprise host.)
*/
export const CLAUDEX_FORCED_UNSET_ENV = [
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
] as const;
export interface BuildClaudexEnvOptions {
configDir: string;
models: ClaudexModels;
/** Override the proxy base URL (defaults to the PR-1 loopback constant). */
baseUrl?: string;
}
/**
* Compose the launch env for Claude Code. Returns a FRESH object (never mutates
* the base env). Strips the entire credential-bearing family AND force-deletes
* the Bedrock/Vertex routing switches (REQ 2), then sets the isolated config dir
* and the proxy routing. Claude Code sees only `ANTHROPIC_AUTH_TOKEN=unused`
* pointed at the loopback proxy; the proxy holds the real OAuth credential, which
* this module never reads.
*/
export function buildClaudexEnv(
baseEnv: NodeJS.ProcessEnv,
opts: BuildClaudexEnvOptions,
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [name, value] of Object.entries(baseEnv)) {
if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) continue; // drop the whole credential family
env[name] = value;
}
// Force-delete routing switches by exact name — their presence (not their
// value) is what would route Claude Code off the proxy to the real API.
for (const name of CLAUDEX_FORCED_UNSET_ENV) delete env[name];
env['CLAUDE_CONFIG_DIR'] = opts.configDir;
env['ANTHROPIC_BASE_URL'] = opts.baseUrl ?? CLAUDEX_PROXY_URL;
env['ANTHROPIC_AUTH_TOKEN'] = 'unused';
env['ANTHROPIC_MODEL'] = opts.models.primary;
env['ANTHROPIC_SMALL_FAST_MODEL'] = opts.models.smallFast;
return env;
}
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
/** Console banner shown at launch. Contains no token material by construction. */
export function buildClaudexBanner(models: ClaudexModels): string {
return [
'',
' ┌─────────────────────────────────────────────────────────────────────┐',
' │ ⚠ EXPERIMENTAL — mosaic claudex │',
' └─────────────────────────────────────────────────────────────────────┘',
` Running GPT models inside the Claude Code harness via claude-code-proxy`,
` (ChatGPT-subscription OAuth). This is NOT Anthropic Claude.`,
` primary : ${models.primary}`,
` small/fast : ${models.smallFast}`,
` Model behavior, tool use, and output quality may differ from Claude.`,
'',
].join('\n');
}
/** Markdown note appended to the composed runtime contract so the model itself
* knows it is running the EXPERIMENTAL GPT-via-proxy configuration. */
export function buildClaudexContractNote(models: ClaudexModels): string {
return [
'# EXPERIMENTAL Runtime — claudex (GPT via claude-code-proxy)',
'',
'You are running in Mosaic **claudex** mode: the Claude Code harness is wired to',
'GPT models through a local `claude-code-proxy` (ChatGPT-subscription OAuth). This',
"runtime is NOT Anthropic's Claude API and is not Claude.",
'',
`- Primary model: \`${models.primary}\``,
`- Small/fast model: \`${models.smallFast}\``,
'',
'Some Claude-specific harness assumptions may not hold under GPT models — verify',
'tool output carefully. This classification is EXPERIMENTAL and is not intended for',
'production delivery without explicit operator sign-off.',
].join('\n');
}
// ─── Proxy gate ───────────────────────────────────────────────────────────────
export interface ProxyGateResult {
ok: boolean;
report: PreflightReport;
/** Non-sensitive problems suitable for surfacing to the operator. */
problems: string[];
}
export interface ProxyGateDeps {
preflight?: () => Promise<PreflightReport>;
ensureProxy?: () => Promise<EnsureProxyResult>;
reauth?: () => number;
log?: (message: string) => void;
}
/**
* Run the proxy readiness gate: preflight → (device reauth if OAuth needs it) →
* (start the proxy if nothing trusted is live) → re-preflight. Returns `ok` only
* when the final preflight passes (binary present, OAuth valid, a TRUSTED-live
* listener — identity verified by PR-1's `verifyListenerIdentity`). All surfaced
* problems are non-sensitive (port + verdict only; never a token).
*/
export async function runClaudexProxyGate(deps: ProxyGateDeps = {}): Promise<ProxyGateResult> {
const preflight = deps.preflight ?? (() => runProxyPreflight());
const ensureProxy = deps.ensureProxy ?? (() => ensureProxyRunning());
const reauth = deps.reauth ?? (() => runDeviceReauth());
const log = deps.log ?? (() => {});
let report = await preflight();
// A missing binary is unrecoverable here — don't attempt reauth or a start.
if (!report.binaryPresent) {
return { ok: false, report, problems: report.problems };
}
if (report.needsReauth) {
log('claudex: claude-code-proxy OAuth needs re-authentication — starting device flow…');
const code = reauth();
if (code !== 0) {
return {
ok: false,
report,
problems: [...report.problems, 'claudex: device re-authentication did not complete.'],
};
}
report = await preflight();
}
if (!report.live) {
log('claudex: no trusted claude-code-proxy responding — starting it…');
const started = await ensureProxy();
if (!started.live) {
return {
ok: false,
report,
problems: [
...report.problems,
`claudex: could not bring up a trusted claude-code-proxy (${started.method}).`,
],
};
}
report = await preflight();
}
return { ok: report.ok, report, problems: report.problems };
}
// ─── Launch orchestration ─────────────────────────────────────────────────────
/**
* The launch.ts-provided seam. Keeps `claudex.ts` free of a circular import back
* into `launch.ts` while letting the orchestration reuse the harness preflight,
* the composed runtime contract, and the process-replacing exec.
*/
export interface ClaudexHarnessAdapter {
/** Runs the Claude-harness preflight (mosaic home, SOUL, `claude` on PATH,
* sequential-thinking). May terminate the process on a hard failure. */
harnessPreflight: () => void;
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */
composePrompt: () => string;
/** Replace the current process with `claude` using the composed env. */
exec: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
}
export interface LaunchClaudexDeps {
baseEnv?: NodeJS.ProcessEnv;
proxyGate?: () => Promise<ProxyGateResult>;
resolveConfigDir?: () => string;
models?: () => ClaudexModels;
buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv;
log?: (message: string) => void;
errorLog?: (message: string) => void;
fail?: (code: number) => never;
}
/**
* Orchestrate a `mosaic [yolo] claudex` launch. Runs the harness preflight, the
* proxy gate, composes the isolated env (REQ 1 + REQ 2), appends the EXPERIMENTAL
* note, and exec's Claude Code. FAIL CLOSED: on any gate failure or guard throw
* it reports non-sensitive detail and exits WITHOUT reaching exec.
*/
export async function launchClaudex(
args: string[],
yolo: boolean,
adapter: ClaudexHarnessAdapter,
deps: LaunchClaudexDeps = {},
): Promise<void> {
const log = deps.log ?? ((m: string) => console.log(m));
const errorLog = deps.errorLog ?? ((m: string) => console.error(m));
const fail = deps.fail ?? ((code: number) => process.exit(code));
const baseEnv = deps.baseEnv ?? process.env;
const proxyGate = deps.proxyGate ?? (() => runClaudexProxyGate({ log }));
const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv));
const models = deps.models ?? (() => resolveClaudexModels(baseEnv));
const buildEnv = deps.buildEnv ?? buildClaudexEnv;
try {
// Harness readiness first (claude on PATH, mosaic home, sequential-thinking).
adapter.harnessPreflight();
// Proxy readiness (binary, OAuth, trusted-live listener).
const gate = await proxyGate();
if (!gate.ok) {
errorLog('[mosaic] claudex preflight failed:');
for (const problem of gate.problems) errorLog(` - ${problem}`);
return fail(1);
}
// Compose the isolated launch env (guard throws → caught below, fail closed).
const resolvedModels = models();
const configDir = resolveConfigDir();
const env = buildEnv(baseEnv, { configDir, models: resolvedModels });
const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`;
log(buildClaudexBanner(resolvedModels));
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
cliArgs.push('--append-system-prompt', prompt, ...args);
adapter.exec('claude', cliArgs, env);
} catch (err) {
errorLog(
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,
);
return fail(1);
}
}

View File

@@ -9,7 +9,6 @@ import {
piForceSkillNames,
registerRuntimeLaunchers,
type RuntimeLaunchHandler,
type ClaudexLaunchHandler,
} from './launch.js';
/**
@@ -32,16 +31,6 @@ function buildProgram(handler: RuntimeLaunchHandler): Command {
return program;
}
function buildProgramWithClaudex(
handler: RuntimeLaunchHandler,
claudexHandler: ClaudexLaunchHandler,
): Command {
const program = new Command();
program.exitOverride();
registerRuntimeLaunchers(program, handler, claudexHandler);
return program;
}
const fakeSkills = ['--skill', '/skills/test-driven-development', '--skill', '/skills/pdf'];
const fakeForced = ['--skill', '/skills/mosaic-tools'];
@@ -291,61 +280,3 @@ describe('registerRuntimeLaunchers — yolo <runtime>', () => {
expect(mockExit).toHaveBeenCalledWith(1);
});
});
describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => {
let mockExit: MockInstance<typeof process.exit>;
let mockError: MockInstance<typeof console.error>;
beforeEach(() => {
mockExit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
mockError = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
mockExit.mockRestore();
mockError.mockRestore();
});
it('dispatches `claudex` to the claudex handler (yolo=false), not the runtime handler', () => {
const handler = vi.fn();
const claudex = vi.fn();
const program = buildProgramWithClaudex(handler, claudex);
program.parse(['node', 'mosaic', 'claudex']);
expect(claudex).toHaveBeenCalledTimes(1);
expect(claudex).toHaveBeenCalledWith([], false);
expect(handler).not.toHaveBeenCalled();
});
it('forwards excess args after `claudex`', () => {
const handler = vi.fn();
const claudex = vi.fn();
const program = buildProgramWithClaudex(handler, claudex);
program.parse(['node', 'mosaic', 'claudex', '--print', 'hi']);
expect(claudex).toHaveBeenCalledWith(['--print', 'hi'], false);
});
it('dispatches `yolo claudex` with yolo=true and slices off the runtime name (#454)', () => {
const handler = vi.fn();
const claudex = vi.fn();
const program = buildProgramWithClaudex(handler, claudex);
program.parse(['node', 'mosaic', 'yolo', 'claudex']);
expect(claudex).toHaveBeenCalledTimes(1);
// extraArgs must be empty — the positional 'claudex' must not leak through.
expect(claudex).toHaveBeenCalledWith([], true);
expect(handler).not.toHaveBeenCalled();
expect(mockExit).not.toHaveBeenCalled();
});
it('forwards true excess args after `yolo claudex`', () => {
const handler = vi.fn();
const claudex = vi.fn();
const program = buildProgramWithClaudex(handler, claudex);
program.parse(['node', 'mosaic', 'yolo', 'claudex', '--model', 'gpt-5.6-sol']);
expect(claudex).toHaveBeenCalledWith(['--model', 'gpt-5.6-sol'], true);
expect(mockExit).not.toHaveBeenCalled();
});
});

View File

@@ -27,7 +27,6 @@ import {
import { readRegularFileSecure } from '../fleet/secure-file.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
import { canonicalizeRoleClass } from './fleet-personas.js';
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
@@ -807,12 +806,12 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
}
/** exec into the runtime, replacing the current process. */
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
function execRuntime(cmd: string, args: string[]): void {
try {
// Use execFileSync with inherited stdio to replace the process
const result = spawnSync(cmd, args, {
stdio: 'inherit',
env,
env: process.env,
});
process.exit(result.status ?? 0);
} catch (err) {
@@ -821,29 +820,6 @@ function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = proce
}
}
/**
* Production glue for `mosaic [yolo] claudex` (EXPERIMENTAL — GPT models inside
* the Claude Code harness via claude-code-proxy). Assembles the real harness
* adapter and delegates the security-critical composition + fail-closed
* orchestration to `launchClaudex` in `claudex.ts`. Kept thin so the tested
* logic lives in the DI module, not here.
*/
function launchClaudexProduction(args: string[], yolo: boolean): void {
writeSessionLock('claude');
const adapter: ClaudexHarnessAdapter = {
harnessPreflight: () => {
checkMosaicHome();
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
checkSoul();
checkRuntime('claude');
checkSequentialThinking('claude');
},
composePrompt: () => buildRuntimePrompt('claude'),
exec: (cmd, cmdArgs, env) => execRuntime(cmd, cmdArgs, env),
};
void launchClaudex(args, yolo, adapter);
}
// ─── Framework script/tool delegation ───────────────────────────────────────
function delegateToScript(scriptPath: string, args: string[], env?: Record<string, string>): never {
@@ -1058,25 +1034,12 @@ export type RuntimeLaunchHandler = (
yolo: boolean,
) => void;
/**
* Handler invoked for `claudex` / `yolo claudex`. Kept separate from
* `RuntimeLaunchHandler` because claudex is an EXPERIMENTAL harness overlay
* (GPT-via-proxy), not one of the first-class runtimes. Exposed + injectable so
* the commander wiring can be exercised without composing a real launch.
*/
export type ClaudexLaunchHandler = (extraArgs: string[], yolo: boolean) => void;
/**
* Wire `<runtime>` and `yolo <runtime>` subcommands onto `program` using a
* pluggable launch handler. Separated from `registerLaunchCommands` so tests
* can inject a spy and verify argument forwarding.
*/
export function registerRuntimeLaunchers(
program: Command,
handler: RuntimeLaunchHandler,
claudexHandler: ClaudexLaunchHandler = (extraArgs, yolo) =>
launchClaudexProduction(extraArgs, yolo),
): void {
export function registerRuntimeLaunchers(program: Command, handler: RuntimeLaunchHandler): void {
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
program
.command(runtime)
@@ -1088,37 +1051,16 @@ export function registerRuntimeLaunchers(
});
}
// claudex — EXPERIMENTAL: GPT models inside the Claude Code harness via
// claude-code-proxy (ChatGPT-subscription OAuth). Isolated CLAUDE_CONFIG_DIR
// + zero-token-leak env injection live in claudex.ts.
program
.command('claudex')
.description('EXPERIMENTAL: launch Claude Code harness against GPT via claude-code-proxy')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
claudexHandler(cmd.args, false);
});
program
.command('yolo <runtime>')
.description(
'Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi|claudex)',
)
.description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((runtime: string, _opts: unknown, cmd: Command) => {
// claudex is an EXPERIMENTAL overlay, not a RuntimeName — dispatch it
// before the runtime allowlist check. Slice off the positional runtime
// name for the same reason as below (#454).
if (runtime === 'claudex') {
claudexHandler(cmd.args.slice(1), true);
return;
}
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
if (!valid.includes(runtime as RuntimeName)) {
console.error(
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}|claudex`,
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}`,
);
process.exit(1);
}