Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions src/server/system-env-shell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
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<Partial<AuthDetectDeps>, "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<AnthropicParentEnvSlot>(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<string, string> = {},
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",
};
}
Loading
Loading