From 3299cdd9fd2e97c8496baab9e7528f613da58930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Fri, 4 Sep 2026 16:54:56 -0300 Subject: [PATCH 1/3] fix(claude): fall back to native launch when routing is off --- src/cli/claude.ts | 235 ++++++++++++++++++++++++++++++-------- src/cli/registry.ts | 5 +- tests/bun-runtime.test.ts | 1 - tests/claude-cli.test.ts | 87 +++++++++++++- 4 files changed, 273 insertions(+), 55 deletions(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index a9e64fc1af..d8355b385f 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -1,16 +1,18 @@ /** - * `ocx claude [claude args...]` — launch Claude Code wired to the local proxy. + * `ocx claude [claude args...]` — launch Claude Code through a live local proxy, + * or natively when routing is unavailable or disabled. * * Mirrors `ccr code` UX (devlog/260711_claude_inbound/020, 003 E1/E2/E5/G1): - * ensures the proxy is running, injects the Anthropic env slots, then execs the - * `claude` CLI with stdio inherited. User-exported env wins except when a stale + * injects the Anthropic env slots, then execs the `claude` CLI with stdio inherited. + * The launcher never starts the service. User-exported env wins except when a stale * loopback opencodex base URL points at a different proxy port. */ import { spawn } from "node:child_process"; import { loadConfig } from "../config"; import { injectClaudeAgentDefs } from "../claude/agents-inject"; +import { CLAUDE_ALIAS_PREFIX_V1, CLAUDE_ALIAS_PREFIX_V2 } from "../claude/alias"; import { effectiveModelEnv, resolveAutoContext } from "../claude/context-windows"; -import { refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache"; +import { claudeConfigDir, refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache"; import { commandInvocation } from "../lib/win-exec"; import { isProxyAdmissionSecret } from "../server/auth-cors"; import { findLiveProxy } from "../server/proxy-liveness"; @@ -18,13 +20,12 @@ import type { OcxConfig } from "../types"; import { configuredAdminToken } from "../lib/admin-secrets"; import { PROXY_MARKER, ownAdmissionTokens, defaultAuthDetectDeps, detectClaudeAuth, type AuthDetectDeps } from "../claude/auth-detect"; import { resolveClaudeAuthMode } from "../claude/auth-mode"; -import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; -import { selfLaunchArgv } from "../lib/self-launch-argv"; import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "./launcher-context"; import { readClientConnectionState } from "../client/state"; import { readServiceApiTokenState } from "../lib/service-secrets"; import { DEFAULT_CATALOG_PATH } from "../codex/paths"; import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { aliasForNative, aliasForRoute } from "../claude/alias"; import { desktop3pAlias } from "../claude/desktop-3p"; @@ -49,6 +50,29 @@ export type ClaudeEnvDeps = { allowRootSkipPermissions?: boolean; }; +function deleteUntrustedAnthropicSlots(env: ClaudeLaunchEnv, deps: ClaudeEnvDeps): void { + const explicitSlots = deps.preBunAnthropicSlots; + const trustedSlots = explicitSlots === undefined + ? trustedNodeLauncherContext()?.anthropicEnvSlots ?? [] + : explicitSlots ?? []; + const exported = new Set(trustedSlots); + for (const name of ANTHROPIC_PARENT_ENV_SLOTS) { + const value = env[name]; + if (value !== undefined && value !== "" && !exported.has(name)) delete env[name]; + } + delete env.OCX_PRE_BUN_ANTHROPIC_ENV; + delete env.OCX_NODE_LAUNCH_CONTEXT; +} + +function readPickerDefaultModel(configDir: string): string | null { + try { + const parsed = JSON.parse(readFileSync(join(configDir, "settings.json"), "utf8")) as Record; + return typeof parsed.model === "string" && parsed.model.trim() !== "" ? parsed.model.trim() : null; + } catch { + return null; + } +} + function isClaudeLoopbackHostname(hostname: string): boolean { const normalized = hostname.toLowerCase().replace(/\.$/, ""); return normalized === "localhost" @@ -128,18 +152,7 @@ export function buildClaudeEnv( // Direct `bun src/cli/index.ts` therefore loses ambient Anthropic values. That is a // real cost to a documented entry point, and the escape hatch is the launcher: run // through `ocx` (the published bin) and genuine shell exports are preserved by proof. - const explicitSlots = deps.preBunAnthropicSlots; - const trustedSlots = explicitSlots === undefined - ? trustedNodeLauncherContext()?.anthropicEnvSlots ?? [] - : explicitSlots ?? []; - const exported = new Set(trustedSlots); - for (const name of ANTHROPIC_PARENT_ENV_SLOTS) { - const value = env[name]; - if (value !== undefined && value !== "" && !exported.has(name)) delete env[name]; - } - // Never forward old or current provenance seams to Claude Code. - delete env.OCX_PRE_BUN_ANTHROPIC_ENV; - delete env.OCX_NODE_LAUNCH_CONTEXT; + deleteUntrustedAnthropicSlots(env, deps); const setDefault = (name: string, value: string | undefined) => { if (value === undefined || value.length === 0) return; if (env[name] !== undefined && env[name] !== "") return; // user wins @@ -302,7 +315,12 @@ export function buildClaudeEnv( * daemon registers every selector form — audit R3#1). 3s bound + management auth header. * (no [1m] marking, conservative). */ -export async function fetchClaudeContextWindows(config: OcxConfig, port: number, timeoutMs = 3_000): Promise> { +export interface ClaudeCodeLiveState { + contextWindows: Record; + enabled?: boolean; +} + +export async function fetchClaudeCodeState(config: OcxConfig, port: number, timeoutMs = 3_000): Promise { try { const headers = new Headers(); const token = configuredAdminToken(); @@ -311,15 +329,22 @@ export async function fetchClaudeContextWindows(config: OcxConfig, port: number, headers, signal: AbortSignal.timeout(timeoutMs), }); - if (!res.ok) return {}; - const body = await res.json() as { contextWindows?: Record }; - return body.contextWindows && typeof body.contextWindows === "object" ? body.contextWindows : {}; + if (!res.ok) return { contextWindows: {} }; + const body = await res.json() as { contextWindows?: Record; enabled?: boolean }; + return { + contextWindows: body.contextWindows && typeof body.contextWindows === "object" ? body.contextWindows : {}, + ...(typeof body.enabled === "boolean" ? { enabled: body.enabled } : {}), + }; } catch { console.error("⚠ 모델 컨텍스트 정보를 불러오지 못했습니다 — 1M 자동 표시는 이번 실행에서 생략됩니다."); - return {}; + return { contextWindows: {} }; } } +export async function fetchClaudeContextWindows(config: OcxConfig, port: number, timeoutMs = 3_000): Promise> { + return (await fetchClaudeCodeState(config, port, timeoutMs)).contextWindows; +} + export function readConnectedClaudeContextWindows(path = DEFAULT_CATALOG_PATH): Record { try { const parsed = JSON.parse(readFileSync(path, "utf8")) as { models?: unknown }; @@ -359,29 +384,118 @@ export type ClaudeProxyEnsureDeps = { }; export async function ensureProxyForClaude(deps: ClaudeProxyEnsureDeps = {}): Promise { - // A proxy that has only just bound can miss a single probe while its event loop - // is still settling startup work — the same just-started race the stop paths - // already retry for (#764, SERVICE_STOP_LIVENESS). Only the attempts budget is - // borrowed here; the probe timeout remains DEFAULT_PROBE_TIMEOUT_MS (750 ms). - // Without this, `ocx claude` can spawn a second proxy while the first is serving. + // Retry the read-only probe because a proxy that has only just bound can miss one + // attempt while its event loop is still settling startup work. This launcher never + // starts the service: service lifecycle stays under explicit operator control. const live = await (deps.findLiveProxy ?? findLiveProxy)({ attempts: 3 }); - if (live) return live.port; - const cfgPort = loadConfig().port; - const pinPort = typeof cfgPort === "number" && cfgPort > 0 ? cfgPort : 10100; - const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(pinPort)]), { - detached: true, - stdio: "ignore", - windowsHide: true, - env: withProcessRuntimeProvenance({ ...process.env, OCX_SERVICE: "1" }), - }); - child.unref(); - const deadline = Date.now() + 8_000; - while (Date.now() < deadline) { - const started = await findLiveProxy(); - if (started) return started.port; - await new Promise(resolve => setTimeout(resolve, 250)); + return live?.port ?? null; +} + +export const CLAUDE_NATIVE_NO_PROXY = + "ℹ️ OpenCodex proxy is not running. Launching Claude Code natively. Start the service with `ocx service start` to restore routing."; + +export const CLAUDE_NATIVE_ROUTING_OFF = + "ℹ️ Claude Code routing is disabled in OpenCodex. Launching Claude Code natively. Enable Claude routing to use the proxy again."; + +export const CLAUDE_NATIVE_LIVE_DISABLED = + "ℹ️ The running OpenCodex proxy has Claude Code routing disabled. Launching Claude Code natively. Restart the service after enabling routing."; + +export type ClaudeLaunchPlan = + | { kind: "routed" } + | { kind: "native"; notice: string }; + +export function claudeLaunchPlan( + proxyLive: boolean, + configuredEnabled: boolean, + liveEnabled: boolean | undefined, +): ClaudeLaunchPlan { + if (!configuredEnabled) return { kind: "native", notice: CLAUDE_NATIVE_ROUTING_OFF }; + if (!proxyLive) return { kind: "native", notice: CLAUDE_NATIVE_NO_PROXY }; + if (liveEnabled === false) return { kind: "native", notice: CLAUDE_NATIVE_LIVE_DISABLED }; + return { kind: "routed" }; +} + +const NATIVE_STRIPPED_LEVERS = [ + "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", + "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", + "CLAUDE_CODE_MAX_CONTEXT_TOKENS", + "CLAUDE_CODE_AUTO_COMPACT_WINDOW", + "CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", + "DISABLE_COMPACT", +] as const; + +const MODEL_ENV_SLOT_NAMES = [ + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", +] as const; + +const DESKTOP_3P_ALIAS = /^claude-opus-4(?:-8)?-[a-z][0-9a-z]{2}$/; + +export function isProxyOnlyModelId(value: string, providerNames: readonly string[] = []): boolean { + const id = value.trim().replace(/\[1m\]$/, ""); + if (!id) return false; + if (id.startsWith(CLAUDE_ALIAS_PREFIX_V1) || id.startsWith(CLAUDE_ALIAS_PREFIX_V2) || DESKTOP_3P_ALIAS.test(id)) { + return true; } - return null; + const slash = id.indexOf("/"); + return slash > 0 && providerNames.includes(id.slice(0, slash)); +} + +export function buildNativeClaudeEnv( + config: OcxConfig, + base: ClaudeLaunchEnv, + deps: ClaudeEnvDeps = {}, +): ClaudeLaunchEnv { + const env: ClaudeLaunchEnv = { ...base }; + deleteUntrustedAnthropicSlots(env, deps); + + for (const name of ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const) { + const value = env[name]?.trim(); + if (value && (value === PROXY_MARKER || isProxyAdmissionSecret(value, config))) delete env[name]; + } + + const baseUrl = env.ANTHROPIC_BASE_URL; + if (baseUrl) { + try { + const parsed = new URL(baseUrl); + if (parsed.protocol === "http:" && isClaudeLoopbackHostname(parsed.hostname)) delete env.ANTHROPIC_BASE_URL; + } catch { + // Preserve a user-provided value that is not a parseable URL. + } + } + + for (const name of NATIVE_STRIPPED_LEVERS) delete env[name]; + const providerNames = Object.keys(config.providers); + for (const name of MODEL_ENV_SLOT_NAMES) { + const value = env[name]; + if (value && isProxyOnlyModelId(value, providerNames)) delete env[name]; + } + if (deps.allowRootSkipPermissions === true && !env.IS_SANDBOX) env.IS_SANDBOX = "1"; + return env; +} + +export function nativeModelOverride( + pickedModel: string | null, + configuredModel: string | undefined, + args: readonly string[], + providerNames: readonly string[] = [], +): { flag?: string[]; warning?: string } { + if (!pickedModel || !isProxyOnlyModelId(pickedModel, providerNames)) return {}; + if (args.some(arg => arg === "--model" || arg.startsWith("--model="))) return {}; + const fallback = configuredModel?.trim(); + if (fallback && !isProxyOnlyModelId(fallback, providerNames)) { + return { + flag: ["--model", fallback], + warning: `ℹ️ The saved model (${pickedModel}) requires the proxy. This native session will use ${fallback}.`, + }; + } + return { + warning: `⚠ The saved model (${pickedModel}) requires the proxy. Use \`--model \` or select a native model in this session.`, + }; } const CLAUDE_INSTALL_HINT = "❌ `claude` CLI not found. Install it first: npm install -g @anthropic-ai/claude-code"; @@ -418,8 +532,7 @@ export function rootSkipPermissionsNotice(env: ClaudeLaunchEnv): string { export async function cmdClaude(args: string[]): Promise { const config = loadConfig(); if (config.claudeCode?.enabled === false) { - console.error("Claude inbound is disabled (config.claudeCode.enabled=false — flip the Claude ON toggle in the GUI or edit config)."); - return 1; + return launchNativeClaude(config, args, CLAUDE_NATIVE_ROUTING_OFF); } const clientState = readClientConnectionState(); if (clientState.kind === "invalid" || clientState.kind === "mismatched") { @@ -443,11 +556,13 @@ export async function cmdClaude(args: string[]): Promise { } else { const port = await ensureProxyForClaude(); if (!port) { - console.error("❌ Proxy did not become healthy after starting."); - return 1; + return launchNativeClaude(config, args, CLAUDE_NATIVE_NO_PROXY); } + const liveState = await fetchClaudeCodeState(config, port); + const plan = claudeLaunchPlan(true, true, liveState.enabled); + if (plan.kind === "native") return launchNativeClaude(config, args, plan.notice); route = port; - contextWindows = await fetchClaudeContextWindows(config, port); + contextWindows = liveState.contextWindows; } const allowRootSkipPermissions = shouldAllowRootSkipPermissions(args); const env = buildClaudeEnv(config, route, process.env, contextWindows, { allowRootSkipPermissions }); @@ -479,7 +594,27 @@ export async function cmdClaude(args: string[]): Promise { console.error(`⚠ Claude agent definitions could not be synced: ${message}`); } } - return await new Promise(resolve => { + return spawnClaude(args, env); +} + +async function launchNativeClaude(config: OcxConfig, args: string[], notice: string): Promise { + console.error(notice); + const providerNames = Object.keys(config.providers); + const override = nativeModelOverride( + readPickerDefaultModel(claudeConfigDir()), + config.claudeCode?.model, + args, + providerNames, + ); + if (override.warning) console.error(override.warning); + const allowRootSkipPermissions = shouldAllowRootSkipPermissions(args); + const env = buildNativeClaudeEnv(config, process.env, { allowRootSkipPermissions }); + if (allowRootSkipPermissions) console.error(rootSkipPermissionsNotice(env)); + return spawnClaude([...(override.flag ?? []), ...args], env); +} + +function spawnClaude(args: string[], env: ClaudeLaunchEnv): Promise { + return new Promise(resolve => { const inv = commandInvocation("claude", args); const child = spawn(inv.file, inv.args, { stdio: "inherit", env: env as NodeJS.ProcessEnv, ...inv.options }); child.on("error", (err: NodeJS.ErrnoException) => { diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 5d6cd4391c..5a9b64588b 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -315,10 +315,11 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "claude", usage: "ocx claude [claude args...]", - summary: "Launch Claude Code wired to the proxy (env injection + gateway model discovery).", + summary: "Launch Claude Code through a live proxy, with native fallback when routing is unavailable.", details: [ - "Ensures the proxy is running, then execs `claude` with ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN,", + "Uses an already-running proxy and execs `claude` with ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN,", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 and model slots from config.claudeCode.", + "It does not start the service. When the proxy is absent or Claude routing is disabled, it launches Claude Code natively.", "Routed models appear in the native /model picker with stable claude-opus-4-8-2026MMDD slot aliases (Claude Code >= 2.1.129).", "Older versions: pick models via ANTHROPIC_MODEL or /model directly (any string passes through).", "User-exported ANTHROPIC_* variables always take precedence.", diff --git a/tests/bun-runtime.test.ts b/tests/bun-runtime.test.ts index bfc1c333ca..26af17a072 100644 --- a/tests/bun-runtime.test.ts +++ b/tests/bun-runtime.test.ts @@ -237,7 +237,6 @@ describe("withProcessRuntimeProvenance (execPath relaunch paths)", () => { // provenance again, so the launch sites are pinned here rather than left to review. const launchers = [ "src/cli/index.ts", - "src/cli/claude.ts", "src/cli/opencode.ts", "src/server/management/system-restart.ts", "src/update/index.ts", diff --git a/tests/claude-cli.test.ts b/tests/claude-cli.test.ts index 8381b99e84..562e261cd9 100644 --- a/tests/claude-cli.test.ts +++ b/tests/claude-cli.test.ts @@ -1,5 +1,15 @@ import { describe, expect, test } from "bun:test"; -import { buildClaudeEnv, claudeNotFoundHint, ensureProxyForClaude, rootSkipPermissionsNotice, shouldAllowRootSkipPermissions } from "../src/cli/claude"; +import { + buildClaudeEnv, + buildNativeClaudeEnv, + claudeLaunchPlan, + claudeNotFoundHint, + ensureProxyForClaude, + isProxyOnlyModelId, + nativeModelOverride, + rootSkipPermissionsNotice, + shouldAllowRootSkipPermissions, +} from "../src/cli/claude"; import { commandInvocation } from "../src/lib/win-exec"; import type { LivenessIo, LiveProxy } from "../src/server/proxy-liveness"; import type { OcxConfig } from "../src/types"; @@ -27,7 +37,7 @@ const AUTH_PRESENT = { }; describe("ocx claude proxy liveness", () => { - test("retries the initial liveness probe before spawning a proxy", async () => { + test("retries the liveness probe without starting a proxy", async () => { const seen: (number | undefined)[] = []; const findLiveProxy = async (io?: LivenessIo): Promise => { seen.push(io?.attempts); @@ -38,6 +48,79 @@ describe("ocx claude proxy liveness", () => { expect(await ensureProxyForClaude({ findLiveProxy })).toBe(10100); expect(seen).toEqual([3]); }); + + test("returns null when no proxy is live", async () => { + const seen: (number | undefined)[] = []; + const findLiveProxy = async (io?: LivenessIo): Promise => { + seen.push(io?.attempts); + return null; + }; + + expect(await ensureProxyForClaude({ findLiveProxy })).toBeNull(); + expect(seen).toEqual([3]); + }); +}); + +describe("ocx claude native fallback", () => { + test("routes only through a live proxy whose Claude route is enabled", () => { + expect(claudeLaunchPlan(true, true, true)).toEqual({ kind: "routed" }); + expect(claudeLaunchPlan(true, true, undefined)).toEqual({ kind: "routed" }); + expect(claudeLaunchPlan(false, true, undefined)).toMatchObject({ kind: "native" }); + expect(claudeLaunchPlan(true, false, true)).toMatchObject({ kind: "native" }); + expect(claudeLaunchPlan(true, true, false)).toMatchObject({ kind: "native" }); + }); + + test("removes proxy-owned state while preserving user credentials and native model ids", () => { + const config = cfg({ + apiKeys: [{ id: "local", name: "local", key: "ocx_data_local_key", createdAt: "2026-01-01" }], + providers: { mock: { adapter: "openai-chat", baseUrl: "http://x/v1" } }, + }); + const env = buildNativeClaudeEnv(config, { + PATH: "/usr/bin", + ANTHROPIC_BASE_URL: "http://127.0.0.1:10100", + ANTHROPIC_AUTH_TOKEN: "ocx_data_local_key", + ANTHROPIC_API_KEY: "sk-ant-user-key", + ANTHROPIC_MODEL: "claude-ocx-mock--model", + ANTHROPIC_DEFAULT_OPUS_MODEL: "mock/model", + ANTHROPIC_DEFAULT_SONNET_MODEL: "sonnet", + CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1", + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1", + CLAUDE_CODE_AUTO_COMPACT_WINDOW: "829800", + }, { + preBunAnthropicSlots: ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"], + }); + + expect(env.PATH).toBe("/usr/bin"); + expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-user-key"); + expect(env.ANTHROPIC_MODEL).toBeUndefined(); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeUndefined(); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("sonnet"); + expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined(); + expect(env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY).toBeUndefined(); + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined(); + }); + + test("keeps unrelated slash model ids and recognizes configured provider routes", () => { + expect(isProxyOnlyModelId("mock/model", ["mock"])).toBe(true); + expect(isProxyOnlyModelId("claude-ocx2-abcd")).toBe(true); + expect(isProxyOnlyModelId("arn:aws:bedrock:region:acct:inference-profile/us.anthropic.model", ["mock"])).toBe(false); + expect(isProxyOnlyModelId("claude-opus-5")).toBe(false); + }); + + test("overrides a persisted proxy model only with a configured native model", () => { + expect(nativeModelOverride("claude-ocx2-abcd", "opus", [], ["mock"])) + .toMatchObject({ flag: ["--model", "opus"] }); + expect(nativeModelOverride("claude-ocx2-abcd", "mock/model", [], ["mock"]).flag).toBeUndefined(); + expect(nativeModelOverride("claude-ocx2-abcd", "opus", ["--model", "sonnet"], ["mock"])) + .toEqual({}); + }); + + test("preserves the root opt-in on native fallback", () => { + const env = buildNativeClaudeEnv(cfg(), {}, { allowRootSkipPermissions: true }); + expect(env.IS_SANDBOX).toBe("1"); + }); }); describe("ocx claude env assembly", () => { From d5e110847c74a686e50a3e60177fbdd37315c541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Fri, 4 Sep 2026 17:22:15 -0300 Subject: [PATCH 2/3] fix(claude): preserve routed autostart before fallback --- src/cli/claude.ts | 122 ++++++++++++++++++++++++-------------- src/cli/registry.ts | 8 +-- tests/bun-runtime.test.ts | 1 + tests/claude-cli.test.ts | 54 +++++++++++------ 4 files changed, 120 insertions(+), 65 deletions(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index d8355b385f..3979c8c75c 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -1,10 +1,10 @@ /** - * `ocx claude [claude args...]` — launch Claude Code through a live local proxy, - * or natively when routing is unavailable or disabled. + * `ocx claude [claude args...]` — launch Claude Code through the local proxy, + * or natively when Claude routing is explicitly disabled. * * Mirrors `ccr code` UX (devlog/260711_claude_inbound/020, 003 E1/E2/E5/G1): - * injects the Anthropic env slots, then execs the `claude` CLI with stdio inherited. - * The launcher never starts the service. User-exported env wins except when a stale + * ensures the proxy is running, injects the Anthropic env slots, then execs the + * `claude` CLI with stdio inherited. User-exported env wins except when a stale * loopback opencodex base URL points at a different proxy port. */ import { spawn } from "node:child_process"; @@ -20,9 +20,11 @@ import type { OcxConfig } from "../types"; import { configuredAdminToken } from "../lib/admin-secrets"; import { PROXY_MARKER, ownAdmissionTokens, defaultAuthDetectDeps, detectClaudeAuth, type AuthDetectDeps } from "../claude/auth-detect"; import { resolveClaudeAuthMode } from "../claude/auth-mode"; +import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { selfLaunchArgv } from "../lib/self-launch-argv"; import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "./launcher-context"; -import { readClientConnectionState } from "../client/state"; -import { readServiceApiTokenState } from "../lib/service-secrets"; +import { readClientConnectionState, type ClientConnectionState } from "../client/state"; +import { readServiceApiTokenState, type ServiceApiTokenState } from "../lib/service-secrets"; import { DEFAULT_CATALOG_PATH } from "../codex/paths"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -384,16 +386,28 @@ export type ClaudeProxyEnsureDeps = { }; export async function ensureProxyForClaude(deps: ClaudeProxyEnsureDeps = {}): Promise { - // Retry the read-only probe because a proxy that has only just bound can miss one - // attempt while its event loop is still settling startup work. This launcher never - // starts the service: service lifecycle stays under explicit operator control. + // A proxy that has only just bound can miss a single probe while its event loop + // is still settling startup work. Retry before spawning to avoid a second proxy. const live = await (deps.findLiveProxy ?? findLiveProxy)({ attempts: 3 }); - return live?.port ?? null; + if (live) return live.port; + const cfgPort = loadConfig().port; + const pinPort = typeof cfgPort === "number" && cfgPort > 0 ? cfgPort : 10100; + const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(pinPort)]), { + detached: true, + stdio: "ignore", + windowsHide: true, + env: withProcessRuntimeProvenance({ ...process.env, OCX_SERVICE: "1" }), + }); + child.unref(); + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + const started = await findLiveProxy(); + if (started) return started.port; + await new Promise(resolve => setTimeout(resolve, 250)); + } + return null; } -export const CLAUDE_NATIVE_NO_PROXY = - "ℹ️ OpenCodex proxy is not running. Launching Claude Code natively. Start the service with `ocx service start` to restore routing."; - export const CLAUDE_NATIVE_ROUTING_OFF = "ℹ️ Claude Code routing is disabled in OpenCodex. Launching Claude Code natively. Enable Claude routing to use the proxy again."; @@ -405,16 +419,46 @@ export type ClaudeLaunchPlan = | { kind: "native"; notice: string }; export function claudeLaunchPlan( - proxyLive: boolean, configuredEnabled: boolean, liveEnabled: boolean | undefined, ): ClaudeLaunchPlan { if (!configuredEnabled) return { kind: "native", notice: CLAUDE_NATIVE_ROUTING_OFF }; - if (!proxyLive) return { kind: "native", notice: CLAUDE_NATIVE_NO_PROXY }; if (liveEnabled === false) return { kind: "native", notice: CLAUDE_NATIVE_LIVE_DISABLED }; return { kind: "routed" }; } +export type ClaudeLaunchPreflight = + | { kind: "continue" } + | { kind: "native"; notice: string } + | { kind: "error"; message: string }; + +/** Validate connected-client ownership before any native fallback can run. */ +export function claudeLaunchPreflight( + configuredEnabled: boolean, + clientState: ClientConnectionState, + tokenState?: ServiceApiTokenState, +): ClaudeLaunchPreflight { + if (clientState.kind === "invalid" || clientState.kind === "mismatched") { + return { kind: "error", message: `Client state is ${clientState.kind}: ${clientState.reason}` }; + } + if (clientState.kind === "connected") { + if (!clientState.value.selectedClients.includes("claude")) { + return { kind: "error", message: "Claude is not selected for this remote hub connection." }; + } + if (tokenState?.kind !== "present" || tokenState.fingerprint !== clientState.value.tokenFingerprint) { + return { + kind: "error", + message: tokenState?.kind === "absent" + ? "Connected service token is missing." + : "Connected service token ownership changed.", + }; + } + } + return configuredEnabled + ? { kind: "continue" } + : { kind: "native", notice: CLAUDE_NATIVE_ROUTING_OFF }; +} + const NATIVE_STRIPPED_LEVERS = [ "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", @@ -453,19 +497,18 @@ export function buildNativeClaudeEnv( const env: ClaudeLaunchEnv = { ...base }; deleteUntrustedAnthropicSlots(env, deps); - for (const name of ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const) { + const admissionSlots = ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const; + const hasOwnedAdmission = admissionSlots.some(name => { const value = env[name]?.trim(); - if (value && (value === PROXY_MARKER || isProxyAdmissionSecret(value, config))) delete env[name]; - } - + return Boolean(value && (value === PROXY_MARKER || isProxyAdmissionSecret(value, config))); + }); const baseUrl = env.ANTHROPIC_BASE_URL; - if (baseUrl) { - try { - const parsed = new URL(baseUrl); - if (parsed.protocol === "http:" && isClaudeLoopbackHostname(parsed.hostname)) delete env.ANTHROPIC_BASE_URL; - } catch { - // Preserve a user-provided value that is not a parseable URL. - } + if (hasOwnedAdmission && targetsLocalClaudeProxy(baseUrl, config.port)) { + delete env.ANTHROPIC_BASE_URL; + } + for (const name of admissionSlots) { + const value = env[name]?.trim(); + if (value && (value === PROXY_MARKER || isProxyAdmissionSecret(value, config))) delete env[name]; } for (const name of NATIVE_STRIPPED_LEVERS) delete env[name]; @@ -531,35 +574,28 @@ export function rootSkipPermissionsNotice(env: ClaudeLaunchEnv): string { export async function cmdClaude(args: string[]): Promise { const config = loadConfig(); - if (config.claudeCode?.enabled === false) { - return launchNativeClaude(config, args, CLAUDE_NATIVE_ROUTING_OFF); - } const clientState = readClientConnectionState(); - if (clientState.kind === "invalid" || clientState.kind === "mismatched") { - console.error(`Client state is ${clientState.kind}: ${clientState.reason}`); + const tokenState = clientState.kind === "connected" ? readServiceApiTokenState() : undefined; + const preflight = claudeLaunchPreflight(config.claudeCode?.enabled !== false, clientState, tokenState); + if (preflight.kind === "error") { + console.error(preflight.message); return 1; } + if (preflight.kind === "native") return launchNativeClaude(config, args, preflight.notice); let route: number | ClaudeRoutingTarget; let contextWindows: Record; if (clientState.kind === "connected") { - if (!clientState.value.selectedClients.includes("claude")) { - console.error("Claude is not selected for this remote hub connection."); - return 1; - } - const token = readServiceApiTokenState(); - if (token.kind !== "present" || token.fingerprint !== clientState.value.tokenFingerprint) { - console.error(token.kind === "absent" ? "Connected service token is missing." : "Connected service token ownership changed."); - return 1; - } - route = { baseUrl: clientState.value.serverUrl, admissionToken: token.token }; + if (tokenState?.kind !== "present") return 1; + route = { baseUrl: clientState.value.serverUrl, admissionToken: tokenState.token }; contextWindows = readConnectedClaudeContextWindows(); } else { const port = await ensureProxyForClaude(); if (!port) { - return launchNativeClaude(config, args, CLAUDE_NATIVE_NO_PROXY); + console.error("❌ Proxy did not become healthy after starting."); + return 1; } const liveState = await fetchClaudeCodeState(config, port); - const plan = claudeLaunchPlan(true, true, liveState.enabled); + const plan = claudeLaunchPlan(true, liveState.enabled); if (plan.kind === "native") return launchNativeClaude(config, args, plan.notice); route = port; contextWindows = liveState.contextWindows; diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 5a9b64588b..32d7481606 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -315,14 +315,14 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "claude", usage: "ocx claude [claude args...]", - summary: "Launch Claude Code through a live proxy, with native fallback when routing is unavailable.", + summary: "Launch Claude Code through the proxy, with native fallback when Claude routing is disabled.", details: [ - "Uses an already-running proxy and execs `claude` with ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN,", + "Ensures the proxy is running, then execs `claude` with ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN,", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 and model slots from config.claudeCode.", - "It does not start the service. When the proxy is absent or Claude routing is disabled, it launches Claude Code natively.", + "When Claude routing is explicitly disabled, it launches natively after removing proven OpenCodex-owned proxy state.", "Routed models appear in the native /model picker with stable claude-opus-4-8-2026MMDD slot aliases (Claude Code >= 2.1.129).", "Older versions: pick models via ANTHROPIC_MODEL or /model directly (any string passes through).", - "User-exported ANTHROPIC_* variables always take precedence.", + "User-exported ANTHROPIC_* variables take precedence for routed launches; native fallback removes only proven OpenCodex-owned proxy values.", "", "Claude Desktop profile:", " ocx claude desktop [apply] Save and apply the four-family profile", diff --git a/tests/bun-runtime.test.ts b/tests/bun-runtime.test.ts index 26af17a072..bfc1c333ca 100644 --- a/tests/bun-runtime.test.ts +++ b/tests/bun-runtime.test.ts @@ -237,6 +237,7 @@ describe("withProcessRuntimeProvenance (execPath relaunch paths)", () => { // provenance again, so the launch sites are pinned here rather than left to review. const launchers = [ "src/cli/index.ts", + "src/cli/claude.ts", "src/cli/opencode.ts", "src/server/management/system-restart.ts", "src/update/index.ts", diff --git a/tests/claude-cli.test.ts b/tests/claude-cli.test.ts index 562e261cd9..806f8cce15 100644 --- a/tests/claude-cli.test.ts +++ b/tests/claude-cli.test.ts @@ -3,6 +3,7 @@ import { buildClaudeEnv, buildNativeClaudeEnv, claudeLaunchPlan, + claudeLaunchPreflight, claudeNotFoundHint, ensureProxyForClaude, isProxyOnlyModelId, @@ -37,7 +38,7 @@ const AUTH_PRESENT = { }; describe("ocx claude proxy liveness", () => { - test("retries the liveness probe without starting a proxy", async () => { + test("retries the initial liveness probe before spawning a proxy", async () => { const seen: (number | undefined)[] = []; const findLiveProxy = async (io?: LivenessIo): Promise => { seen.push(io?.attempts); @@ -48,26 +49,29 @@ describe("ocx claude proxy liveness", () => { expect(await ensureProxyForClaude({ findLiveProxy })).toBe(10100); expect(seen).toEqual([3]); }); - - test("returns null when no proxy is live", async () => { - const seen: (number | undefined)[] = []; - const findLiveProxy = async (io?: LivenessIo): Promise => { - seen.push(io?.attempts); - return null; - }; - - expect(await ensureProxyForClaude({ findLiveProxy })).toBeNull(); - expect(seen).toEqual([3]); - }); }); describe("ocx claude native fallback", () => { - test("routes only through a live proxy whose Claude route is enabled", () => { - expect(claudeLaunchPlan(true, true, true)).toEqual({ kind: "routed" }); - expect(claudeLaunchPlan(true, true, undefined)).toEqual({ kind: "routed" }); - expect(claudeLaunchPlan(false, true, undefined)).toMatchObject({ kind: "native" }); - expect(claudeLaunchPlan(true, false, true)).toMatchObject({ kind: "native" }); - expect(claudeLaunchPlan(true, true, false)).toMatchObject({ kind: "native" }); + test("routes unless configured or live Claude routing is explicitly disabled", () => { + expect(claudeLaunchPlan(true, true)).toEqual({ kind: "routed" }); + expect(claudeLaunchPlan(true, undefined)).toEqual({ kind: "routed" }); + expect(claudeLaunchPlan(false, true)).toMatchObject({ kind: "native" }); + expect(claudeLaunchPlan(true, false)).toMatchObject({ kind: "native" }); + }); + + test("rejects an invalid connected client before configuration-disabled fallback", () => { + expect(claudeLaunchPreflight(false, { kind: "invalid", reason: "bad client state" })) + .toEqual({ kind: "error", message: "Client state is invalid: bad client state" }); + expect(claudeLaunchPreflight(false, { + kind: "connected", + value: { + serverUrl: "https://hub.example.test", + apiKeyId: "remote", + tokenFingerprint: "expected", + selectedClients: ["claude"], + }, + }, { kind: "present", token: "secret", fingerprint: "changed" })) + .toEqual({ kind: "error", message: "Connected service token ownership changed." }); }); test("removes proxy-owned state while preserving user credentials and native model ids", () => { @@ -102,6 +106,20 @@ describe("ocx claude native fallback", () => { expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined(); }); + test("preserves an unrelated loopback gateway and its user credential", () => { + for (const baseUrl of ["http://localhost:8080", "http://127.0.0.1:10100"]) { + const env = buildNativeClaudeEnv(cfg({ port: 10100 }), { + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_API_KEY: "sk-ant-user-key", + }, { + preBunAnthropicSlots: ["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"], + }); + + expect(env.ANTHROPIC_BASE_URL).toBe(baseUrl); + expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-user-key"); + } + }); + test("keeps unrelated slash model ids and recognizes configured provider routes", () => { expect(isProxyOnlyModelId("mock/model", ["mock"])).toBe(true); expect(isProxyOnlyModelId("claude-ocx2-abcd")).toBe(true); From 08cd8e2f5186cf9c78c2f1934c6a9396a18330bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Fri, 4 Sep 2026 21:14:50 -0300 Subject: [PATCH 3/3] fix(claude): recognize desktop date aliases in native fallback --- src/claude/desktop-profile.ts | 4 +-- src/cli/claude.ts | 3 +- tests/claude-integration/claude-cli.test.ts | 35 +++++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/claude/desktop-profile.ts b/src/claude/desktop-profile.ts index 2bd0edf59a..0b49330796 100644 --- a/src/claude/desktop-profile.ts +++ b/src/claude/desktop-profile.ts @@ -83,7 +83,7 @@ function isRealAnthropicRoute(route: string): boolean { return route.startsWith("anthropic/claude-"); } -function validDateAlias(alias: string): boolean { +export function isDesktopDateAlias(alias: string): boolean { const match = DATE_ALIAS.exec(alias); if (!match) return false; const year = Number(match[1]!.slice(0, 4)); @@ -120,7 +120,7 @@ export function parseDesktopProfile(value: unknown): DesktopProfile { if (typeof raw.alias !== "string" || !raw.alias) throw new DesktopProfileError("must be a non-empty string", `profile.assignments.${route}.alias`); if (isRealAnthropicRoute(route)) { if (raw.alias !== routeModelId(route)) throw new DesktopProfileError("real Anthropic routes must keep their exact model id", `profile.assignments.${route}.alias`); - } else if (!validDateAlias(raw.alias)) { + } else if (!isDesktopDateAlias(raw.alias)) { throw new DesktopProfileError("must be a valid claude-opus-4-8-2026MMDD alias", `profile.assignments.${route}.alias`); } if (aliases.has(raw.alias)) throw new DesktopProfileError(`duplicate alias "${raw.alias}"`, `profile.assignments.${route}.alias`); diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 3979c8c75c..37e326887e 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -12,6 +12,7 @@ import { loadConfig } from "../config"; import { injectClaudeAgentDefs } from "../claude/agents-inject"; import { CLAUDE_ALIAS_PREFIX_V1, CLAUDE_ALIAS_PREFIX_V2 } from "../claude/alias"; import { effectiveModelEnv, resolveAutoContext } from "../claude/context-windows"; +import { isDesktopDateAlias } from "../claude/desktop-profile"; import { claudeConfigDir, refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache"; import { commandInvocation } from "../lib/win-exec"; import { isProxyAdmissionSecret } from "../server/auth-cors"; @@ -482,7 +483,7 @@ const DESKTOP_3P_ALIAS = /^claude-opus-4(?:-8)?-[a-z][0-9a-z]{2}$/; export function isProxyOnlyModelId(value: string, providerNames: readonly string[] = []): boolean { const id = value.trim().replace(/\[1m\]$/, ""); if (!id) return false; - if (id.startsWith(CLAUDE_ALIAS_PREFIX_V1) || id.startsWith(CLAUDE_ALIAS_PREFIX_V2) || DESKTOP_3P_ALIAS.test(id)) { + if (id.startsWith(CLAUDE_ALIAS_PREFIX_V1) || id.startsWith(CLAUDE_ALIAS_PREFIX_V2) || DESKTOP_3P_ALIAS.test(id) || isDesktopDateAlias(id)) { return true; } const slash = id.indexOf("/"); diff --git a/tests/claude-integration/claude-cli.test.ts b/tests/claude-integration/claude-cli.test.ts index 33a01e00a7..1df5ec5fb1 100644 --- a/tests/claude-integration/claude-cli.test.ts +++ b/tests/claude-integration/claude-cli.test.ts @@ -12,6 +12,7 @@ import { shouldAllowRootSkipPermissions, } from "../../src/cli/claude"; import { commandInvocation } from "../../src/lib/win-exec"; +import { reconcileDesktopProfile } from "../../src/claude/desktop-profile"; import type { LivenessIo, LiveProxy } from "../../src/server/proxy-liveness"; import type { OcxConfig } from "../../src/types"; @@ -135,6 +136,40 @@ describe("ocx claude native fallback", () => { .toEqual({}); }); + test("clears generated Desktop date aliases and overrides them with a native model", () => { + const profile = reconcileDesktopProfile(undefined, [{ route: "mock/model", label: "Mock" }]); + const alias = profile.assignments["mock/model"]!.alias; + + for (const model of [alias, `${alias}[1m]`]) { + expect(isProxyOnlyModelId(model)).toBe(true); + expect(buildNativeClaudeEnv(cfg(), { + ANTHROPIC_MODEL: model, + ANTHROPIC_DEFAULT_OPUS_MODEL: model, + })).toEqual({}); + expect(nativeModelOverride(model, "opus", [])) + .toMatchObject({ flag: ["--model", "opus"] }); + expect(nativeModelOverride(model, model, []).flag).toBeUndefined(); + expect(nativeModelOverride(model, "opus", ["--model", "sonnet"])) + .toEqual({}); + } + }); + + test("keeps legacy Desktop aliases and limits date recognition to valid profile slots", () => { + for (const model of ["claude-opus-4-a1b", "claude-opus-4-8-a1b"]) { + expect(isProxyOnlyModelId(model)).toBe(true); + } + for (const model of [ + "claude-opus-4-8-20260229", + "claude-opus-4-8-20261301", + "claude-opus-4-8-20270101", + "claude-sonnet-4-20250514", + ]) { + expect(isProxyOnlyModelId(model)).toBe(false); + expect(buildNativeClaudeEnv(cfg(), { ANTHROPIC_MODEL: model }).ANTHROPIC_MODEL).toBe(model); + expect(nativeModelOverride(model, "opus", [])).toEqual({}); + } + }); + test("preserves the root opt-in on native fallback", () => { const env = buildNativeClaudeEnv(cfg(), {}, { allowRootSkipPermissions: true }); expect(env.IS_SANDBOX).toBe("1");