Compare commits
4 Commits
fix/808-ag
...
feat/790-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f61d16f16 | ||
|
|
6bef94ea0d | ||
|
|
7f987f52fc | ||
|
|
0e41a5c264 |
@@ -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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,464 +0,0 @@
|
||||
/**
|
||||
* Claudex launch composition (P2–P4 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user