Skip to content
Open
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
177 changes: 177 additions & 0 deletions src/claude/persistent-session-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { claudeConfigDir } from "./gateway-cache";
import { recordOwnedConfigPath } from "../lib/config-ownership";

export interface PersistentClaudeEnv {
[key: string]: string | undefined;
}

interface PreviousValue {
present: boolean;
value?: unknown;
}

interface PersistentEnvState {
version: 1;
settingsPath: string;
previous: Record<string, PreviousValue>;
}

export interface PersistentEnvSyncResult {
synced: boolean;
settingsPath: string;
statePath: string;
warning?: string;
}

const MANAGED_KEYS = [
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
"CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST",
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_FABLE_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL",
"CLAUDE_CODE_MAX_CONTEXT_TOKENS",
"DISABLE_COMPACT",
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
"CLAUDE_CODE_ALWAYS_ENABLE_EFFORT",
] as const;

type ManagedKey = typeof MANAGED_KEYS[number];

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function atomicWrite(path: string, content: string): void {
const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
writeFileSync(temp, content, { encoding: "utf8", mode: 0o600 });
try {
renameSync(temp, path);
} catch (error) {
try { unlinkSync(temp); } catch { /* best effort */ }
throw error;
}
}

function readSettings(path: string): Record<string, unknown> {
if (!existsSync(path)) return {};
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
if (!isRecord(parsed)) throw new Error("Claude settings root must be a JSON object");
return parsed;
}

function readState(path: string, settingsPath: string): PersistentEnvState {
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
if (!isRecord(parsed)
|| parsed.version !== 1
|| parsed.settingsPath !== settingsPath
|| !isRecord(parsed.previous)) {
throw new Error("invalid state");
}
const previous: Record<string, PreviousValue> = {};
for (const [key, raw] of Object.entries(parsed.previous)) {
if (!MANAGED_KEYS.includes(key as ManagedKey) || !isRecord(raw) || typeof raw.present !== "boolean") continue;
previous[key] = raw.present ? { present: true, value: raw.value } : { present: false };
}
return { version: 1, settingsPath, previous };
} catch {
return { version: 1, settingsPath, previous: {} };
}
}

/**
* Environment that must survive Claude Code's terminal boundary.
*
* Agent View sessions are separate Claude Code processes parented to the per-user
* supervisor. They do not reliably inherit the interactive terminal environment, but
* Claude officially applies settings.json `env` to every session. Host-managed mode is
* forced OFF here because it deliberately strips provider variables loaded from settings;
* OCX owns and refreshes these exact keys instead.
*/
export function persistentClaudeEnv(source: PersistentClaudeEnv): Record<string, string> {
const result: Record<string, string> = {
CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "0",
};
for (const key of MANAGED_KEYS) {
if (key === "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST") continue;
const value = source[key];
if (typeof value === "string" && value.length > 0) result[key] = value;
}
return result;
}

