diff --git a/packages/mosaic/README.md b/packages/mosaic/README.md index a616479..6a1b9c9 100644 --- a/packages/mosaic/README.md +++ b/packages/mosaic/README.md @@ -47,6 +47,61 @@ 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`. diff --git a/packages/mosaic/src/commands/claudex.spec.ts b/packages/mosaic/src/commands/claudex.spec.ts new file mode 100644 index 0000000..fab7cdc --- /dev/null +++ b/packages/mosaic/src/commands/claudex.spec.ts @@ -0,0 +1,732 @@ +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 { + 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 { + 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(); + } + }); +}); diff --git a/packages/mosaic/src/commands/claudex.ts b/packages/mosaic/src/commands/claudex.ts new file mode 100644 index 0000000..f6f47d6 --- /dev/null +++ b/packages/mosaic/src/commands/claudex.ts @@ -0,0 +1,464 @@ +/** + * 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; + ensureProxy?: () => Promise; + 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 { + 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; + 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 { + 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); + } +} diff --git a/packages/mosaic/src/commands/launch.spec.ts b/packages/mosaic/src/commands/launch.spec.ts index c6d47fb..d468c9b 100644 --- a/packages/mosaic/src/commands/launch.spec.ts +++ b/packages/mosaic/src/commands/launch.spec.ts @@ -9,6 +9,7 @@ import { piForceSkillNames, registerRuntimeLaunchers, type RuntimeLaunchHandler, + type ClaudexLaunchHandler, } from './launch.js'; /** @@ -31,6 +32,16 @@ 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']; @@ -280,3 +291,61 @@ describe('registerRuntimeLaunchers — yolo ', () => { expect(mockExit).toHaveBeenCalledWith(1); }); }); + +describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => { + let mockExit: MockInstance; + let mockError: MockInstance; + + 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(); + }); +}); diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index c56cc27..b51bcb1 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -27,6 +27,7 @@ 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; @@ -806,12 +807,12 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev } /** exec into the runtime, replacing the current process. */ -function execRuntime(cmd: string, args: string[]): void { +function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void { try { // Use execFileSync with inherited stdio to replace the process const result = spawnSync(cmd, args, { stdio: 'inherit', - env: process.env, + env, }); process.exit(result.status ?? 0); } catch (err) { @@ -820,6 +821,29 @@ function execRuntime(cmd: string, args: string[]): void { } } +/** + * 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): never { @@ -1034,12 +1058,25 @@ 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 `` and `yolo ` 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): void { +export function registerRuntimeLaunchers( + program: Command, + handler: RuntimeLaunchHandler, + claudexHandler: ClaudexLaunchHandler = (extraArgs, yolo) => + launchClaudexProduction(extraArgs, yolo), +): void { for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) { program .command(runtime) @@ -1051,16 +1088,37 @@ export function registerRuntimeLaunchers(program: Command, handler: RuntimeLaunc }); } + // 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 ') - .description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)') + .description( + 'Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi|claudex)', + ) .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('|')}`, + `[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}|claudex`, ); process.exit(1); }