From 60a8354561fe05fcdda7e6830c265a6fb5851dae Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Sun, 2 Aug 2026 17:45:47 +0200 Subject: [PATCH 01/13] fix(claude): add recursive OCX launcher --- src/claude/recursive-launch.ts | 163 +++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 src/claude/recursive-launch.ts diff --git a/src/claude/recursive-launch.ts b/src/claude/recursive-launch.ts new file mode 100644 index 000000000..f6045e1c0 --- /dev/null +++ b/src/claude/recursive-launch.ts @@ -0,0 +1,163 @@ +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); +} + +/** + * 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 shimDir = pathApi.join(deps.configDir, "claude-launcher"); + 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}`, + }; + } +} From bed1c51f45e2491c448a75df8ab259532074c847 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Sun, 2 Aug 2026 17:46:10 +0200 Subject: [PATCH 02/13] test(claude): cover recursive OCX launcher --- tests/claude-recursive-launch.test.ts | 111 ++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/claude-recursive-launch.test.ts diff --git a/tests/claude-recursive-launch.test.ts b/tests/claude-recursive-launch.test.ts new file mode 100644 index 000000000..b22e2437a --- /dev/null +++ b/tests/claude-recursive-launch.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + prepareRecursiveClaudeLaunch, + renderRecursiveClaudeShim, + resolveNativeClaudeCommand, +} from "../src/claude/recursive-launch"; + +function withTempDir(run: (root: string) => void): void { + const root = mkdtempSync(join(tmpdir(), "ocx-recursive-claude-")); + try { + run(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +describe("recursive ocx claude launcher", () => { + test("prepends an OCX-owned shim while launching the resolved native Claude binary", () => { + withTempDir(root => { + const nativeClaude = "/usr/local/bin/claude"; + const launch = prepareRecursiveClaudeLaunch({ + PATH: "/usr/local/bin:/usr/bin", + ANTHROPIC_AUTH_TOKEN: "secret-never-written", + }, { + platform: "linux", + configDir: root, + runtimePath: "/opt/opencodex/bun", + entryPath: "/opt/opencodex/package-main.mjs", + exists: path => path === nativeClaude, + isExecutable: path => path === nativeClaude, + }); + + expect(launch.command).toBe(nativeClaude); + expect(launch.shimPath).toBe(join(root, "claude-launcher", "claude")); + expect(launch.env.PATH?.split(":")[0]).toBe(join(root, "claude-launcher")); + expect(launch.env.OPENCODEX_CLAUDE_REAL_COMMAND).toBe(nativeClaude); + + const shim = readFileSync(launch.shimPath!, "utf8"); + expect(shim).toContain(`export OPENCODEX_CLAUDE_REAL_COMMAND='${nativeClaude}'`); + expect(shim).toContain("package-main.mjs' claude \"$@\""); + expect(shim).not.toContain("secret-never-written"); + expect(shim).not.toContain("ANTHROPIC_AUTH_TOKEN"); + }); + }); + + test("a shim-started nested launch keeps using the pinned native command", () => { + const nativeClaude = "/opt/claude/bin/claude"; + const resolved = resolveNativeClaudeCommand({ + PATH: "/tmp/ocx/claude-launcher:/opt/claude/bin", + OPENCODEX_CLAUDE_REAL_COMMAND: nativeClaude, + }, "linux", path => path === nativeClaude, path => path === nativeClaude); + + expect(resolved).toBe(nativeClaude); + }); + + test("missing Claude binary preserves the normal command-not-found path without installing a shim", () => { + withTempDir(root => { + const launch = prepareRecursiveClaudeLaunch({ PATH: "/usr/bin" }, { + platform: "linux", + configDir: root, + runtimePath: "/opt/bun", + entryPath: "/opt/ocx.mjs", + exists: () => false, + isExecutable: () => false, + }); + + expect(launch.command).toBe("claude"); + expect(launch.shimPath).toBeNull(); + expect(launch.env.PATH).toBe("/usr/bin"); + }); + }); + + test("shim write failures do not block the top-level Claude launch", () => { + withTempDir(root => { + const launch = prepareRecursiveClaudeLaunch({ PATH: "/bin" }, { + platform: "linux", + configDir: root, + runtimePath: "/opt/bun", + entryPath: "/opt/ocx.mjs", + exists: path => path === "/bin/claude", + isExecutable: path => path === "/bin/claude", + writeShim: () => { throw new Error("read-only filesystem"); }, + }); + + expect(launch.command).toBe("/bin/claude"); + expect(launch.shimPath).toBeNull(); + expect(launch.warning).toContain("read-only filesystem"); + }); + }); + + test("Windows resolution and shim preserve cmd launchers and argument forwarding", () => { + const nativeClaude = "C:\\Users\\joep\\AppData\\Roaming\\npm\\claude.CMD"; + const resolved = resolveNativeClaudeCommand({ + Path: "C:\\Users\\joep\\AppData\\Roaming\\npm;C:\\Windows", + PATHEXT: ".EXE;.CMD", + }, "win32", path => path === nativeClaude, path => path === nativeClaude); + + expect(resolved).toBe(nativeClaude); + const shim = renderRecursiveClaudeShim( + "win32", + nativeClaude, + "C:\\Program Files\\opencodex\\bun.exe", + "C:\\Program Files\\opencodex\\package-main.mjs", + ); + expect(shim).toContain(`set "OPENCODEX_CLAUDE_REAL_COMMAND=${nativeClaude}"`); + expect(shim).toContain('"C:\\Program Files\\opencodex\\bun.exe" "C:\\Program Files\\opencodex\\package-main.mjs" claude %*'); + }); +}); From 1ed26cf48927df698f8ccfae1335b3fda36c6473 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Sun, 2 Aug 2026 17:46:59 +0200 Subject: [PATCH 03/13] fix(claude): route descendant CLI launches through OCX --- src/cli/claude.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index d4f8270e6..1bcbb7bb9 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -7,10 +7,11 @@ * loopback opencodex base URL points at a different proxy port. */ import { spawn } from "node:child_process"; -import { loadConfig } from "../config"; +import { getConfigDir, loadConfig } from "../config"; import { injectClaudeAgentDefs } from "../claude/agents-inject"; import { effectiveModelEnv, resolveAutoContext } from "../claude/context-windows"; import { refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache"; +import { prepareRecursiveClaudeLaunch } from "../claude/recursive-launch"; import { commandInvocation } from "../lib/win-exec"; import { findLiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; @@ -245,6 +246,14 @@ export async function cmdClaude(args: string[]): Promise { } const contextWindows = await fetchClaudeContextWindows(config, port); const env = buildClaudeEnv(config, port, process.env, contextWindows); + const recursiveLaunch = prepareRecursiveClaudeLaunch(env, { + configDir: getConfigDir(), + runtimePath: process.execPath, + entryPath: process.argv[1], + }); + if (recursiveLaunch.warning) { + console.error(`⚠ ${recursiveLaunch.warning}; descendant Claude CLIs may require manual 'ocx claude'.`); + } // Pre-write the CLI's gateway-model cache (devlog 030): without a token the CLI // never refreshes it, so the picker would keep showing yesterday's aliases. try { @@ -267,8 +276,12 @@ export async function cmdClaude(args: string[]): Promise { console.error(`⚠ Claude agent definitions could not be synced: ${message}`); } return await new Promise(resolve => { - const inv = commandInvocation("claude", args); - const child = spawn(inv.file, inv.args, { stdio: "inherit", env: env as NodeJS.ProcessEnv, ...inv.options }); + const inv = commandInvocation(recursiveLaunch.command, args, process.platform, { env: recursiveLaunch.env }); + const child = spawn(inv.file, inv.args, { + stdio: "inherit", + env: recursiveLaunch.env as NodeJS.ProcessEnv, + ...inv.options, + }); child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { console.error(CLAUDE_INSTALL_HINT); From b5f691bea6d357d3d647b08f155b4ee32f2ef570 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Sun, 2 Aug 2026 17:48:03 +0200 Subject: [PATCH 04/13] fix(claude): isolate recursive shims per installation --- src/claude/recursive-launch.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/claude/recursive-launch.ts b/src/claude/recursive-launch.ts index f6045e1c0..f259f46b6 100644 --- a/src/claude/recursive-launch.ts +++ b/src/claude/recursive-launch.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { accessSync, chmodSync, constants, existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs"; import { posix, win32 } from "node:path"; @@ -120,6 +121,23 @@ function prependShimToPath(env: RecursiveClaudeEnv, shimDir: string, platform: N 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`. * @@ -143,7 +161,8 @@ export function prepareRecursiveClaudeLaunch( } const env: RecursiveClaudeEnv = { ...base, [REAL_COMMAND_ENV]: realCommand }; - const shimDir = pathApi.join(deps.configDir, "claude-launcher"); + 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 }); From 659084a0631ce7bda946de9c6fa3949ff4d10314 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Sun, 2 Aug 2026 17:48:34 +0200 Subject: [PATCH 05/13] test(claude): cover concurrent installation isolation --- tests/claude-recursive-launch.test.ts | 42 ++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/tests/claude-recursive-launch.test.ts b/tests/claude-recursive-launch.test.ts index b22e2437a..5c5ba2675 100644 --- a/tests/claude-recursive-launch.test.ts +++ b/tests/claude-recursive-launch.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { prepareRecursiveClaudeLaunch, renderRecursiveClaudeShim, @@ -34,8 +34,10 @@ describe("recursive ocx claude launcher", () => { }); expect(launch.command).toBe(nativeClaude); - expect(launch.shimPath).toBe(join(root, "claude-launcher", "claude")); - expect(launch.env.PATH?.split(":")[0]).toBe(join(root, "claude-launcher")); + expect(launch.shimPath).not.toBeNull(); + expect(launch.shimPath).toStartWith(join(root, "claude-launcher")); + expect(launch.shimPath).toEndWith(join("", "claude")); + expect(launch.env.PATH?.split(":")[0]).toBe(dirname(launch.shimPath!)); expect(launch.env.OPENCODEX_CLAUDE_REAL_COMMAND).toBe(nativeClaude); const shim = readFileSync(launch.shimPath!, "utf8"); @@ -46,10 +48,42 @@ describe("recursive ocx claude launcher", () => { }); }); + test("different active installations sharing one config dir cannot overwrite each other", () => { + withTempDir(root => { + const firstClaude = "/opt/first/bin/claude"; + const secondClaude = "/opt/second/bin/claude"; + const first = prepareRecursiveClaudeLaunch({ PATH: "/opt/first/bin" }, { + platform: "linux", + configDir: root, + runtimePath: "/opt/first/bun", + entryPath: "/opt/first/ocx.mjs", + exists: path => path === firstClaude, + isExecutable: path => path === firstClaude, + }); + const firstBefore = readFileSync(first.shimPath!, "utf8"); + + const second = prepareRecursiveClaudeLaunch({ PATH: "/opt/second/bin" }, { + platform: "linux", + configDir: root, + runtimePath: "/opt/second/bun", + entryPath: "/opt/second/ocx.mjs", + exists: path => path === secondClaude, + isExecutable: path => path === secondClaude, + }); + + expect(first.shimPath).not.toBe(second.shimPath); + expect(readFileSync(first.shimPath!, "utf8")).toBe(firstBefore); + expect(firstBefore).toContain("/opt/first/ocx.mjs"); + expect(readFileSync(second.shimPath!, "utf8")).toContain("/opt/second/ocx.mjs"); + expect(first.env.PATH?.split(":")[0]).toBe(dirname(first.shimPath!)); + expect(second.env.PATH?.split(":")[0]).toBe(dirname(second.shimPath!)); + }); + }); + test("a shim-started nested launch keeps using the pinned native command", () => { const nativeClaude = "/opt/claude/bin/claude"; const resolved = resolveNativeClaudeCommand({ - PATH: "/tmp/ocx/claude-launcher:/opt/claude/bin", + PATH: "/tmp/ocx/claude-launcher/hash:/opt/claude/bin", OPENCODEX_CLAUDE_REAL_COMMAND: nativeClaude, }, "linux", path => path === nativeClaude, path => path === nativeClaude); From 0340ff9008d3002b3943426fad274d58c1d704d4 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Sun, 2 Aug 2026 17:49:06 +0200 Subject: [PATCH 06/13] test(claude): use portable path assertions --- tests/claude-recursive-launch.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/claude-recursive-launch.test.ts b/tests/claude-recursive-launch.test.ts index 5c5ba2675..e23467865 100644 --- a/tests/claude-recursive-launch.test.ts +++ b/tests/claude-recursive-launch.test.ts @@ -35,8 +35,8 @@ describe("recursive ocx claude launcher", () => { expect(launch.command).toBe(nativeClaude); expect(launch.shimPath).not.toBeNull(); - expect(launch.shimPath).toStartWith(join(root, "claude-launcher")); - expect(launch.shimPath).toEndWith(join("", "claude")); + expect(launch.shimPath!.startsWith(join(root, "claude-launcher"))).toBe(true); + expect(launch.shimPath!.endsWith("claude")).toBe(true); expect(launch.env.PATH?.split(":")[0]).toBe(dirname(launch.shimPath!)); expect(launch.env.OPENCODEX_CLAUDE_REAL_COMMAND).toBe(nativeClaude); From f138c88a152c06cddc292da837d4c70aa85e230f Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Sun, 2 Aug 2026 15:54:32 +0000 Subject: [PATCH 07/13] test(claude): cover cmdClaude spawn wiring Co-authored-by: Codesmith --- tests/claude-cmd-spawn-wiring.test.ts | 110 ++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/claude-cmd-spawn-wiring.test.ts diff --git a/tests/claude-cmd-spawn-wiring.test.ts b/tests/claude-cmd-spawn-wiring.test.ts new file mode 100644 index 000000000..d02717613 --- /dev/null +++ b/tests/claude-cmd-spawn-wiring.test.ts @@ -0,0 +1,110 @@ +/** + * Regression coverage for the `cmdClaude` → `commandInvocation`/`spawn` wiring: + * the recursive-launch command and env must be the ones the child process + * actually receives, not the raw process env (review on PR #53). + */ +import { afterAll, describe, expect, mock, test } from "bun:test"; +import * as childProcess from "node:child_process"; +import { EventEmitter } from "node:events"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import * as agentsInject from "../src/claude/agents-inject"; +import * as gatewayCache from "../src/claude/gateway-cache"; +import * as configModule from "../src/config"; +import * as proxyLiveness from "../src/server/proxy-liveness"; +import type { OcxConfig } from "../src/types"; + +const root = mkdtempSync(join(tmpdir(), "ocx-claude-spawn-wiring-")); +const binDir = join(root, "bin"); +const configDir = join(root, "home"); +mkdirSync(binDir, { recursive: true }); +const isWindows = process.platform === "win32"; +const fakeClaude = join(binDir, isWindows ? "claude.CMD" : "claude"); +writeFileSync( + fakeClaude, + isWindows ? "@echo off\r\nexit /b 0\r\n" : "#!/bin/sh\nexit 0\n", + { mode: 0o755 }, +); + +const PROXY_PORT = 10142; + +type SpawnCall = { file: string; args: string[]; options: childProcess.SpawnOptions }; +const spawnCalls: SpawnCall[] = []; +const spawnMock = mock((file: string, args: readonly string[], options: childProcess.SpawnOptions) => { + spawnCalls.push({ file, args: [...args], options }); + const child = new EventEmitter(); + queueMicrotask(() => child.emit("exit", 0, null)); + return child as unknown as childProcess.ChildProcess; +}); + +mock.module("node:child_process", () => ({ ...childProcess, spawn: spawnMock })); +mock.module("../src/config", () => ({ + ...configModule, + loadConfig: (): OcxConfig => ({ port: PROXY_PORT } as OcxConfig), + getConfigDir: () => configDir, +})); +mock.module("../src/server/proxy-liveness", () => ({ + ...proxyLiveness, + findLiveProxy: async () => ({ pid: null, port: PROXY_PORT, source: "config" as const }), +})); +mock.module("../src/claude/gateway-cache", () => ({ + ...gatewayCache, + refreshGatewayModelCacheFromProxy: async () => join(root, "gateway-cache.json"), +})); +mock.module("../src/claude/agents-inject", () => ({ + ...agentsInject, + injectClaudeAgentDefs: () => 1, +})); + +const { cmdClaude } = await import("../src/cli/claude"); + +afterAll(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe("cmdClaude spawn wiring", () => { + test("forwards the recursive-launch command and env through commandInvocation into spawn", async () => { + const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === "path") ?? "PATH"; + const saved: Record = { + [pathKey]: process.env[pathKey], + PATHEXT: process.env.PATHEXT, + OPENCODEX_CLAUDE_REAL_COMMAND: process.env.OPENCODEX_CLAUDE_REAL_COMMAND, + ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, + }; + process.env[pathKey] = binDir; + if (isWindows) process.env.PATHEXT = ".CMD"; + delete process.env.OPENCODEX_CLAUDE_REAL_COMMAND; + delete process.env.ANTHROPIC_BASE_URL; + try { + const code = await cmdClaude(["--model", "wiring-check"]); + + expect(code).toBe(0); + expect(spawnCalls.length).toBe(1); + const { file, args, options } = spawnCalls[0]!; + const commandLine = [file, ...args].join(" "); + // recursiveLaunch.command reached commandInvocation and its output reached spawn. + if (isWindows) { + // win32 routes `.cmd` launchers through ComSpec with the resolved path inline. + expect(commandLine).toContain("claude.CMD"); + } else { + expect(file).toBe(fakeClaude); + expect(args).toEqual(["--model", "wiring-check"]); + } + expect(commandLine).toContain("wiring-check"); + // recursiveLaunch.env — not the raw process env — is what the child receives. + const spawnedEnv = options.env as Record; + expect(spawnedEnv.OPENCODEX_CLAUDE_REAL_COMMAND).toBe(fakeClaude); + expect(spawnedEnv.ANTHROPIC_BASE_URL).toBe(`http://127.0.0.1:${PROXY_PORT}`); + const spawnedPathKey = Object.keys(spawnedEnv).find(key => key.toLowerCase() === "path") ?? "PATH"; + const firstPathEntry = (spawnedEnv[spawnedPathKey] ?? "").split(isWindows ? ";" : ":")[0]!; + expect(dirname(firstPathEntry)).toBe(join(configDir, "claude-launcher")); + expect(options.stdio).toBe("inherit"); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); +}); From 59497a3e34b41c6d661d79989838011b9fd5526b Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Sun, 2 Aug 2026 16:08:10 +0000 Subject: [PATCH 08/13] test(claude): make recursive-launch assertions Windows-safe Co-authored-by: Codesmith --- tests/claude-recursive-launch.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/claude-recursive-launch.test.ts b/tests/claude-recursive-launch.test.ts index e23467865..2d22cb2e3 100644 --- a/tests/claude-recursive-launch.test.ts +++ b/tests/claude-recursive-launch.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { join, posix } from "node:path"; import { prepareRecursiveClaudeLaunch, renderRecursiveClaudeShim, @@ -35,9 +35,12 @@ describe("recursive ocx claude launcher", () => { expect(launch.command).toBe(nativeClaude); expect(launch.shimPath).not.toBeNull(); - expect(launch.shimPath!.startsWith(join(root, "claude-launcher"))).toBe(true); + // The launch simulates a posix platform, so paths and PATH entries are + // posix-shaped even when the test itself runs on Windows. A real temp + // root can contain a drive-letter colon, so avoid split(":") on PATH. + expect(launch.shimPath!.startsWith(posix.join(root, "claude-launcher"))).toBe(true); expect(launch.shimPath!.endsWith("claude")).toBe(true); - expect(launch.env.PATH?.split(":")[0]).toBe(dirname(launch.shimPath!)); + expect(launch.env.PATH).toBe(`${posix.dirname(launch.shimPath!)}:/usr/local/bin:/usr/bin`); expect(launch.env.OPENCODEX_CLAUDE_REAL_COMMAND).toBe(nativeClaude); const shim = readFileSync(launch.shimPath!, "utf8"); @@ -75,8 +78,8 @@ describe("recursive ocx claude launcher", () => { expect(readFileSync(first.shimPath!, "utf8")).toBe(firstBefore); expect(firstBefore).toContain("/opt/first/ocx.mjs"); expect(readFileSync(second.shimPath!, "utf8")).toContain("/opt/second/ocx.mjs"); - expect(first.env.PATH?.split(":")[0]).toBe(dirname(first.shimPath!)); - expect(second.env.PATH?.split(":")[0]).toBe(dirname(second.shimPath!)); + expect(first.env.PATH).toBe(`${posix.dirname(first.shimPath!)}:/opt/first/bin`); + expect(second.env.PATH).toBe(`${posix.dirname(second.shimPath!)}:/opt/second/bin`); }); }); From 77fcfdd178fb85a8a17a4f43c99088ca4cf59a0c Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Sun, 2 Aug 2026 16:13:48 +0000 Subject: [PATCH 09/13] test(cursor): fix 1ms cooldown race in default Retry-After assertion Co-authored-by: Codesmith --- tests/cursor-account-pool.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/cursor-account-pool.test.ts b/tests/cursor-account-pool.test.ts index c80013316..33549b0a4 100644 --- a/tests/cursor-account-pool.test.ts +++ b/tests/cursor-account-pool.test.ts @@ -116,7 +116,10 @@ describe("Cursor account pool", () => { rotateCursorAccountOnQuota(config(true), firstId!, null, "default-session"); const snapshot = getCursorAccountHealthSnapshot(firstId!); expect(snapshot?.cooldownSource).toBe("default"); - expect(snapshot!.cooldownUntil!).toBeLessThanOrEqual(before + 60_000); + // Bound with a timestamp taken after the rotation: the clock can tick between + // `before` and the rotation's own Date.now(), so `before + 60_000` is racy. + expect(snapshot!.cooldownUntil!).toBeGreaterThanOrEqual(before + 60_000); + expect(snapshot!.cooldownUntil!).toBeLessThanOrEqual(Date.now() + 60_000); }); test("only explicit rate and quota failures qualify for rotation", () => { From da7c6b39fb878617d3f42eace72771de31fc406b Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Sun, 2 Aug 2026 18:59:03 +0200 Subject: [PATCH 10/13] fix(claude): persist OCX env for agent-view workers --- src/claude/persistent-session-env.ts | 177 +++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 src/claude/persistent-session-env.ts diff --git a/src/claude/persistent-session-env.ts b/src/claude/persistent-session-env.ts new file mode 100644 index 000000000..724b9256e --- /dev/null +++ b/src/claude/persistent-session-env.ts @@ -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; +} + +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 { + 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 { + 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 = {}; + 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 { + const result: Record = { + 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 = { ...(existingEnv as Record | 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}`, + }; + } +} From 033b97a777d047379bc27c46e9a214aede858024 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Sun, 2 Aug 2026 19:00:00 +0200 Subject: [PATCH 11/13] fix(claude): persist gateway env for supervisor workers --- src/cli/claude.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 1bcbb7bb9..5d8295823 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -11,6 +11,7 @@ import { getConfigDir, loadConfig } from "../config"; import { injectClaudeAgentDefs } from "../claude/agents-inject"; import { effectiveModelEnv, resolveAutoContext } from "../claude/context-windows"; import { refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache"; +import { syncClaudePersistentSessionEnv } from "../claude/persistent-session-env"; import { prepareRecursiveClaudeLaunch } from "../claude/recursive-launch"; import { commandInvocation } from "../lib/win-exec"; import { findLiveProxy } from "../server/proxy-liveness"; @@ -246,6 +247,16 @@ export async function cmdClaude(args: string[]): Promise { } const contextWindows = await fetchClaudeContextWindows(config, port); const env = buildClaudeEnv(config, port, process.env, contextWindows); + + // Agent View/background sessions are separate Claude processes parented to a + // per-user supervisor. Keep the provider env eligible for settings.json loading: + // host-managed mode intentionally strips those settings-sourced variables. + env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = "0"; + const persistentEnv = syncClaudePersistentSessionEnv(env, getConfigDir()); + if (!persistentEnv.synced && persistentEnv.warning) { + console.error(`⚠ ${persistentEnv.warning}; Agent View workers may require respawn after fixing Claude settings.`); + } + const recursiveLaunch = prepareRecursiveClaudeLaunch(env, { configDir: getConfigDir(), runtimePath: process.execPath, From 2584ff2fa7429d13f0441f1307487073631baeb9 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Sun, 2 Aug 2026 19:00:25 +0200 Subject: [PATCH 12/13] test(claude): cover supervisor env persistence --- tests/claude-persistent-session-env.test.ts | 115 ++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/claude-persistent-session-env.test.ts diff --git a/tests/claude-persistent-session-env.test.ts b/tests/claude-persistent-session-env.test.ts new file mode 100644 index 000000000..2c110ef7a --- /dev/null +++ b/tests/claude-persistent-session-env.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + persistentClaudeEnv, + syncClaudePersistentSessionEnv, +} from "../src/claude/persistent-session-env"; + +function withTempDirs(run: (claudeDir: string, stateDir: string) => void): void { + const root = mkdtempSync(join(tmpdir(), "ocx-claude-persistent-env-")); + const claudeDir = join(root, ".claude"); + const stateDir = join(root, ".opencodex"); + mkdirSync(claudeDir, { recursive: true }); + mkdirSync(stateDir, { recursive: true }); + try { + run(claudeDir, stateDir); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function readSettings(claudeDir: string): Record { + return JSON.parse(readFileSync(join(claudeDir, "settings.json"), "utf8")) as Record; +} + +describe("Claude supervisor environment persistence", () => { + test("selects only OCX provider keys and disables the settings-env stripping guard", () => { + const selected = persistentClaudeEnv({ + PATH: "/tmp/private-bin", + OPENCODEX_CLAUDE_REAL_COMMAND: "/usr/bin/claude", + ANTHROPIC_BASE_URL: "http://127.0.0.1:10100", + ANTHROPIC_AUTH_TOKEN: "opencodex-proxy", + ANTHROPIC_MODEL: "claude-ocx-opencode-free--deepseek-v4-flash-free", + CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1", + }); + + expect(selected).toEqual({ + CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "0", + ANTHROPIC_BASE_URL: "http://127.0.0.1:10100", + ANTHROPIC_AUTH_TOKEN: "opencodex-proxy", + ANTHROPIC_MODEL: "claude-ocx-opencode-free--deepseek-v4-flash-free", + }); + expect(selected.PATH).toBeUndefined(); + expect(selected.OPENCODEX_CLAUDE_REAL_COMMAND).toBeUndefined(); + }); + + test("merges into settings.json without disturbing unrelated settings", () => { + withTempDirs((claudeDir, stateDir) => { + writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({ + theme: "dark", + permissions: { allow: ["Bash(git status)"] }, + env: { KEEP_ME: "yes" }, + }, null, 2)); + + const result = syncClaudePersistentSessionEnv({ + ANTHROPIC_BASE_URL: "http://127.0.0.1:10123", + ANTHROPIC_AUTH_TOKEN: "opencodex-proxy", + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1", + }, stateDir, claudeDir); + + expect(result.synced).toBeTrue(); + const settings = readSettings(claudeDir); + expect(settings.theme).toBe("dark"); + expect(settings.permissions).toEqual({ allow: ["Bash(git status)"] }); + expect(settings.env).toEqual({ + KEEP_ME: "yes", + CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "0", + ANTHROPIC_BASE_URL: "http://127.0.0.1:10123", + ANTHROPIC_AUTH_TOKEN: "opencodex-proxy", + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1", + }); + }); + }); + + test("restores the user's prior token when OCX switches to subscription mode", () => { + withTempDirs((claudeDir, stateDir) => { + writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({ + env: { + ANTHROPIC_AUTH_TOKEN: "user-token", + CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1", + }, + }, null, 2)); + + expect(syncClaudePersistentSessionEnv({ + ANTHROPIC_BASE_URL: "http://127.0.0.1:10100", + ANTHROPIC_AUTH_TOKEN: "opencodex-proxy", + }, stateDir, claudeDir).synced).toBeTrue(); + + expect(syncClaudePersistentSessionEnv({ + ANTHROPIC_BASE_URL: "http://127.0.0.1:10101", + }, stateDir, claudeDir).synced).toBeTrue(); + + const env = readSettings(claudeDir).env as Record; + expect(env.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:10101"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("user-token"); + expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe("0"); + }); + }); + + test("malformed Claude settings fail closed instead of replacing the file", () => { + withTempDirs((claudeDir, stateDir) => { + const path = join(claudeDir, "settings.json"); + writeFileSync(path, "{broken-json"); + + const result = syncClaudePersistentSessionEnv({ + ANTHROPIC_BASE_URL: "http://127.0.0.1:10100", + }, stateDir, claudeDir); + + expect(result.synced).toBeFalse(); + expect(result.warning).toContain("could not be persisted"); + expect(readFileSync(path, "utf8")).toBe("{broken-json"); + }); + }); +}); From ab47729db9d2fbf32ef6cc6d1074b99446339531 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Sun, 2 Aug 2026 19:01:03 +0200 Subject: [PATCH 13/13] test(claude): use portable boolean matchers --- tests/claude-persistent-session-env.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/claude-persistent-session-env.test.ts b/tests/claude-persistent-session-env.test.ts index 2c110ef7a..5fbcf8a17 100644 --- a/tests/claude-persistent-session-env.test.ts +++ b/tests/claude-persistent-session-env.test.ts @@ -59,7 +59,7 @@ describe("Claude supervisor environment persistence", () => { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1", }, stateDir, claudeDir); - expect(result.synced).toBeTrue(); + expect(result.synced).toBe(true); const settings = readSettings(claudeDir); expect(settings.theme).toBe("dark"); expect(settings.permissions).toEqual({ allow: ["Bash(git status)"] }); @@ -85,11 +85,11 @@ describe("Claude supervisor environment persistence", () => { expect(syncClaudePersistentSessionEnv({ ANTHROPIC_BASE_URL: "http://127.0.0.1:10100", ANTHROPIC_AUTH_TOKEN: "opencodex-proxy", - }, stateDir, claudeDir).synced).toBeTrue(); + }, stateDir, claudeDir).synced).toBe(true); expect(syncClaudePersistentSessionEnv({ ANTHROPIC_BASE_URL: "http://127.0.0.1:10101", - }, stateDir, claudeDir).synced).toBeTrue(); + }, stateDir, claudeDir).synced).toBe(true); const env = readSettings(claudeDir).env as Record; expect(env.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:10101"); @@ -107,7 +107,7 @@ describe("Claude supervisor environment persistence", () => { ANTHROPIC_BASE_URL: "http://127.0.0.1:10100", }, stateDir, claudeDir); - expect(result.synced).toBeFalse(); + expect(result.synced).toBe(false); expect(result.warning).toContain("could not be persisted"); expect(readFileSync(path, "utf8")).toBe("{broken-json"); });