Skip to content
Merged
318 changes: 318 additions & 0 deletions devlog/_plan/260911_hub_single_port/030_hub_local_clients.md

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions docs-site/src/content/docs/reference/configuration/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,21 @@ already holds that loopback address, so OpenCodex refuses the pair at write time
rather than failing the second bind. On those binds you do not need the listener at all — a
loopback bind already admits local callers.

With a `port` set, the local integrations follow the listener: `ocx claude`, the `system-env`
injection, the Claude Desktop profile, the Cursor gateway value and the routed vision helper all
write `http://127.0.0.1:<listener port>`, the same port `ocx sync` writes into Codex. Restart the
proxy after changing this field so those values are rewritten.

The listener serves only `POST /v1/responses`, its WebSocket upgrade, `POST /v1/responses/compact`,
`POST /v1/messages` (the Anthropic wire Claude Code and Claude Desktop speak),
`POST /v1/chat/completions` (the OpenAI chat wire Cursor and the vision helper speak),
`POST /v1/alpha/search` (the native Codex web-search relay), `GET /v1/models`, and the realtime
voice surface: the standalone WebSocket upgrades, WebRTC call creation (`POST /v1/live`,
`POST /v1/realtime/calls`), and the keyed sideband join upgrades (`/v1/live/{callId}`,
`/v1/realtime/calls/{callId}`, `/v1/realtime?call_id=`). Everything else, including `/api/*` and
the dashboard, returns `404`.
`/v1/realtime/calls/{callId}`, `/v1/realtime?call_id=`). Everything else, including `/api/*`,
`/healthz`, `/readyz` and the dashboard, returns `404` — local management reads such as
`ocx claude`'s discovery call go to the authenticated management surface with a management
credential, never here.

:::danger[This is an unauthenticated surface]
Every process on the machine can use this listener. It spends account quota and paid provider
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,7 @@
"launchd-repair.test.ts": "service",
"legacy-shell-compat.test.ts": "responses",
"live-service-manager-guard.test.ts": "service",
"local-destinations.test.ts": "lib",
"local-management-attestation.test.ts": "server",
"local-management-capability.test.ts": "server",
"local-management-direct-transport.test.ts": "server",
Expand Down
34 changes: 31 additions & 3 deletions src/claude/desktop-3p.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type DesktopProfileModel,
} from "./desktop-profile";
import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../codex/catalog";
import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations";
import { assertDesktop3pModelsValid } from "./desktop-3p-guard";

