diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 0672de31..0e71226d 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -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). @@ -270,15 +352,25 @@ export async function cmdClaude(args: string[]): Promise { } 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) { diff --git a/tests/claude-cli.test.ts b/tests/claude-cli.test.ts index f0498ce8..0b90c91c 100644 --- a/tests/claude-cli.test.ts +++ b/tests/claude-cli.test.ts @@ -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 { @@ -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"); + }); + + 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", () => { @@ -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.