Skip to content
4 changes: 2 additions & 2 deletions src/claude/desktop-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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`);
Expand Down
258 changes: 215 additions & 43 deletions src/cli/claude.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
* `ocx claude [claude args...]` — launch Claude Code wired to the local proxy.
* `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):
* ensures the proxy is running, injects the Anthropic env slots, then execs the
Expand All @@ -9,8 +10,10 @@
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 { isDesktopDateAlias } from "../claude/desktop-profile";
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";
Expand All @@ -21,10 +24,11 @@ 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";
import { aliasForNative, aliasForRoute } from "../claude/alias";
import { desktop3pAlias } from "../claude/desktop-3p";

Expand All @@ -49,6 +53,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<AnthropicParentEnvSlot>(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<string, unknown>;
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"
Expand Down Expand Up @@ -128,18 +155,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<AnthropicParentEnvSlot>(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
Expand Down Expand Up @@ -302,7 +318,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<Record<string, number>> {
export interface ClaudeCodeLiveState {
contextWindows: Record<string, number>;
enabled?: boolean;
}

export async function fetchClaudeCodeState(config: OcxConfig, port: number, timeoutMs = 3_000): Promise<ClaudeCodeLiveState> {
try {
const headers = new Headers();
const token = configuredAdminToken();
Expand All @@ -311,15 +332,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<string, number> };
return body.contextWindows && typeof body.contextWindows === "object" ? body.contextWindows : {};
if (!res.ok) return { contextWindows: {} };
const body = await res.json() as { contextWindows?: Record<string, number>; 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<Record<string, number>> {
return (await fetchClaudeCodeState(config, port, timeoutMs)).contextWindows;
}

export function readConnectedClaudeContextWindows(path = DEFAULT_CATALOG_PATH): Record<string, number> {
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as { models?: unknown };
Expand Down Expand Up @@ -360,10 +388,7 @@ export type ClaudeProxyEnsureDeps = {

export async function ensureProxyForClaude(deps: ClaudeProxyEnsureDeps = {}): Promise<number | null> {
// 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.
// is still settling startup work. Retry before spawning to avoid a second proxy.
const live = await (deps.findLiveProxy ?? findLiveProxy)({ attempts: 3 });
if (live) return live.port;
const cfgPort = loadConfig().port;
Expand All @@ -384,6 +409,139 @@ export async function ensureProxyForClaude(deps: ClaudeProxyEnsureDeps = {}): Pr
return null;
}

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(
configuredEnabled: boolean,
liveEnabled: boolean | undefined,
): ClaudeLaunchPlan {
if (!configuredEnabled) return { kind: "native", notice: CLAUDE_NATIVE_ROUTING_OFF };
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",
"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}$/;
Comment thread
everton-dgn marked this conversation as resolved.

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) || isDesktopDateAlias(id)) {
return true;
}
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);

const admissionSlots = ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const;
const hasOwnedAdmission = admissionSlots.some(name => {
const value = env[name]?.trim();
return Boolean(value && (value === PROXY_MARKER || isProxyAdmissionSecret(value, config)));
});
const baseUrl = env.ANTHROPIC_BASE_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];
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 <Anthropic 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";

/**
Expand Down Expand Up @@ -417,37 +575,31 @@ export function rootSkipPermissionsNotice(env: ClaudeLaunchEnv): string {

export async function cmdClaude(args: string[]): Promise<number> {
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;
}
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<string, number>;
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) {
console.error("❌ Proxy did not become healthy after starting.");
return 1;
}
const liveState = await fetchClaudeCodeState(config, port);
const plan = claudeLaunchPlan(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 });
Expand Down Expand Up @@ -479,7 +631,27 @@ export async function cmdClaude(args: string[]): Promise<number> {
console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
}
}
return await new Promise<number>(resolve => {
return spawnClaude(args, env);
}

async function launchNativeClaude(config: OcxConfig, args: string[], notice: string): Promise<number> {
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<number> {
return new Promise<number>(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) => {
Expand Down
5 changes: 3 additions & 2 deletions src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,13 +315,14 @@ 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 the proxy, with native fallback when Claude routing is disabled.",
details: [
"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.",
"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 <id> 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",
Expand Down
Loading
Loading