/**
* Merge OCX-owned provider env into the user's Claude settings without replacing any
* unrelated setting. Original values are journaled under OPENCODEX_HOME so switching
* auth modes can restore keys that OCX no longer needs and stop/eject can restore all
* managed keys later. Both files are written atomically with mode 0600.
*/
export function syncClaudePersistentSessionEnv(
source: PersistentClaudeEnv,
stateDir: string,
configDir = claudeConfigDir(),
): PersistentEnvSyncResult {
const settingsPath = join(configDir, "settings.json");
const statePath = join(stateDir, "claude-persistent-env.json");
try {
mkdirSync(configDir, { recursive: true, mode: 0o700 });
mkdirSync(stateDir, { recursive: true, mode: 0o700 });

const settings = readSettings(settingsPath);
const existingEnv = settings.env;
if (existingEnv !== undefined && !isRecord(existingEnv)) {
throw new Error("Claude settings `env` must be a JSON object");
}
const env: Record<string, unknown> = { ...(existingEnv as Record<string, unknown> | undefined) };
const desired = persistentClaudeEnv(source);
const state = readState(statePath, settingsPath);

for (const key of MANAGED_KEYS) {
const desiredValue = desired[key];
if (desiredValue !== undefined) {
if (!Object.prototype.hasOwnProperty.call(state.previous, key)) {
state.previous[key] = Object.prototype.hasOwnProperty.call(env, key)
? { present: true, value: env[key] }
: { present: false };
}
env[key] = desiredValue;
continue;
}

const previous = state.previous[key];
if (!previous) continue;
if (previous.present) env[key] = previous.value;
else delete env[key];
delete state.previous[key];
}

settings.env = env;
atomicWrite(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);

recordOwnedConfigPath(stateDir, statePath);
atomicWrite(statePath, `${JSON.stringify(state, null, 2)}\n`);
return { synced: true, settingsPath, statePath };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return {
synced: false,
settingsPath,
statePath,
warning: `Claude supervisor environment could not be persisted: ${detail}`,
};
}
}
182 changes: 182 additions & 0 deletions src/claude/recursive-launch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { createHash } from "node:crypto";
import { accessSync, chmodSync, constants, existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
import { posix, win32 } from "node:path";

export interface RecursiveClaudeEnv {
[key: string]: string | undefined;
}

export interface RecursiveClaudeLaunchDeps {
platform?: NodeJS.Platform;
configDir: string;
runtimePath: string;
entryPath: string;
exists?: (path: string) => boolean;
isExecutable?: (path: string) => boolean;
writeShim?: (path: string, content: string, platform: NodeJS.Platform) => void;
}

export interface RecursiveClaudeLaunch {
command: string;
env: RecursiveClaudeEnv;
shimPath: string | null;
warning?: string;
}

const REAL_COMMAND_ENV = "OPENCODEX_CLAUDE_REAL_COMMAND";

function pathVariableName(env: RecursiveClaudeEnv): string {
return Object.keys(env).find(key => key.toLowerCase() === "path") ?? "PATH";
}

function isExecutableDefault(path: string, platform: NodeJS.Platform): boolean {
if (!existsSync(path)) return false;
if (platform === "win32") return true;
try {
accessSync(path, constants.X_OK);
return true;
} catch {
return false;
}
}

/** Resolve the native Claude launcher before the OCX shim is added to PATH. */
export function resolveNativeClaudeCommand(
env: RecursiveClaudeEnv,
platform: NodeJS.Platform = process.platform,
exists: (path: string) => boolean = existsSync,
isExecutable: (path: string) => boolean = path => isExecutableDefault(path, platform),
): string | null {
const pinned = env[REAL_COMMAND_ENV]?.trim();
if (pinned && exists(pinned) && isExecutable(pinned)) return pinned;

const pathKey = pathVariableName(env);
const delimiter = platform === "win32" ? win32.delimiter : posix.delimiter;
const dirs = (env[pathKey] ?? "").split(delimiter).filter(Boolean);
if (platform === "win32") {
const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
for (const dir of dirs) {
for (const extension of extensions) {
const candidate = win32.join(dir, `claude${extension}`);
if (exists(candidate) && isExecutable(candidate)) return candidate;
}
}
return null;
}

for (const dir of dirs) {
const candidate = posix.join(dir, "claude");
if (exists(candidate) && isExecutable(candidate)) return candidate;
}
return null;
}

function quotePosix(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}

function quoteCmdValue(value: string): string {
// Percent expansion happens even inside quoted SET values and command tokens.
return value.replaceAll("%", "%%").replaceAll("^", "^^");
}

/** The shim contains paths only; gateway URLs and auth tokens remain process-local. */
export function renderRecursiveClaudeShim(
platform: NodeJS.Platform,
realCommand: string,
runtimePath: string,
entryPath: string,
): string {
if (platform === "win32") {
return [
"@echo off",
"setlocal",
`set "${REAL_COMMAND_ENV}=${quoteCmdValue(realCommand)}"`,
`"${quoteCmdValue(runtimePath)}" "${quoteCmdValue(entryPath)}" claude %*`,
"exit /b %ERRORLEVEL%",
"",
].join("\r\n");
}

return [
"#!/bin/sh",
"set -eu",
`export ${REAL_COMMAND_ENV}=${quotePosix(realCommand)}`,
`exec ${quotePosix(runtimePath)} ${quotePosix(entryPath)} claude "$@"`,
"",
].join("\n");
}

function writeShimAtomic(path: string, content: string, platform: NodeJS.Platform): void {
const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
writeFileSync(temp, content, { encoding: "utf8", mode: 0o700 });
renameSync(temp, path);
if (platform !== "win32") chmodSync(path, 0o700);
}

function prependShimToPath(env: RecursiveClaudeEnv, shimDir: string, platform: NodeJS.Platform): void {
const pathKey = pathVariableName(env);
const delimiter = platform === "win32" ? win32.delimiter : posix.delimiter;
const current = (env[pathKey] ?? "").split(delimiter).filter(Boolean);
env[pathKey] = [shimDir, ...current.filter(entry => entry !== shimDir)].join(delimiter);
}

/**
* Stable, non-secret identity for one OCX + Claude installation tuple. Shims for two
* concurrently active installations sharing OPENCODEX_HOME must never overwrite each
* other: an earlier session keeps its own directory at the front of PATH.
*/
function installationKey(
platform: NodeJS.Platform,
realCommand: string,
runtimePath: string,
entryPath: string,
): string {
return createHash("sha256")
.update(JSON.stringify([platform, realCommand, runtimePath, entryPath]))
.digest("hex")
.slice(0, 20);
}

/**
* Install a lightweight `claude` shim for descendants of `ocx claude`.
*
* Claude Code may deliberately remove provider auth variables from tool/child process
* environments. A descendant that starts a fresh `claude` CLI would then fall back to
* its own login state and report "Not logged in". The shim re-enters `ocx claude`, which
* reconstructs the complete gateway environment from current config. No token is
* written to disk; the generated launcher stores only executable paths.
*/
export function prepareRecursiveClaudeLaunch(
base: RecursiveClaudeEnv,
deps: RecursiveClaudeLaunchDeps,
): RecursiveClaudeLaunch {
const platform = deps.platform ?? process.platform;
const pathApi = platform === "win32" ? win32 : posix;
const exists = deps.exists ?? existsSync;
const isExecutable = deps.isExecutable ?? (path => isExecutableDefault(path, platform));
const realCommand = resolveNativeClaudeCommand(base, platform, exists, isExecutable);
if (!realCommand) {
return { command: "claude", env: { ...base }, shimPath: null };
}

const env: RecursiveClaudeEnv = { ...base, [REAL_COMMAND_ENV]: realCommand };
const key = installationKey(platform, realCommand, deps.runtimePath, deps.entryPath);
const shimDir = pathApi.join(deps.configDir, "claude-launcher", key);
const shimPath = pathApi.join(shimDir, platform === "win32" ? "claude.cmd" : "claude");
try {
mkdirSync(shimDir, { recursive: true, mode: 0o700 });
const content = renderRecursiveClaudeShim(platform, realCommand, deps.runtimePath, deps.entryPath);
(deps.writeShim ?? writeShimAtomic)(shimPath, content, platform);
prependShimToPath(env, shimDir, platform);
return { command: realCommand, env, shimPath };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return {
command: realCommand,
env,
shimPath: null,
warning: `Recursive Claude launcher could not be installed: ${detail}`,
};
}
}
Loading
Loading