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