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
10 changed files with 8 additions and 1534 deletions

View File

@@ -1,64 +0,0 @@
# Issue #807 — GLPI list wrappers accept HTTP 206
- **Branch:** `fix/807-glpi-206`
- **Task:** Gitea issue #807
- **Role:** Author-only worker reporting to `mosaic-100`; no self-review or merge
- **Started:** 2026-07-16
## Objective
Fix the shipped GLPI ticket, computer, and user list wrappers so ranged responses with HTTP 206 Partial Content render successfully while genuine HTTP failures remain non-zero errors.
## Scope
- Modify only the three affected list wrappers and a focused shell regression test.
- Do not touch `session-init.sh`, `ticket-create.sh`, or `docs/TASKS.md`.
- Add task-local delivery evidence here as required by the mission protocol.
## Plan
1. Add a deterministic shell harness that copies each wrapper beside stubbed `session-init.sh`, credentials, and `curl` boundaries.
2. Prove RED against the current 200-only gates: 206 must fail before the implementation change.
3. Update all three status gates to accept exactly 200 or 206.
4. Prove GREEN for 206 rendering and genuine 401/500 failures, then run repository quality gates.
5. Commit with co-author attribution, run the push queue guard, push, and open a PR for independent review and merge by the team lead.
## Budget
- No explicit token cap supplied.
- Soft estimate: 8K tokens; narrow single-worker execution with no exploratory scope.
## Progress
- [x] Mission, task, PRD, QA, documentation, and code-review guidance loaded.
- [x] RED regression evidence captured: `test-list-http-status.sh` exited 1; all three wrappers rejected 206 while retaining 401 failures.
- [x] Implementation complete.
- [x] Relevant tests and repository gates green.
- [ ] Commit pushed and PR opened.
## Tests and evidence
- RED (before source fix): `packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → exit 1; ticket/computer/user 206 assertions failed, all 401 assertions passed.
- GREEN: `bash -n packages/mosaic/framework/tools/glpi/{ticket-list.sh,computer-list.sh,user-list.sh,test-list-http-status.sh}` → pass.
- GREEN: `shellcheck packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → pass.
- GREEN: `packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → 6 assertions pass (206 renders and 401 errors for all three wrappers).
- GREEN: `pnpm typecheck` → 42/42 tasks pass.
- GREEN: `pnpm lint` → 23/23 tasks pass.
- GREEN: `pnpm format:check` → all matched files pass.
- Setup note: initial gate attempts could not start because the fresh worktree lacked dependencies; `pnpm install --frozen-lockfile --store-dir /home/hermes/.local/share/pnpm/store` restored the locked workspace dependencies without lockfile changes.
## Acceptance criteria mapping
| Criterion | Evidence |
| --- | --- |
| HTTP 206 succeeds and renders each ranged list | Focused test's three 206 render assertions pass |
| Genuine HTTP failures remain non-zero with existing diagnostics | Focused test's three HTTP 401 assertions pass |
| Only affected list wrappers change | Diff contains the three status predicates plus focused test/evidence; session and create wrappers untouched |
## Documentation decision
No operator/API documentation change is needed: this restores documented list behavior for a healthy GLPI response without changing command syntax, output, configuration, or public contracts. This task scratchpad records delivery evidence.
## Risks / blockers
- Existing dirty `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are runtime-owned and will not be edited or committed.

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

@@ -37,7 +37,7 @@ response=$(curl -sk -w "\n%{http_code}" \
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; then
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to list computers (HTTP $http_code)" >&2
exit 1
fi

View File

@@ -1,84 +0,0 @@
#!/usr/bin/env bash
# Regression harness for #807: ranged GLPI list requests may return HTTP 206.
#
# Each shipped list wrapper must render a healthy 206 response and must retain
# its non-zero error behavior for a genuine HTTP failure.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/glpi-list-http-status}"
TOOL_DIR="$WORK_DIR/tools/glpi"
BIN_DIR="$WORK_DIR/bin"
rm -rf "$WORK_DIR"
mkdir -p "$TOOL_DIR" "$BIN_DIR" "$WORK_DIR/tools/_lib"
trap 'rm -rf "$WORK_DIR"' EXIT
for wrapper in ticket-list.sh computer-list.sh user-list.sh; do
cp "$SCRIPT_DIR/$wrapper" "$TOOL_DIR/$wrapper"
done
cat > "$WORK_DIR/tools/_lib/credentials.sh" <<'SH'
load_credentials() {
export GLPI_URL="https://glpi.test/apirest.php"
export GLPI_APP_TOKEN="test-app-token"
}
SH
cat > "$TOOL_DIR/session-init.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' 'test-session-token'
SH
chmod +x "$TOOL_DIR/session-init.sh"
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '[{"id":42,"priority":3,"status":1,"name":"Regression fixture","date_mod":"2026-07-16 10:00:00","serial":"SER-42","states_id":1,"realname":"Fixture","firstname":"GLPI","is_active":1}]'
printf '%s\n' "${GLPI_TEST_HTTP_CODE:?GLPI_TEST_HTTP_CODE is required}"
SH
chmod +x "$BIN_DIR/curl"
export MOSAIC_HOME="$WORK_DIR"
export PATH="$BIN_DIR:$PATH"
wrappers=(ticket-list.sh computer-list.sh user-list.sh)
resources=(tickets computers users)
headings=(PRIORITY SERIAL USERNAME)
fail=0
for index in "${!wrappers[@]}"; do
wrapper="${wrappers[$index]}"
resource="${resources[$index]}"
heading="${headings[$index]}"
path="$TOOL_DIR/$wrapper"
if ! output=$(GLPI_TEST_HTTP_CODE=206 bash "$path" 2>&1); then
echo "FAIL: $wrapper rejected healthy HTTP 206" >&2
fail=1
elif [[ "$output" != *"$heading"* || "$output" != *"Regression fixture"* ]]; then
echo "FAIL: $wrapper did not render the HTTP 206 list response" >&2
fail=1
else
echo "PASS: $wrapper renders HTTP 206"
fi
rc=0
output=$(GLPI_TEST_HTTP_CODE=401 bash "$path" 2>&1) || rc=$?
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: $wrapper accepted HTTP 401" >&2
fail=1
elif [[ "$output" != *"Error: Failed to list $resource (HTTP 401)"* ]]; then
echo "FAIL: $wrapper changed the HTTP failure diagnostic" >&2
fail=1
else
echo "PASS: $wrapper rejects HTTP 401"
fi
done
if [[ "$fail" -eq 0 ]]; then
echo "ALL PASS: test-list-http-status.sh"
fi
exit "$fail"

View File

@@ -55,7 +55,7 @@ response=$(curl -sk -w "\n%{http_code}" \
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; then
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to list tickets (HTTP $http_code)" >&2
exit 1
fi

View File

@@ -37,7 +37,7 @@ response=$(curl -sk -w "\n%{http_code}" \
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; then
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to list users (HTTP $http_code)" >&2
exit 1
fi

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);
}