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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 99 additions & 7 deletions src/cli/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,88 @@ export interface ClaudeLaunchEnv {
[key: string]: string | undefined;
}

/**
* Present the central/tunnelled proxy admission credential as an independent
* request header. Claude keeps its own subscription OAuth in subscription mode;
* the OCX service token is only for admitting the request at the proxy boundary.
*/
export function attachClaudeAdmissionHeader(
env: ClaudeLaunchEnv,
token: string | null,
): ClaudeLaunchEnv {
if (!token) return env;
const existing = env.ANTHROPIC_CUSTOM_HEADERS?.trim();
const lines = existing ? existing.split(/\r?\n/) : [];
if (lines.some(line => /^\s*x-opencodex-api-key\s*:/i.test(line))) return env;
env.ANTHROPIC_CUSTOM_HEADERS = [...lines, `x-opencodex-api-key: ${token}`].join("\n");
return env;
}

/**
* Admission credentials are only safe on the loopback origin owned by this OCX
* launch. Remote/tunnelled deployments must terminate through a local forward;
* an arbitrary ANTHROPIC_BASE_URL must never receive the service credential.
*/
export function isManagedClaudeAdmissionRoute(baseUrl: string | undefined, port: number): boolean {
if (!baseUrl) return false;
try {
const parsed = new URL(baseUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
const hostname = parsed.hostname.trim().toLowerCase().replace(/\.$/, "");
const loopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
if (!loopback) return false;
const effectivePort = parsed.port ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
return effectivePort === port;
} catch {
return false;
}
}

/** Remove the OCX admission header while preserving unrelated custom headers. */
export function stripClaudeAdmissionHeader(env: ClaudeLaunchEnv): ClaudeLaunchEnv {
const existing = env.ANTHROPIC_CUSTOM_HEADERS?.trim();
if (!existing) return env;
const lines = existing.split(/\r?\n/).filter(line => !/^\s*x-opencodex-api-key\s*:/i.test(line));
if (lines.length > 0) env.ANTHROPIC_CUSTOM_HEADERS = lines.join("\n");
else delete env.ANTHROPIC_CUSTOM_HEADERS;
return env;
}

/**
* Attach the service admission credential only when Claude is pointed at the
* exact loopback origin owned by the running OCX instance. Returns true when
* that managed route has an admission header after the operation.
*/
export function attachClaudeAdmissionHeaderForManagedRoute(
env: ClaudeLaunchEnv,
token: string | null,
port: number,
): boolean {
if (!isManagedClaudeAdmissionRoute(env.ANTHROPIC_BASE_URL, port)) {
// A stale header inherited from an earlier `ocx claude` launch is just as
// dangerous as injecting a new one, so fail closed on non-managed origins.
stripClaudeAdmissionHeader(env);
return false;
}
attachClaudeAdmissionHeader(env, token);
return (env.ANTHROPIC_CUSTOM_HEADERS ?? "")
.split(/\r?\n/)
.some(line => /^\s*x-opencodex-api-key\s*:/i.test(line));
}

/**
* Claude Code must treat the environment as host-managed whenever OCX supplies
* either its normal Anthropic credential or the separate tunnel admission
* credential. Otherwise Agent View can retain settings-sourced provider vars
* that override the proxy route after the admission header has been injected.
*/
export function isClaudeProviderManagedByHost(
env: ClaudeLaunchEnv,
admissionToken: string | null,
): boolean {
return Boolean(env.ANTHROPIC_AUTH_TOKEN || admissionToken);
}

/**
* Injectable IO for tests. `env` is deliberately NOT injectable: it is bound to the
* launch base so detection and the spawned process can never disagree (audit R3-3).
Expand Down Expand Up @@ -270,15 +352,25 @@ export async function cmdClaude(args: string[]): Promise<number> {
}
const contextWindows = await fetchClaudeContextWindows(config, port);
const env = buildClaudeEnv(config, port, process.env, contextWindows);
// A client-only/tunnelled install has a separate OCX admission credential. Do not
// overload ANTHROPIC_AUTH_TOKEN with it: that would replace the user's Claude OAuth.
// Claude Code supports newline-delimited ANTHROPIC_CUSTOM_HEADERS, so carry the
// service token on x-opencodex-api-key instead.
const dataPlaneAdmissionToken = resolveDataPlaneAdmissionToken(process.env);
const managedAdmissionHeader = attachClaudeAdmissionHeaderForManagedRoute(
env,
dataPlaneAdmissionToken,
port,
);

// Agent View sessions load settings.json `env`. Host-managed mode strips those
// keys — keep it OFF only when we are not injecting an admission token. With a
// real token, host-managed MUST stay ON so Claude Code does not warn that
// ANTHROPIC_AUTH_TOKEN competes with a /login OAuth session.
if (env.ANTHROPIC_AUTH_TOKEN) {
env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = "1";
} else {
env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = "0";
// keys only when OCX actually owns authentication on the selected route. Preserve
// an explicit user export (including =0) instead of silently overriding it here.
if (env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST === undefined) {
env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = isClaudeProviderManagedByHost(
env,
managedAdmissionHeader ? dataPlaneAdmissionToken : null,
) ? "1" : "0";
}
const persistentEnv = syncClaudePersistentSessionEnv(env, getConfigDir());
if (!persistentEnv.synced && persistentEnv.warning) {
Expand Down
54 changes: 53 additions & 1 deletion tests/claude-cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { claudeNotFoundHint } from "../src/cli/claude";
import { commandInvocation } from "../src/lib/win-exec";
import { buildClaudeEnv, claudeAdmissionToken } from "../src/cli/claude";
import { attachClaudeAdmissionHeader, attachClaudeAdmissionHeaderForManagedRoute, buildClaudeEnv, claudeAdmissionToken, isClaudeProviderManagedByHost, isManagedClaudeAdmissionRoute } from "../src/cli/claude";
import type { OcxConfig } from "../src/types";

function cfg(extra?: Partial<OcxConfig>): OcxConfig {
Expand Down Expand Up @@ -52,6 +52,51 @@ describe("ocx claude env assembly", () => {
expect(env.ANTHROPIC_AUTH_TOKEN).toBe("sk-ocx-123");
});

test("service admission header preserves Claude subscription OAuth", () => {
const env = buildClaudeEnv(cfg({ claudeCode: {} }), 10100, {}, {}, AUTH_PRESENT);
attachClaudeAdmissionHeader(env, "service-token");
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(env.ANTHROPIC_CUSTOM_HEADERS).toBe("x-opencodex-api-key: service-token");
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined();
});

test("service admission header preserves existing custom headers and user override", () => {
const env = { ANTHROPIC_CUSTOM_HEADERS: "x-trace-id: abc" };
attachClaudeAdmissionHeader(env, "service-token");
expect(env.ANTHROPIC_CUSTOM_HEADERS).toBe("x-trace-id: abc\nx-opencodex-api-key: service-token");

const user = { ANTHROPIC_CUSTOM_HEADERS: "X-OpenCodex-API-Key: user-token\nx-trace-id: abc" };
attachClaudeAdmissionHeader(user, "service-token");
expect(user.ANTHROPIC_CUSTOM_HEADERS).toBe("X-OpenCodex-API-Key: user-token\nx-trace-id: abc");
});

Comment thread
MisterWanted marked this conversation as resolved.
test("admission credential is limited to the exact managed loopback route", () => {
expect(isManagedClaudeAdmissionRoute("http://127.0.0.1:10100", 10100)).toBe(true);
expect(isManagedClaudeAdmissionRoute("http://localhost:10100/v1", 10100)).toBe(true);
expect(isManagedClaudeAdmissionRoute("http://127.0.0.1:10101", 10100)).toBe(false);
expect(isManagedClaudeAdmissionRoute("https://third-party.example/v1", 10100)).toBe(false);
expect(isManagedClaudeAdmissionRoute("not-a-url", 10100)).toBe(false);
});

test("non-managed base URL never receives or retains the OCX admission header", () => {
const env = {
ANTHROPIC_BASE_URL: "https://third-party.example/v1",
ANTHROPIC_CUSTOM_HEADERS: "x-trace-id: abc\nx-opencodex-api-key: stale-service-token",
};
const attached = attachClaudeAdmissionHeaderForManagedRoute(env, "service-token", 10100);
expect(attached).toBe(false);
expect(env.ANTHROPIC_CUSTOM_HEADERS).toBe("x-trace-id: abc");
expect(isClaudeProviderManagedByHost(env, attached ? "service-token" : null)).toBe(false);
});

test("managed loopback route receives the admission header and becomes host-managed", () => {
const env = { ANTHROPIC_BASE_URL: "http://127.0.0.1:10100", ANTHROPIC_CUSTOM_HEADERS: "x-trace-id: abc" };
const attached = attachClaudeAdmissionHeaderForManagedRoute(env, "service-token", 10100);
expect(attached).toBe(true);
expect(env.ANTHROPIC_CUSTOM_HEADERS).toBe("x-trace-id: abc\nx-opencodex-api-key: service-token");
expect(isClaudeProviderManagedByHost(env, attached ? "service-token" : null)).toBe(true);
});

// Host-managed routing guard (devlog 260720_claude_authmode_persist/020):
// defends the spawn env against leftover cc-switch/CCR settings.json env hijack.
test("subscription mode leaves the host-managed auth assertion unset", () => {
Expand All @@ -70,6 +115,13 @@ describe("ocx claude env assembly", () => {
expect(admission.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe("1");
});

test("a tunnel admission credential makes the spawned Claude environment host-managed", () => {
const subscription = buildClaudeEnv(cfg({ claudeCode: {} }), 10100, {}, {}, AUTH_PRESENT);
expect(subscription.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(isClaudeProviderManagedByHost(subscription, "service-token")).toBe(true);
expect(isClaudeProviderManagedByHost(subscription, null)).toBe(false);
});

// The gateway model-cache refresh must reach a tunnelled proxy. Our own dummy marker
// satisfies Claude Code's "a token is set" check but no gateway admits it, so it must
// never be preferred over a real credential.
Expand Down
Loading