export interface Desktop3pModelEntry {
Expand Down Expand Up @@ -322,9 +323,14 @@ export function activeDesktop3pAlias(provider: string, modelId: string): string
* channel for supports1m/tier pins and it overrides discovery anyway (no merge), so
* discovery stays off for determinism. supports1m makes Desktop offer a separate 1M
* row; selecting it sends the bare id + `anthropic-beta: context-1m-2025-08-07`.
*
* `portOrOrigin` is the LOCAL destination Desktop should dial, already resolved by the caller
* (see `writeDesktop3pConfig`): on a hub that is the unauthenticated loopback listener, and with
* no listener the bind address — which is why an ORIGIN is accepted and not only a port. A bare
* port keeps meaning `http://127.0.0.1:<port>`, so every existing caller and test is unchanged.
*/
export function generateDesktop3pConfig(
port: number,
portOrOrigin: number | string,
nativeSlugs: string[],
routedModels: Array<Desktop3pRoutedModel>,
apiKey = "ocx",
Expand All @@ -335,7 +341,7 @@ export function generateDesktop3pConfig(
const base = {
inferenceProvider: "gateway",
inferenceCredentialKind: "static",
inferenceGatewayBaseUrl: `http://127.0.0.1:${port}`,
inferenceGatewayBaseUrl: typeof portOrOrigin === "number" ? `http://127.0.0.1:${portOrOrigin}` : portOrOrigin,
inferenceGatewayApiKey: apiKey,
};
if (mode === "discovery") {
Expand Down Expand Up @@ -620,8 +626,30 @@ export function writeDesktop3pConfig(
if (connection.kind === "connected" || inspectRemoteDesktopCleanup().kind !== "absent") {
return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_remote_store_active" };
}
// Claude Desktop runs on this machine, so it dials the unauthenticated loopback listener
// when one is enabled — on such a hub that is the only credential-free local socket
// (#4236) — and otherwise the bind address, which answers but demands data-plane
// admission. Resolved here, from the config this write already re-read, rather than in
// the pure generator: `latest.config` is the freshest answer any caller could pass in.
const destination = localInferenceDestination(latest.config, port);
// Desktop can carry a credential, so it does: the key the caller passed (the first
// configured `apiKeys` entry), else the env token / hardened service token file. This is
// the DATA-PLANE secret only — an admin token must never enter an exported client
// configuration (reviewer constraint on #4236).
const gatewayKey = destination.requiresAdmissionToken
? apiKey ?? localAdmissionToken(latest.config)
: apiKey;
if (destination.requiresAdmissionToken && !gatewayKey) {
// The placeholder the generator defaults to would 401 on this bind. Write the profile
// anyway — a reachable URL with a visible auth failure beats a dead socket — but say so.
console.error(
`⚠ Claude Desktop will dial ${destination.origin}, which requires an opencodex data-plane `
+ "credential that could not be resolved. Configure an API key or enable "
+ "`unauthenticatedLoopbackListener`.",
);
}
return writeDesktop3pConfigWithGenerator(() => (
generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode, profile, nativeContextCap)
generateDesktop3pConfig(destination.origin, nativeSlugs, routedModels, gatewayKey, mode, profile, nativeContextCap)
));
}), lifecycleLockDeps);
} catch { return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_lifecycle_busy_or_unsafe" }; }
Expand Down
33 changes: 12 additions & 21 deletions src/claude/gateway-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets";
import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations";
import type { OcxConfig } from "../types";

export interface GatewayModelRow {
Expand All @@ -24,7 +24,12 @@ export interface GatewayModelRow {
export interface GatewayModelCacheRefreshOptions {
timeoutMs?: number;
configDir?: string;
admissionConfig?: Pick<OcxConfig, "apiKeys">;
/**
* Admission credential source AND local destination source: the cache file's `baseUrl` must
* equal the `ANTHROPIC_BASE_URL` the CLI is launched with or Claude Code ignores the whole
* cache, so this has to resolve the same loopback listener `buildClaudeEnv` resolves (#4236).
*/
admissionConfig?: Pick<OcxConfig, "apiKeys" | "hostname" | "unauthenticatedLoopbackListener">;
env?: NodeJS.ProcessEnv;
fetchImpl?: typeof fetch;
}
Expand Down Expand Up @@ -60,19 +65,6 @@ export function writeGatewayModelCache(baseUrl: string, models: readonly Gateway
}
}

/**
* Hardened service-token file, the same precedence `ocx opencode` uses. A service
* install writes the admission token to disk rather than the interactive environment,
* so an interactive `ocx claude` with neither env token nor configured key would
* otherwise still get a 401 and keep a stale picker list.
*/
function serviceFileToken(env: NodeJS.ProcessEnv): string | null {
const lookup = env.OCX_API_TOKEN_FILE?.trim()
? env
: { ...env, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() };
return loadServiceTokenFromFile(lookup as Record<string, string | undefined>);
}

/** Fetch the anthropic-flavor /v1/models from the local proxy and write the cache. */
export async function refreshGatewayModelCacheFromProxy(
port: number,
Expand All @@ -92,17 +84,16 @@ export async function refreshGatewayModelCacheFromProxy(
// request sent to its local 127.0.0.1 address. Reuse the same dedicated
// credential domain as /v1/models admission; never place it in Authorization,
// which can belong to an upstream provider on other data-plane surfaces.
const envToken = (options.env ?? process.env).OPENCODEX_API_AUTH_TOKEN?.trim();
const configuredToken = options.admissionConfig?.apiKeys
?.find(entry => entry.key.trim().length > 0)
?.key.trim();
// Env token, then the hardened service token file (a service install writes the admission
// token to disk rather than the interactive environment), then a configured key — one
// shared ladder, so this cannot drift from what `buildClaudeEnv` puts in the launch env.
const admissionToken = typeof portOrTarget === "number"
? envToken || serviceFileToken(options.env ?? process.env) || configuredToken
? localAdmissionToken(options.admissionConfig, options.env ?? process.env)
: portOrTarget.admissionToken;
if (admissionToken) headers.set("x-opencodex-api-key", admissionToken);

const baseUrl = typeof portOrTarget === "number"
? `http://127.0.0.1:${portOrTarget}`
? localInferenceDestination(options.admissionConfig, portOrTarget).origin
: new URL(portOrTarget.baseUrl).origin;

// ?ids=cli pins the readable claude-ocx id family deterministically (audit 051
Expand Down
91 changes: 80 additions & 11 deletions src/cli/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { isProxyAdmissionSecret } from "../server/auth-cors";
import { findLiveProxy } from "../server/proxy-liveness";
import type { OcxConfig } from "../types";
import { configuredAdminToken } from "../lib/admin-secrets";
import { localAdmissionToken, localInferenceDestination, localLoopbackInferencePorts, localManagementOrigin } from "../lib/local-destinations";
import { PROXY_MARKER, ownAdmissionTokens, defaultAuthDetectDeps, detectClaudeAuth, type AuthDetectDeps } from "../claude/auth-detect";
import { resolveClaudeAuthMode } from "../claude/auth-mode";
import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
Expand Down Expand Up @@ -102,16 +103,34 @@ function isClaudeLoopbackHostname(hostname: string): boolean {
|| normalized === "[::1]";
}

function targetsLocalClaudeProxy(value: string | undefined, port: number): boolean {
/**
* Is this base URL one of OURS?
*
* Two ways to be ours (#4236), because a hub has two shapes of local destination:
*
* - a SET of loopback ports, not one port: with an unauthenticated loopback listener the
* public port and the listener's port are both addresses this proxy answers on at
* 127.0.0.1, so a URL naming either of them was written by us. Treating the one this launch
* did not pick as a foreign proxy would strip our own admission token out of the
* environment. On a tailnet bind with no listener that set is EMPTY, so a leftover
* `http://127.0.0.1:<port>` is correctly seen as stale rather than as ours.
* - the resolved destination origin itself, which on such a bind is the bind address. Without
* this arm the launch would write a base URL and then refuse to recognize it one line later.
*/
function targetsLocalClaudeProxy(
value: string | undefined,
ports: readonly number[],
ownOrigin?: string,
): boolean {
if (!value) return false;
try {
const parsed = new URL(value);
if (parsed.username !== "" || parsed.password !== "") return false;
if (ownOrigin !== undefined && parsed.origin === ownOrigin) return true;
const effectivePort = parsed.port === "" ? 80 : Number(parsed.port);
return parsed.protocol === "http:"
&& isClaudeLoopbackHostname(parsed.hostname)
&& effectivePort === port
&& parsed.username === ""
&& parsed.password === "";
&& ports.includes(effectivePort);
} catch {
return false;
}
Expand Down Expand Up @@ -147,7 +166,16 @@ export function buildClaudeEnv(
): ClaudeLaunchEnv {
const explicitTarget = typeof portOrTarget === "number" ? null : portOrTarget;
const port = typeof portOrTarget === "number" ? portOrTarget : null;
const managedBaseUrl = explicitTarget ? new URL(explicitTarget.baseUrl).origin : `http://127.0.0.1:${port}`;
// A local launch dials the unauthenticated loopback listener whenever one is enabled — the
// only credential-free local socket a tailnet-bound hub has (#4236). With the listener OFF
// the destination is the BIND address, which is reachable but demands data-plane admission;
// the resolver says which of the two this is instead of every caller guessing.
const destination = port === null ? null : localInferenceDestination(config, port);
const managedBaseUrl = explicitTarget
? new URL(explicitTarget.baseUrl).origin
: destination!.origin;
// Every port this proxy answers on at 127.0.0.1, so a base URL naming any of them is ours.
const ownLocalPorts = port === null ? [] : localLoopbackInferencePorts(config, port);
const env: ClaudeLaunchEnv = { ...base };
// Step 1 — strip OUR OWN dummy from the inherited environment before anything reads
// or writes the token slot. setDefault below preserves any non-empty value, so a
Expand Down Expand Up @@ -188,10 +216,14 @@ export function buildClaudeEnv(
try {
const parsed = new URL(existingBaseUrl);
const effectivePort = parsed.port === "" ? 80 : Number(parsed.port);
// Stale means "a port no live local listener of ours owns". With a loopback listener
// enabled that is two ports, and rewriting one of them into the other would reject a
// destination we wrote ourselves.
if (parsed.protocol === "http:"
&& isClaudeLoopbackHostname(parsed.hostname)
&& effectivePort !== port) {
const replacement = `http://127.0.0.1:${port}`;
&& !ownLocalPorts.includes(effectivePort)
&& parsed.origin !== managedBaseUrl) {
const replacement = managedBaseUrl;
console.error(`⚠ Replacing stale opencodex ANTHROPIC_BASE_URL ${parsed.origin} with ${replacement}.`);
env.ANTHROPIC_BASE_URL = replacement;
// The credentials in this environment were paired with the destination we just
Expand All @@ -216,10 +248,19 @@ export function buildClaudeEnv(
// the user's Claude login. Resolve the mode before adding any proxy-owned credential:
// subscription launches must keep their OAuth, while proxy launches may use the
// admission key or dummy marker (see server/claude-messages.ts).
const ownTokens = explicitTarget ? [explicitTarget.admissionToken] : ownAdmissionTokens(config);
// A bind that demands admission needs a credential the machine can actually present, which
// is wider than `config.apiKeys`: the service installs its data-plane secret as
// `OPENCODEX_API_AUTH_TOKEN` / the hardened token file, and that is the ladder the Codex
// provider table already uses. Never the admin token (reviewer constraint on #4236).
const hostAdmissionToken = destination?.requiresAdmissionToken === true
? localAdmissionToken(config)
: undefined;
const ownTokens = explicitTarget
? [explicitTarget.admissionToken]
: [...new Set([...(hostAdmissionToken ? [hostAdmissionToken] : []), ...ownAdmissionTokens(config)])];
const targetsLocalProxy = explicitTarget
? targetsClaudeRoutingTarget(env.ANTHROPIC_BASE_URL, explicitTarget)
: targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL, port!);
: targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL, ownLocalPorts, managedBaseUrl);
const isOwnAdmissionToken = (value: string): boolean =>
ownTokens.includes(value) || isProxyAdmissionSecret(value, config);
const inheritedApiKey = env.ANTHROPIC_API_KEY;
Expand Down Expand Up @@ -265,6 +306,19 @@ export function buildClaudeEnv(
if (!env.ANTHROPIC_AUTH_TOKEN && !hasUserApiKey && targetsLocalProxy && resolved.markerMode === "proxy") {
env.ANTHROPIC_AUTH_TOKEN = PROXY_MARKER;
}
// Degrade out loud rather than hand Claude Code a destination that 401s (#4236). A
// subscription launch deliberately carries no host token — asserting one logs a claude.ai
// subscriber out (#253) — so on a bind that demands admission the honest outcome is a
// warning naming the two fixes, not a silent refusal at the first request.
if (destination?.requiresAdmissionToken === true && targetsLocalProxy) {
const carried = env.ANTHROPIC_AUTH_TOKEN?.trim();
if (!hasUserApiKey && (!carried || carried === PROXY_MARKER)) {
console.error(
`⚠ ${managedBaseUrl} requires an opencodex data-plane credential and this launch carries none — `
+ "requests will be refused. Enable `unauthenticatedLoopbackListener` or bind the proxy to loopback.",
);
}
}
const finalAuthToken = env.ANTHROPIC_AUTH_TOKEN;
const hostOwnsAuthentication = targetsLocalProxy
&& !hasUserApiKey
Expand Down Expand Up @@ -335,6 +389,12 @@ export function buildClaudeEnv(
* Context-window map from the RUNNING proxy's management API (warm TTL cache; the
* daemon registers every selector form — audit R3#1). 3s bound + management auth header.
* (no [1m] marking, conservative).
*
* This is the MANAGEMENT destination, not the inference one (#4236): `/api/claude-code` is
* never served by the unauthenticated loopback listener, so it resolves through
* `localManagementOrigin` — a hub's loopback management ingress when it has one, otherwise the
* public bind — and keeps sending the local admin token. `enabled: false` is how `ocx claude`
* decides to launch natively, so a wrong destination here silently downgrades every launch.
*/
export interface ClaudeCodeLiveState {
contextWindows: Record<string, number>;
Expand All @@ -346,7 +406,7 @@ export async function fetchClaudeCodeState(config: OcxConfig, port: number, time
const headers = new Headers();
const token = configuredAdminToken();
if (token) headers.set("x-opencodex-api-key", token);
const res = await fetch(`http://127.0.0.1:${port}/api/claude-code`, {
const res = await fetch(`${localManagementOrigin(config, port)}/api/claude-code`, {
headers,
signal: AbortSignal.timeout(timeoutMs),
});
Expand Down Expand Up @@ -525,7 +585,16 @@ export function buildNativeClaudeEnv(
return Boolean(value && (value === PROXY_MARKER || isProxyAdmissionSecret(value, config)));
});
const baseUrl = env.ANTHROPIC_BASE_URL;
if (hasOwnedAdmission && targetsLocalClaudeProxy(baseUrl, config.port)) {
// Shedding asks a DIFFERENT question than the stale-replacement branch above, so it uses a
// wider set (#4236). There the question is "is this inherited URL a live destination of
// ours?" and a port nothing answers on must be rewritten. Here it is "could we have written
// this?" — and the answer is yes for the public port on any topology, because an earlier
// config on this machine may have been loopback-bound. Leaving such a URL in place with its
// admission token stripped (the loop below always strips it) would point a native launch at a
// dead socket with no credential, which is strictly worse than shedding one port too many.
const nativeLocalPorts = [...new Set([config.port, ...localLoopbackInferencePorts(config, config.port)])];
const nativeOwnOrigin = localInferenceDestination(config, config.port).origin;
if (hasOwnedAdmission && targetsLocalClaudeProxy(baseUrl, nativeLocalPorts, nativeOwnOrigin)) {
delete env.ANTHROPIC_BASE_URL;
}
for (const name of admissionSlots) {
Expand Down
Loading
Loading