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
12 changes: 12 additions & 0 deletions docs-site/src/content/docs/reference/cli/providers-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ ocx login xai
ocx login anthropic
```

A proxy that is already running picks up the new credential without a restart: the CLI asks it to
reload that one provider from disk, and the request carries no credential of its own. If the
running proxy cannot accept that request — most often because it started from a build that predates
attested reload — the login still succeeds and the credential is still written to disk, but the
live process keeps serving the previous one. The CLI says so and asks you to restart:

```
⚠️ A proxy is running but could not reload this provider (unattested-target).
The credential is saved to disk; the running proxy keeps using the previous one.
Restart it to pick this up: ocx restart
```

### `ocx logout <provider>`

Remove the stored OAuth credential for a provider.
Expand Down
19 changes: 19 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort";
import {
COST4_RATE_KEYS,
isValidCost4Rate,
refreshPreservedProviderOwner,
refreshUserCostOverlays,
withPreservedDiskOnlyProviders,
} from "./usage/user-cost-overlays";
Expand Down Expand Up @@ -2744,6 +2745,24 @@ export function armClaudeCodeBaseline(config: OcxConfig): void {
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
}

/**
* Adopt one schema-validated provider that was read from the authoritative disk
* config into a long-lived server config without rebasing any unrelated field.
* Updating the matching baseline row keeps a later guarded save from treating the
* adopted provider as an unsaved live edit that should defeat a newer disk change.
*/
export function adoptPersistedProviderIntoLiveConfig(
config: OcxConfig,
name: string,
provider: OcxProviderConfig,
persistedConfig?: OcxConfig,
): void {
config.providers[name] = structuredClone(provider);
const baseline = liveConfigBaseline.get(config);
if (baseline) baseline.providers[name] = structuredClone(provider);
if (persistedConfig) refreshPreservedProviderOwner(config, persistedConfig);
}

/** Test seam only: is this instance armed? */
export function claudeCodeBaselineArmed(config: OcxConfig): boolean {
return claudeCodeBaseline.has(config);
Expand Down
100 changes: 100 additions & 0 deletions src/lib/local-provider-reload-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { isLocalAttestationSecret } from "./local-management-attestation";

export const LOCAL_PROVIDER_RELOAD_METHOD = "POST";
export const LOCAL_PROVIDER_RELOAD_PATH = "/api/providers/reload";
export const LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION = "v1";
export const LOCAL_PROVIDER_RELOAD_EXPECTED_PID_HEADER = "x-opencodex-provider-reload-expected-pid";
export const LOCAL_PROVIDER_RELOAD_NONCE_HEADER = "x-opencodex-provider-reload-nonce";
export const LOCAL_PROVIDER_RELOAD_EXPIRES_AT_HEADER = "x-opencodex-provider-reload-expires-at";
export const LOCAL_PROVIDER_RELOAD_NAME_HEADER = "x-opencodex-provider-reload-name";
export const LOCAL_PROVIDER_RELOAD_CAPABILITY_HEADER = "x-opencodex-provider-reload-capability";
export const LOCAL_PROVIDER_RELOAD_CAPABILITY_TTL_MS = 10_000;

const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/;
const PROVIDER_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/;

export type ExpectedLocalProviderReloadPid =
| { kind: "absent" }
| { kind: "invalid" }
| { kind: "present"; pid: number };

export function parseExpectedLocalProviderReloadPid(value: string | null): ExpectedLocalProviderReloadPid {
if (value === null) return { kind: "absent" };
if (!/^[1-9]\d*$/.test(value)) return { kind: "invalid" };
const pid = Number(value);
return Number.isSafeInteger(pid) ? { kind: "present", pid } : { kind: "invalid" };
}

export function isLocalProviderReloadName(value: unknown): value is string {
return typeof value === "string" && PROVIDER_NAME.test(value);
}

function capabilityPayload(
nonce: string,
method: string,
path: string,
name: string,
pid: number,
port: number,
expiresAt: number,
): string | null {
if (!BASE64URL_256.test(nonce)) return null;
if (method !== LOCAL_PROVIDER_RELOAD_METHOD || path !== LOCAL_PROVIDER_RELOAD_PATH) return null;
if (!isLocalProviderReloadName(name)) return null;
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
if (!Number.isSafeInteger(expiresAt) || expiresAt <= 0) return null;
return `opencodex-local-provider-reload-v1\n${nonce}\n${method}\n${path}\n${name}\n${pid}\n${port}\n${expiresAt}`;
}

/** Process-scoped authorization to reload one named provider from protected disk state. */
export function createLocalProviderReloadCapability(
secret: string,
nonce: string,
method: string,
path: string,
name: string,
pid: number,
port: number,
expiresAt: number,
): string | null {
if (!isLocalAttestationSecret(secret)) return null;
const payload = capabilityPayload(nonce, method, path, name, pid, port, expiresAt);
if (!payload) return null;
return createHmac("sha256", secret).update(payload).digest("base64url");
}

export function verifyLocalProviderReloadCapability(
secret: string,
nonce: string | null,
method: string,
path: string,
name: string | null,
pid: number,
port: number,
expiresAt: number,
capability: string | null,
now = Date.now(),
): boolean {
if (!nonce || !name || !capability || !BASE64URL_256.test(capability)) return false;
if (
!Number.isSafeInteger(now)
|| expiresAt <= now
|| expiresAt > now + LOCAL_PROVIDER_RELOAD_CAPABILITY_TTL_MS
) return false;
const expected = createLocalProviderReloadCapability(
secret,
nonce,
method,
path,
name,
pid,
port,
expiresAt,
);
if (!expected) return false;
const expectedBytes = Buffer.from(expected);
const actualBytes = Buffer.from(capability);
return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes);
}
78 changes: 52 additions & 26 deletions src/oauth/login-cli.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,66 @@
import * as readline from "node:readline";
import { openUrl } from "../lib/open-url";
import { loadConfig, saveConfig } from "../config";
import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
import { findLiveProxy } from "../server/proxy-liveness";
import {
requestBoundLocalProviderReload,
type LocalProviderReloadResult,
} from "../server/local-provider-reload-client";
import { isPublicOAuthProvider, listOAuthProviders, runLogin } from "./index";
import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers";
import type { OcxConfig, OcxProviderConfig } from "../types";
import { configuredAdminToken } from "../lib/admin-secrets";
import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";

const LIVE_RELOAD_PROVIDERS = new Set<string>([
...listOAuthProviders(),
...Object.keys(KEY_LOGIN_PROVIDERS),
]);

export function runningProxyUpdateHeaders(): Headers {
const headers = new Headers({ "Content-Type": "application/json" });
const adminToken = configuredAdminToken();
if (adminToken) headers.set("X-OpenCodex-API-Key", adminToken);
return headers;
}

/** Push the new provider into a running proxy's live config so it routes without a restart. */
export async function notifyRunningProxy(name: string, provider: unknown): Promise<void> {
// Identity-checked runtime-port lookup: reaches a fallback-port proxy and avoids
// posting credentials-adjacent config to whatever else answers on config.port.
/**
* Ask the attested runtime to reload one already-persisted provider.
*
* Returns the outcome instead of swallowing it. A running proxy that predates
* attested reload — or one whose runtime record no longer matches — cannot adopt
* the new credential, and the caller has to say so: the credential is on disk, but
* the live process keeps routing with the old one until it restarts. Silently
* printing success there is how a login appears to work and then does not.
*/
export async function notifyRunningProxy(name: string): Promise<LocalProviderReloadResult | null> {
if (!LIVE_RELOAD_PROVIDERS.has(name)) return null;
const live = await findLiveProxy();
if (!live) return;
try {
await fetch(`http://${probeHostname(live.hostname)}:${live.port}/api/providers`, {
method: "POST",
headers: runningProxyUpdateHeaders(),
body: JSON.stringify({ name, provider }),
});
} catch {
/* proxy unreachable; disk config loads on next start */
}
if (!live) return null;
return await requestBoundLocalProviderReload(live, name);
}

