Files
stack/packages/mosaic/src/commands/claudex.ts
jason.woltje 3be443c96d
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
feat(mosaic): claudex isolated config + env-inject + yolo wiring (P2–P4 of #790) (#806)
2026-07-16 22:10:33 +00:00

465 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Claudex launch composition (P2P4 of `mosaic yolo claudex`).
*
* Builds the isolated launch environment for running GPT models inside the
* Claude Code harness via `raine/claude-code-proxy` (ChatGPT-subscription OAuth).
* PR-1 (`claudex-proxy.ts`) owns the proxy preflight/lifecycle; this module owns
* the *composition* the launcher hands to Claude Code:
*
* P2 isolated CLAUDE_CONFIG_DIR (provably never the real ~/.claude) + env
* injection that leaks ZERO token material;
* P3 the model-tier map (primary → gpt-5.6-sol, small/fast → gpt-5.6-luna,
* operator env values win);
* P4 the EXPERIMENTAL classification banner + composed-contract note.
*
* Two hard security invariants (secrev-enforced):
* REQ 1 — Provable isolation. {@link assertIsolatedConfigDir} makes the
* CLAUDE_CONFIG_DIR seam incapable of resolving to `~/.claude` (or any
* descendant of it); it canonicalizes both sides, rejects descendants,
* and — after ensuring the dir — re-checks and rejects a symlinked
* target (TOCTOU). Fails CLOSED on any uncertainty.
* REQ 2 — Zero token leakage. This module never reads the proxy's
* `auth.json`; {@link buildClaudexEnv} strips the ENTIRE
* credential-bearing env family and hands Claude Code only
* `ANTHROPIC_AUTH_TOKEN=unused`. The proxy holds the real credential.
*
* Every side-effecting boundary is dependency-injected so the launch path is
* unit-testable without spawning Claude Code or touching a real config dir.
*/
import { lstatSync, mkdirSync, realpathSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import {
CLAUDEX_PROXY_URL,
ensureProxyRunning,
runDeviceReauth,
runProxyPreflight,
type EnsureProxyResult,
type PreflightReport,
} from './claudex-proxy.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
// ─── Isolated CLAUDE_CONFIG_DIR (HARD SECURITY REQ 1) ─────────────────────────
/** Dedicated override env for the isolated config dir. The ambient
* `CLAUDE_CONFIG_DIR` is deliberately NOT honored — it may already point at the
* real `~/.claude` of the launching session. */
export const CLAUDEX_CONFIG_DIR_ENV = 'MOSAIC_CLAUDEX_CONFIG_DIR';
/** The default isolated config dir — structurally under the mosaic home, so it
* can never equal `~/.claude`. */
export function defaultClaudexConfigDir(mosaicHome: string = MOSAIC_HOME): string {
return join(mosaicHome, 'claudex', 'home');
}
export interface ConfigDirDeps {
/** The real Claude state dir to protect (default `~/.claude`). */
realClaudeDir?: string;
/** Resolve a path to canonical form, resolving symlinks on the longest
* existing ancestor (so a not-yet-created dir still canonicalizes). */
canonicalize?: (p: string) => string;
/** Ensure the isolated dir exists (mkdir -p, owner-only 0700). */
mkdir?: (p: string) => void;
/** Whether a path is itself a symlink (lstat). */
isSymlink?: (p: string) => boolean;
}
/** Resolve a path to canonical form, resolving symlinks on the LONGEST EXISTING
* ancestor and re-appending the not-yet-existing tail. A symlinked ancestor that
* points into `~/.claude` is therefore caught even before the leaf exists. */
function defaultCanonicalizeIntended(p: string): string {
const abs = resolve(p);
let existing = abs;
const tail: string[] = [];
// Walk up until we hit an existing ancestor (or the filesystem root).
for (;;) {
try {
const real = realpathSync(existing);
return tail.length > 0 ? join(real, ...tail) : real;
} catch (err) {
// ONLY a genuine "does not exist yet" (ENOENT) justifies walking up to an
// existing ancestor. Any other errno (ELOOP, EACCES, ENOTDIR, …) means we
// cannot establish the canonical form — fail CLOSED rather than fall back
// to a possibly-wrong literal path.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
const parent = dirname(existing);
if (parent === existing) return abs; // reached root without an existing prefix
tail.unshift(existing.slice(parent.length + 1));
existing = parent;
}
}
}
function defaultMkdir(p: string): void {
mkdirSync(p, { recursive: true, mode: 0o700 });
}
function defaultIsSymlink(p: string): boolean {
try {
return lstatSync(p).isSymbolicLink();
} catch (err) {
// A missing path is genuinely "not a symlink"; anything else (EACCES, ELOOP,
// ENOTDIR, …) is uncertainty the guard must not swallow — fail CLOSED.
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw err;
}
}
/** True when `child` is `parent` itself or a descendant of it (path-wise). */
function isWithin(child: string, parent: string): boolean {
if (child === parent) return true;
const rel = relative(parent, child);
return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel);
}
/**
* Guard: prove that `candidate` is a legitimate ISOLATED config dir and can
* never be, resolve to, or live under the real `~/.claude`. Canonicalizes BOTH
* sides (either may be a symlink), rejects `~/.claude` and any descendant, and
* fails CLOSED (throws) on an empty/relative candidate. Returns the canonical
* isolated path on success.
*/
export function assertIsolatedConfigDir(candidate: string, deps: ConfigDirDeps = {}): string {
const canonicalize = deps.canonicalize ?? defaultCanonicalizeIntended;
const realClaudeDir = deps.realClaudeDir ?? join(homedir(), '.claude');
if (typeof candidate !== 'string' || candidate.trim() === '') {
throw new Error('claudex: isolated CLAUDE_CONFIG_DIR must be a non-empty path (fail closed).');
}
if (!isAbsolute(candidate)) {
throw new Error(
`claudex: isolated CLAUDE_CONFIG_DIR must be an absolute path: ${JSON.stringify(candidate)}`,
);
}
const canonCandidate = canonicalize(candidate);
const canonReal = canonicalize(realClaudeDir);
// Compare canonical forms AND raw resolved forms — belt and suspenders so a
// canonicalizer that no-ops on a nonexistent real dir still catches the literal.
if (isWithin(canonCandidate, canonReal) || isWithin(resolve(candidate), resolve(realClaudeDir))) {
throw new Error(
`claudex: refusing to use the real Claude config dir (or a descendant of it) as the ` +
`isolated CLAUDE_CONFIG_DIR. Resolved to ${JSON.stringify(canonCandidate)}.`,
);
}
return canonCandidate;
}
/**
* Resolve the isolated CLAUDE_CONFIG_DIR: pick the dedicated override or the
* namespaced default, run the pre-create guard, ensure the dir (0700), then
* RE-CHECK after creation — reject a symlinked target and re-run the guard on
* the now-existing (fully canonicalizable) path. This closes the pre-created
* symlink race (TOCTOU). Every failure throws (fail closed).
*/
export function resolveClaudexConfigDir(
env: NodeJS.ProcessEnv = process.env,
deps: ConfigDirDeps & { mosaicHome?: string } = {},
): string {
const mosaicHome = deps.mosaicHome ?? MOSAIC_HOME;
const mkdir = deps.mkdir ?? defaultMkdir;
const isSymlink = deps.isSymlink ?? defaultIsSymlink;
const override = env[CLAUDEX_CONFIG_DIR_ENV]?.trim();
const candidate =
override && override.length > 0 ? override : defaultClaudexConfigDir(mosaicHome);
// Pre-create guard (before touching the filesystem).
assertIsolatedConfigDir(candidate, deps);
// Ensure the dir, then re-verify against the post-create reality.
mkdir(candidate);
if (isSymlink(candidate)) {
throw new Error(
'claudex: refusing to use the isolated CLAUDE_CONFIG_DIR — the target is a symlink ' +
'(possible pre-created race). Fail closed.',
);
}
// Re-run the guard now that the leaf exists so canonicalization reflects any
// symlinked ancestor introduced between the pre-check and mkdir.
return assertIsolatedConfigDir(candidate, deps);
}
// ─── Model-tier map (P3) ──────────────────────────────────────────────────────
export const CLAUDEX_DEFAULT_PRIMARY_MODEL = 'gpt-5.6-sol';
export const CLAUDEX_DEFAULT_SMALL_FAST_MODEL = 'gpt-5.6-luna';
export interface ClaudexModels {
/** Primary tier (opus/sonnet) → ANTHROPIC_MODEL. */
primary: string;
/** Small/fast tier (haiku) → ANTHROPIC_SMALL_FAST_MODEL. */
smallFast: string;
}
/** Resolve the model-tier map. Operator-provided env values WIN over defaults;
* blank values fall back to the defaults. */
export function resolveClaudexModels(env: NodeJS.ProcessEnv = process.env): ClaudexModels {
const primary = env['ANTHROPIC_MODEL']?.trim() || CLAUDEX_DEFAULT_PRIMARY_MODEL;
const smallFast = env['ANTHROPIC_SMALL_FAST_MODEL']?.trim() || CLAUDEX_DEFAULT_SMALL_FAST_MODEL;
return { primary, smallFast };
}
// ─── Env injection (HARD SECURITY REQ 2 — zero token leakage) ─────────────────
/**
* Names of env vars considered credential-bearing. The whole family is stripped
* from the composed env so no real Anthropic key, OAuth token, or third-party /
* cloud credential can reach the local proxy or be used by Claude Code to bypass
* it. We then re-add ONLY the safe claudex vars (`ANTHROPIC_MODEL`,
* `_SMALL_FAST_MODEL`, `_BASE_URL`, `_AUTH_TOKEN=unused`). A name-pattern sweep
* can't miss a specific var a short denylist forgot, while still preserving the
* arbitrary non-credential env the harness/MCP/hooks require (PATH, HOME, XDG,
* terminal, proxies, …), which a strict allowlist would fragilely drop.
*
* The cloud-provider families (`AWS_*`, `GOOGLE_APPLICATION_CREDENTIALS`,
* `GOOGLE_CLOUD_*`, `GCP_*`) are included because Claude Code can route to the
* real Anthropic API via AWS Bedrock / GCP Vertex using the ambient cloud
* credential chain — a Claude-capable credential that must never survive into a
* claudex launch. `_KEY$` / `_SECRET` (not just the `_API_KEY$` tail) close the
* mid-string gap (`STRIPE_SECRET_KEY`, `SSH_PRIVATE_KEY`, `AWS_SECRET_ACCESS_KEY`).
*/
export const CLAUDEX_CREDENTIAL_ENV_RE =
/^ANTHROPIC_|^CLAUDE_CODE_OAUTH|^AWS_|^GOOGLE_APPLICATION_CREDENTIALS$|^GOOGLE_CLOUD_|^GCP_|_API_?KEY$|_KEY$|_TOKEN$|_SECRET/i;
/**
* Provider ROUTING switches whose mere PRESENCE (independent of any credential)
* makes Claude Code bypass `ANTHROPIC_BASE_URL` (the loopback proxy) and talk to
* the real Anthropic API via Bedrock/Vertex. A name-pattern is the wrong model
* for a boolean switch, so these are force-deleted by exact name — REGARDLESS of
* value — after the credential sweep. (REQ 2: isolation must hold for any
* launching env, including a Bedrock/Vertex-configured enterprise host.)
*/
export const CLAUDEX_FORCED_UNSET_ENV = [
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
] as const;
export interface BuildClaudexEnvOptions {
configDir: string;
models: ClaudexModels;
/** Override the proxy base URL (defaults to the PR-1 loopback constant). */
baseUrl?: string;
}
/**
* Compose the launch env for Claude Code. Returns a FRESH object (never mutates
* the base env). Strips the entire credential-bearing family AND force-deletes
* the Bedrock/Vertex routing switches (REQ 2), then sets the isolated config dir
* and the proxy routing. Claude Code sees only `ANTHROPIC_AUTH_TOKEN=unused`
* pointed at the loopback proxy; the proxy holds the real OAuth credential, which
* this module never reads.
*/
export function buildClaudexEnv(
baseEnv: NodeJS.ProcessEnv,
opts: BuildClaudexEnvOptions,
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [name, value] of Object.entries(baseEnv)) {
if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) continue; // drop the whole credential family
env[name] = value;
}
// Force-delete routing switches by exact name — their presence (not their
// value) is what would route Claude Code off the proxy to the real API.
for (const name of CLAUDEX_FORCED_UNSET_ENV) delete env[name];
env['CLAUDE_CONFIG_DIR'] = opts.configDir;
env['ANTHROPIC_BASE_URL'] = opts.baseUrl ?? CLAUDEX_PROXY_URL;
env['ANTHROPIC_AUTH_TOKEN'] = 'unused';
env['ANTHROPIC_MODEL'] = opts.models.primary;
env['ANTHROPIC_SMALL_FAST_MODEL'] = opts.models.smallFast;
return env;
}
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
/** Console banner shown at launch. Contains no token material by construction. */
export function buildClaudexBanner(models: ClaudexModels): string {
return [
'',
' ┌─────────────────────────────────────────────────────────────────────┐',
' │ ⚠ EXPERIMENTAL — mosaic claudex │',
' └─────────────────────────────────────────────────────────────────────┘',
` Running GPT models inside the Claude Code harness via claude-code-proxy`,
` (ChatGPT-subscription OAuth). This is NOT Anthropic Claude.`,
` primary : ${models.primary}`,
` small/fast : ${models.smallFast}`,
` Model behavior, tool use, and output quality may differ from Claude.`,
'',
].join('\n');
}
/** Markdown note appended to the composed runtime contract so the model itself
* knows it is running the EXPERIMENTAL GPT-via-proxy configuration. */
export function buildClaudexContractNote(models: ClaudexModels): string {
return [
'# EXPERIMENTAL Runtime — claudex (GPT via claude-code-proxy)',
'',
'You are running in Mosaic **claudex** mode: the Claude Code harness is wired to',
'GPT models through a local `claude-code-proxy` (ChatGPT-subscription OAuth). This',
"runtime is NOT Anthropic's Claude API and is not Claude.",
'',
`- Primary model: \`${models.primary}\``,
`- Small/fast model: \`${models.smallFast}\``,
'',
'Some Claude-specific harness assumptions may not hold under GPT models — verify',
'tool output carefully. This classification is EXPERIMENTAL and is not intended for',
'production delivery without explicit operator sign-off.',
].join('\n');
}
// ─── Proxy gate ───────────────────────────────────────────────────────────────
export interface ProxyGateResult {
ok: boolean;
report: PreflightReport;
/** Non-sensitive problems suitable for surfacing to the operator. */
problems: string[];
}
export interface ProxyGateDeps {
preflight?: () => Promise<PreflightReport>;
ensureProxy?: () => Promise<EnsureProxyResult>;
reauth?: () => number;
log?: (message: string) => void;
}
/**
* Run the proxy readiness gate: preflight → (device reauth if OAuth needs it) →
* (start the proxy if nothing trusted is live) → re-preflight. Returns `ok` only
* when the final preflight passes (binary present, OAuth valid, a TRUSTED-live
* listener — identity verified by PR-1's `verifyListenerIdentity`). All surfaced
* problems are non-sensitive (port + verdict only; never a token).
*/
export async function runClaudexProxyGate(deps: ProxyGateDeps = {}): Promise<ProxyGateResult> {
const preflight = deps.preflight ?? (() => runProxyPreflight());
const ensureProxy = deps.ensureProxy ?? (() => ensureProxyRunning());
const reauth = deps.reauth ?? (() => runDeviceReauth());
const log = deps.log ?? (() => {});
let report = await preflight();
// A missing binary is unrecoverable here — don't attempt reauth or a start.
if (!report.binaryPresent) {
return { ok: false, report, problems: report.problems };
}
if (report.needsReauth) {
log('claudex: claude-code-proxy OAuth needs re-authentication — starting device flow…');
const code = reauth();
if (code !== 0) {
return {
ok: false,
report,
problems: [...report.problems, 'claudex: device re-authentication did not complete.'],
};
}
report = await preflight();
}
if (!report.live) {
log('claudex: no trusted claude-code-proxy responding — starting it…');
const started = await ensureProxy();
if (!started.live) {
return {
ok: false,
report,
problems: [
...report.problems,
`claudex: could not bring up a trusted claude-code-proxy (${started.method}).`,
],
};
}
report = await preflight();
}
return { ok: report.ok, report, problems: report.problems };
}
// ─── Launch orchestration ─────────────────────────────────────────────────────
/**
* The launch.ts-provided seam. Keeps `claudex.ts` free of a circular import back
* into `launch.ts` while letting the orchestration reuse the harness preflight,
* the composed runtime contract, and the process-replacing exec.
*/
export interface ClaudexHarnessAdapter {
/** Runs the Claude-harness preflight (mosaic home, SOUL, `claude` on PATH,
* sequential-thinking). May terminate the process on a hard failure. */
harnessPreflight: () => void;
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */
composePrompt: () => string;
/** Replace the current process with `claude` using the composed env. */
exec: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
}
export interface LaunchClaudexDeps {
baseEnv?: NodeJS.ProcessEnv;
proxyGate?: () => Promise<ProxyGateResult>;
resolveConfigDir?: () => string;
models?: () => ClaudexModels;
buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv;
log?: (message: string) => void;
errorLog?: (message: string) => void;
fail?: (code: number) => never;
}
/**
* Orchestrate a `mosaic [yolo] claudex` launch. Runs the harness preflight, the
* proxy gate, composes the isolated env (REQ 1 + REQ 2), appends the EXPERIMENTAL
* note, and exec's Claude Code. FAIL CLOSED: on any gate failure or guard throw
* it reports non-sensitive detail and exits WITHOUT reaching exec.
*/
export async function launchClaudex(
args: string[],
yolo: boolean,
adapter: ClaudexHarnessAdapter,
deps: LaunchClaudexDeps = {},
): Promise<void> {
const log = deps.log ?? ((m: string) => console.log(m));
const errorLog = deps.errorLog ?? ((m: string) => console.error(m));
const fail = deps.fail ?? ((code: number) => process.exit(code));
const baseEnv = deps.baseEnv ?? process.env;
const proxyGate = deps.proxyGate ?? (() => runClaudexProxyGate({ log }));
const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv));
const models = deps.models ?? (() => resolveClaudexModels(baseEnv));
const buildEnv = deps.buildEnv ?? buildClaudexEnv;
try {
// Harness readiness first (claude on PATH, mosaic home, sequential-thinking).
adapter.harnessPreflight();
// Proxy readiness (binary, OAuth, trusted-live listener).
const gate = await proxyGate();
if (!gate.ok) {
errorLog('[mosaic] claudex preflight failed:');
for (const problem of gate.problems) errorLog(` - ${problem}`);
return fail(1);
}
// Compose the isolated launch env (guard throws → caught below, fail closed).
const resolvedModels = models();
const configDir = resolveConfigDir();
const env = buildEnv(baseEnv, { configDir, models: resolvedModels });
const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`;
log(buildClaudexBanner(resolvedModels));
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
cliArgs.push('--append-system-prompt', prompt, ...args);
adapter.exec('claude', cliArgs, env);
} catch (err) {
errorLog(
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,
);
return fail(1);
}
}