From 24bcdb554fc995bad8dd808fb74ec24d9d3343c9 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Sun, 6 Sep 2026 13:03:33 +0200 Subject: [PATCH 1/3] fix(claude): authenticate tunnelled client without replacing OAuth --- src/cli/claude.ts | 22 ++++++++++++++++++++++ tests/claude-cli.test.ts | 20 +++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 0672de31..deb5bdb9 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -25,6 +25,23 @@ 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; +} + /** * 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,6 +287,11 @@ 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. + attachClaudeAdmissionHeader(env, resolveDataPlaneAdmissionToken(process.env)); // 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 diff --git a/tests/claude-cli.test.ts b/tests/claude-cli.test.ts index f0498ce8..da6851ff 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, buildClaudeEnv, claudeAdmissionToken } from "../src/cli/claude"; import type { OcxConfig } from "../src/types"; function cfg(extra?: Partial): OcxConfig { @@ -52,6 +52,24 @@ 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"); + }); + // 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", () => { From 53324f8b0d69aec255455f9fd608dbeb4f7ae131 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Mon, 7 Sep 2026 18:21:34 +0200 Subject: [PATCH 2/3] fix(claude): mark tunnel admission as host-managed --- src/cli/claude.ts | 18 ++++++++++++++++-- tests/claude-cli.test.ts | 9 ++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index deb5bdb9..765924d4 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -42,6 +42,19 @@ export function attachClaudeAdmissionHeader( return env; } +/** + * 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). @@ -291,13 +304,14 @@ export async function cmdClaude(args: string[]): Promise { // 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. - attachClaudeAdmissionHeader(env, resolveDataPlaneAdmissionToken(process.env)); + const dataPlaneAdmissionToken = resolveDataPlaneAdmissionToken(process.env); + attachClaudeAdmissionHeader(env, dataPlaneAdmissionToken); // 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) { + if (isClaudeProviderManagedByHost(env, dataPlaneAdmissionToken)) { env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = "1"; } else { env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = "0"; diff --git a/tests/claude-cli.test.ts b/tests/claude-cli.test.ts index da6851ff..a169725e 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 { attachClaudeAdmissionHeader, buildClaudeEnv, claudeAdmissionToken } from "../src/cli/claude"; +import { attachClaudeAdmissionHeader, buildClaudeEnv, claudeAdmissionToken, isClaudeProviderManagedByHost } from "../src/cli/claude"; import type { OcxConfig } from "../src/types"; function cfg(extra?: Partial): OcxConfig { @@ -88,6 +88,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. From 504bb46e81223363f332ba887912617860f28b56 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Mon, 7 Sep 2026 19:18:36 +0200 Subject: [PATCH 3/3] fix(claude): constrain admission header to managed route --- src/cli/claude.ts | 72 +++++++++++++++++++++++++++++++++++----- tests/claude-cli.test.ts | 29 +++++++++++++++- 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 765924d4..0e71226d 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -42,6 +42,58 @@ export function attachClaudeAdmissionHeader( 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 @@ -305,16 +357,20 @@ export async function cmdClaude(args: string[]): Promise { // Claude Code supports newline-delimited ANTHROPIC_CUSTOM_HEADERS, so carry the // service token on x-opencodex-api-key instead. const dataPlaneAdmissionToken = resolveDataPlaneAdmissionToken(process.env); - attachClaudeAdmissionHeader(env, dataPlaneAdmissionToken); + 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 (isClaudeProviderManagedByHost(env, dataPlaneAdmissionToken)) { - 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 a169725e..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 { attachClaudeAdmissionHeader, buildClaudeEnv, claudeAdmissionToken, isClaudeProviderManagedByHost } 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 { @@ -70,6 +70,33 @@ describe("ocx claude env assembly", () => { 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", () => {