/**
* After `runLogin()` has persisted the merged provider (including preserved apiKey /
* apiKeyPool / authMode), push that on-disk entry into a running proxy.
*
* Must not send `OAUTH_PROVIDERS[name].providerConfig`: POST /api/providers replaces the
* live entry and saves it, which would drop the preserved key billing state.
* apiKeyPool / authMode), ask the attested proxy to reload that exact on-disk entry.
*/
export async function notifyRunningProxyAfterOAuthLogin(name: string): Promise<LocalProviderReloadResult | null> {
if (!loadConfig().providers[name]) return null;
return await notifyRunningProxy(name);
}

/**
* A live proxy was found but could not adopt the credential. `null` means there was
* nothing to notify (no running proxy, or a provider that never reloads live), which
* is not a warning-worthy state.
*/
export async function notifyRunningProxyAfterOAuthLogin(name: string): Promise<void> {
const provider = loadConfig().providers[name];
if (!provider) return;
await notifyRunningProxy(name, provider);
export function warnIfLiveReloadSkipped(result: LocalProviderReloadResult | null): void {
if (!result || result.kind === "reloaded") return;
console.warn(
`\n⚠️ A proxy is running but could not reload this provider (${result.reason}).`
+ `\n The credential is saved to disk; the running proxy keeps using the previous one.`
+ `\n Restart it to pick this up: ocx restart`,
);
}

export async function handleLogin(provider?: string): Promise<void> {
Expand Down Expand Up @@ -73,8 +91,9 @@ async function handleOAuthLogin(name: string): Promise<void> {
} finally {
rl.close();
}
await notifyRunningProxyAfterOAuthLogin(name);
const reload = await notifyRunningProxyAfterOAuthLogin(name);
console.log(`\n✅ Logged in to ${name}. Try: ocx sync`);
warnIfLiveReloadSkipped(reload);
}

export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: string, baseUrlOverride?: string): OcxProviderConfig {
Expand Down Expand Up @@ -134,11 +153,16 @@ export async function commitKeyLoginProvider(
config: OcxConfig,
name: string,
provider: OcxProviderConfig,
onLiveReload?: (result: LocalProviderReloadResult | null) => void,
): Promise<OcxProviderConfig> {
const mergedProvider = mergeKeyLoginProviderRow(provider, config.providers[name]);
config.providers[name] = mergedProvider;
saveConfig(config);
await notifyRunningProxy(name, mergedProvider);
// Evaluate the reload BEFORE the optional call: `onLiveReload?.(await ...)` short-circuits
// the whole argument list when no callback is supplied, so the reload would never fire for
// callers that do not care about the outcome.
const reloadResult = await notifyRunningProxy(name);
onLiveReload?.(reloadResult);
return mergedProvider;
}

Expand Down Expand Up @@ -184,8 +208,10 @@ async function handleKeyLogin(name: string): Promise<void> {
console.error(`Error: ${commitCollision}.`);
process.exit(1);
}
await commitKeyLoginProvider(config, name, provider);
let reload: LocalProviderReloadResult | null = null;
await commitKeyLoginProvider(config, name, provider, result => { reload = result; });
console.log(`✅ ${def.label} added. Try: ocx sync`);
warnIfLiveReloadSkipped(reload);
}

