import { accessSync, constants, readFileSync, writeFileSync, unlinkSync, mkdirSync, statSync } from "node:fs"; import { delimiter, join } from "node:path"; import { getConfigDir } from "../config"; import { resolveAutoContext, type AutoContextMode } from "../claude/context-windows"; import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens, type AuthDetectDeps } from "../claude/auth-detect"; import { resolveClaudeAuthMode } from "../claude/auth-mode"; import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "../cli/launcher-context"; import type { OcxConfig } from "../types"; import { recordOwnedConfigPath } from "../lib/config-ownership"; /** * Does the opencodex dummy marker belong in the system environment? * * Keyed on the SAME resolver `ocx claude` uses, so an auto config with no Claude auth * also reaches plain `claude` launches — before this, auto-absent users got nothing * from auto-connect and the feature looked broken for exactly the people it helps * (devlog 260726_claude_auth_auto/035). * * NOTE this is a SNAPSHOT: the file only changes when this runs (proxy start, `ocx * ensure`, or a settings save). `ocx claude` re-resolves live on every launch. */ export type SystemEnvDeps = { /** Test seam; production uses the authenticated Node-launcher context. */ preBunAnthropicSlots?: readonly AnthropicParentEnvSlot[] | null; /** Test seam for auth sources; `env` and `ownTokens` stay bound below. */ authDetect?: Omit, "env" | "ownTokens">; }; /** * Bun may synthesize Anthropic variables from a project `.env` before this module runs. * Only values recorded by the plain-Node launcher are trusted as parent exports. Direct * Bun/service launches have no proof-bound slot list, so they fail closed and let the * file/keychain auth sources decide instead of allowing dotenv to select subscription mode. */ function systemEnvAnthropicEnv( env: NodeJS.ProcessEnv, preBunAnthropicSlots: readonly AnthropicParentEnvSlot[] | null | undefined, ): NodeJS.ProcessEnv { const trustedSlots = preBunAnthropicSlots === undefined ? trustedNodeLauncherContext()?.anthropicEnvSlots ?? [] : preBunAnthropicSlots ?? []; const exported = new Set(trustedSlots); const sanitized = { ...env }; for (const name of ANTHROPIC_PARENT_ENV_SLOTS) { if (sanitized[name] !== undefined && !exported.has(name)) delete sanitized[name]; } return sanitized; } export function systemEnvMarkerMode(config: OcxConfig, deps: SystemEnvDeps = {}): "proxy" | "subscription" { const env = systemEnvAnthropicEnv(process.env, deps.preBunAnthropicSlots); const ownTokens = ownAdmissionTokens(config); return resolveClaudeAuthMode(config, detectClaudeAuth({ ...defaultAuthDetectDeps(env, ownTokens), ...(deps.authDetect ?? {}), env: () => env, ownTokens, })).markerMode; } // --------------------------------------------------------------------------- // Shell-hook env file: written on inject, sourced by the shell hook in .zshrc. // This works for ALL new shells immediately, unlike launchctl setenv which only // reaches processes launched directly by launchd (not Terminal.app children). // --------------------------------------------------------------------------- export function getShellEnvFilePath(): string { return join(getConfigDir(), "claude-env.sh"); } function shellValue(value: string): string { return `'${value.replaceAll("'", `'\\''`)}'`; } export function writeShellEnvFile( port: number, config: OcxConfig, modelEnv: Record = {}, auto?: AutoContextMode, deps: SystemEnvDeps = {}, ): void { const lines = [ `# Generated by opencodex — do not edit manually`, `export ANTHROPIC_BASE_URL=${shellValue(`http://127.0.0.1:${port}`)}`, `export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=${shellValue("1")}`, ]; // New lever keys are CONDITIONAL exports (audit 139 R2#1): a value the user already // exported in their shell wins even though launchctl knows nothing about it. const conditional = (name: string, value: string) => `[ -z "\${${name}+x}" ] && export ${name}=${shellValue(value)}`; if (systemEnvMarkerMode(config, deps) === "proxy") { if (config.apiKeys?.length) { lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`); } else { lines.push(conditional("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER)); } } // Model slots (default + tiers + legacy small-fast) with [1m] applied (devlog 260712 B2). if (modelEnv.ANTHROPIC_MODEL) { lines.push(`export ANTHROPIC_MODEL=${shellValue(modelEnv.ANTHROPIC_MODEL)}`); } else if (config.claudeCode?.model) { lines.push(`export ANTHROPIC_MODEL=${shellValue(config.claudeCode.model)}`); } for (const [name, value] of Object.entries(modelEnv)) { if (name === "ANTHROPIC_MODEL") continue; lines.push(conditional(name, value)); } const maxCtx = config.claudeCode?.maxContextTokens; if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) { lines.push(conditional("CLAUDE_CODE_MAX_CONTEXT_TOKENS", String(Math.floor(maxCtx)))); lines.push(conditional("DISABLE_COMPACT", "1")); } // Auto-context (devlog 260712 020): same contract as `ocx claude` / launchctl. const autoShell = auto ?? resolveAutoContext(config.claudeCode); if (autoShell.enabled) lines.push(conditional("CLAUDE_CODE_AUTO_COMPACT_WINDOW", String(autoShell.compactWindow))); if (config.claudeCode?.alwaysEnableEffort === true) { lines.push(conditional("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1")); } const shellEnvPath = getShellEnvFilePath(); recordOwnedConfigPath(getConfigDir(), shellEnvPath); mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); writeFileSync(shellEnvPath, lines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 }); } export function removeShellEnvFile(): void { try { unlinkSync(getShellEnvFilePath()); } catch { /* already gone */ } } // --------------------------------------------------------------------------- // .zshrc hook auto-install: adds a one-liner that sources claude-env.sh. // Idempotent — skips if the hook line already exists. // --------------------------------------------------------------------------- const SHELL_HOOK_MARKER = "# opencodex claude-env hook"; const SHELL_HOOK_LINE = `${SHELL_HOOK_MARKER}\n[ -f ~/.opencodex/claude-env.sh ] && source ~/.opencodex/claude-env.sh`; export function installShellHook(): { installed: boolean; reason?: string } { if (process.platform !== "darwin") return { installed: false, reason: "not macOS" }; const home = process.env.HOME; if (!home) return { installed: false, reason: "no HOME" }; const zshrcPath = join(home, ".zshrc"); try { let content = ""; try { content = readFileSync(zshrcPath, "utf8"); } catch { /* file doesn't exist yet */ } if (content.includes(SHELL_HOOK_MARKER)) return { installed: false, reason: "already installed" }; const addition = `\n${SHELL_HOOK_LINE}\n`; writeFileSync(zshrcPath, content + addition, { encoding: "utf8", mode: 0o644 }); return { installed: true }; } catch (err) { return { installed: false, reason: `write failed: ${err instanceof Error ? err.message : String(err)}` }; } } export function uninstallShellHook(): { removed: boolean; reason?: string } { if (process.platform !== "darwin") return { removed: false, reason: "not macOS" }; const home = process.env.HOME; if (!home) return { removed: false, reason: "no HOME" }; const zshrcPath = join(home, ".zshrc"); try { const content = readFileSync(zshrcPath, "utf8"); if (!content.includes(SHELL_HOOK_MARKER)) return { removed: false, reason: "not installed" }; // Match CR?LF, not LF alone. A .zshrc with CRLF line endings — ordinary on a home // directory an editor or another OS has touched — did not match, so the file was // rewritten unchanged and the caller was told the hook was removed. Reporting success // while the hook still sources on every new shell is the worse of the two failures. const cleaned = content.replace(/\r?\n?# opencodex claude-env hook\r?\n\[.*claude-env\.sh.*(?:\r?\n)?/g, "\n"); // Verify instead of assuming: if the marker survives, the block is shaped in a way this // pattern does not own, and the honest answer is failure rather than a silent no-op. if (cleaned.includes(SHELL_HOOK_MARKER)) { return { removed: false, reason: "hook block present but not in the expected shape; remove it manually" }; } writeFileSync(zshrcPath, cleaned, { encoding: "utf8", mode: 0o644 }); return { removed: true }; } catch (error) { if (error && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT") { return { removed: false, reason: "not installed" }; } return { removed: false, reason: "read/write failed" }; } } /** Whether a real `claude` executable is discoverable from this process's PATH. */ export function claudeCodeCliInstalled(pathValue = process.env.PATH): boolean { if (!pathValue) return false; for (const directory of pathValue.split(delimiter)) { // An empty PATH segment means the current directory. Do not let the proxy treat a // workspace-local file as a durable user installation. if (!directory) continue; const candidate = join(directory, "claude"); try { if (!statSync(candidate).isFile()) continue; accessSync(candidate, constants.X_OK); return true; } catch { // Keep scanning PATH after missing, non-file, and non-executable entries. } } return false; } /** * Keep the shell hook aligned with the integration that can actually consume it. * Claude Desktop uses its own profile and does not source `.zshrc`; this hook exists * only for plain Claude Code CLI launches. * * Reconciliation is PATH-sensitive by construction: "Claude Code is installed" is answered * from the PATH of whichever process calls this. A launchd/service context with a stripped * PATH can therefore fail to see a `claude` the user's interactive shell finds, and this will * remove the hook. That is the intended failure direction — removing an OpenCodex-owned block * is reversible on the next foreground `ocx start`, whereas leaving a hook pointing at an * uninstalled CLI is the stale state this reconciliation exists to clear. Only the block * carrying our own marker is ever touched; user lines are preserved. */ export function reconcileShellHook(systemEnvInjected: boolean): { changed: boolean; state: "installed" | "absent" | "failed"; reason?: string; } { if (process.platform !== "darwin") return { changed: false, state: "absent", reason: "not macOS" }; if (systemEnvInjected && claudeCodeCliInstalled()) { const result = installShellHook(); if (result.installed) return { changed: true, state: "installed" }; if (result.reason === "already installed") { return { changed: false, state: "installed", reason: result.reason }; } return { changed: false, state: "failed", reason: result.reason ?? "install failed" }; } const result = uninstallShellHook(); if (!result.removed && result.reason !== "not installed") { return { changed: false, state: "failed", reason: result.reason ?? "remove failed" }; } return { changed: result.removed, state: "absent", reason: systemEnvInjected ? "Claude Code not installed" : "system environment inactive", }; }