Compare commits
4 Commits
fix/807-gl
...
feat/790-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f61d16f16 | ||
|
|
6bef94ea0d | ||
|
|
7f987f52fc | ||
|
|
0e41a5c264 |
@@ -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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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