function cloneRecordOfArrays(input: Record<string, string[]>): Record<string, string[]> {
Expand Down
10 changes: 7 additions & 3 deletions src/server/direct-local-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ function parseResponse(bytes: Buffer): Response {
}

/**
* Fetch one local HTTP GET over a direct TCP connection.
* Fetch one bodyless local HTTP GET or POST over a direct TCP connection.
*
* Bun's global fetch and Bun 1.3's node:http compatibility layer can honor
* HTTP(S)_PROXY. Local identity and capability probes must not expose headers
Expand All @@ -239,18 +239,22 @@ export async function directLocalHttpFetch(

if (url.protocol !== "http:") throw new Error("direct local request must use HTTP");
if (url.username || url.password) throw new Error("direct local request URL must not contain credentials");
if (method !== "GET" || body !== null) throw new Error("direct local request must be a bodyless GET");
if ((method !== "GET" && method !== "POST") || body !== null) {
throw new Error("direct local request must be a bodyless GET or POST");
}
if (signal?.aborted) throw abortReason(signal);

const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
headers.delete("proxy-authorization");
headers.delete("proxy-connection");
if (method === "POST") headers.set("content-length", "0");
else headers.delete("content-length");
headers.set("host", url.host);
headers.set("connection", "close");
const headerLines: string[] = [];
headers.forEach((value, key) => { headerLines.push(`${key}: ${value}`); });
const requestBytes = Buffer.from(
`GET ${url.pathname}${url.search} HTTP/1.1\r\n${headerLines.join("\r\n")}\r\n\r\n`,
`${method} ${url.pathname}${url.search} HTTP/1.1\r\n${headerLines.join("\r\n")}\r\n\r\n`,
"latin1",
);
const parsedHostname = url.hostname.startsWith("[") && url.hostname.endsWith("]")
Expand Down
2 changes: 2 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ import {
createLocalAttestationSecret,
} from "../lib/local-management-attestation";
import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../lib/system-restart-contract";
import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../lib/local-provider-reload-contract";
import { createReadinessGate, type ReadinessGate } from "./readiness";

export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
Expand Down Expand Up @@ -811,6 +812,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
pid: process.pid,
port: healthPort,
restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION,
providerReloadCapability: LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION,
}, 200, req, policy);
const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER);
if (challenge) {
Expand Down
Loading
Loading