diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 6cb382df82..457b83c571 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -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 ` Remove the stored OAuth credential for a provider. diff --git a/src/config.ts b/src/config.ts index 7f99b2e470..ea0f5c94a8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -78,6 +78,7 @@ import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort"; import { COST4_RATE_KEYS, isValidCost4Rate, + refreshPreservedProviderOwner, refreshUserCostOverlays, withPreservedDiskOnlyProviders, } from "./usage/user-cost-overlays"; @@ -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); diff --git a/src/lib/local-provider-reload-contract.ts b/src/lib/local-provider-reload-contract.ts new file mode 100644 index 0000000000..1a5f340b99 --- /dev/null +++ b/src/lib/local-provider-reload-contract.ts @@ -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); +} diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index f9f5798a90..437b61e6d6 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -1,13 +1,22 @@ 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([ + ...listOAuthProviders(), + ...Object.keys(KEY_LOGIN_PROVIDERS), +]); + export function runningProxyUpdateHeaders(): Headers { const headers = new Headers({ "Content-Type": "application/json" }); const adminToken = configuredAdminToken(); @@ -15,34 +24,43 @@ export function runningProxyUpdateHeaders(): Headers { 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 { - // 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 { + 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 { + 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 { - 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 { @@ -73,8 +91,9 @@ async function handleOAuthLogin(name: string): Promise { } 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 { @@ -134,11 +153,16 @@ export async function commitKeyLoginProvider( config: OcxConfig, name: string, provider: OcxProviderConfig, + onLiveReload?: (result: LocalProviderReloadResult | null) => void, ): Promise { 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; } @@ -184,8 +208,10 @@ async function handleKeyLogin(name: string): Promise { 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): Record { diff --git a/src/server/direct-local-http.ts b/src/server/direct-local-http.ts index a0a5acb81f..976c1af92b 100644 --- a/src/server/direct-local-http.ts +++ b/src/server/direct-local-http.ts @@ -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 @@ -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("]") diff --git a/src/server/index.ts b/src/server/index.ts index 96520bf156..7a460ab5a3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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; @@ -811,6 +812,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server RuntimePortState | null; + createNonce?: () => string; + now?: () => number; + timeoutMs?: number; +} + +const LOCAL_PROVIDER_RELOAD_TIMEOUT_MS = 10_000; + +/** + * Ask the exact runtime proxy to reload one already-persisted provider. + * + * No provider object, API key, OAuth token, custom header, or reusable management + * credential crosses the socket. The request is bodyless and its one-shot capability + * binds the provider name to the attested process, method, path, PID, port, and expiry. + */ +export async function requestBoundLocalProviderReload( + target: LiveProxy, + name: string, + deps: LocalProviderReloadDeps = {}, +): Promise { + if (!isLocalProviderReloadName(name)) return { kind: "unavailable", reason: "invalid-name" }; + if (target.source !== "runtime" || target.pid === null || target.pid <= 0) { + return { kind: "unavailable", reason: "unattested-target" }; + } + const readRuntime = deps.readRuntime ?? readRuntimePort; + const runtime = readRuntime(target.pid); + if ( + !runtime?.attestationSecret + || runtime.pid !== target.pid + || runtime.port !== target.port + ) { + return { kind: "unavailable", reason: "runtime-mismatch" }; + } + + const fetchImpl = deps.fetchImpl ?? directLocalHttpFetch; + const timeoutMs = deps.timeoutMs ?? LOCAL_PROVIDER_RELOAD_TIMEOUT_MS; + const nonce = (deps.createNonce ?? createLocalAttestationChallenge)(); + const baseUrl = `http://${probeHostname(target.hostname)}:${target.port}`; + let proofResponse: Response; + try { + proofResponse = await fetchImpl(`${baseUrl}/healthz`, { + headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: nonce }, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch { + return { kind: "unavailable", reason: "transport" }; + } + const body = await proofResponse.json().catch(() => null) as HealthzIdentity | null; + if ( + !proofResponse.ok + || !isOpencodexHealthz(body) + || body?.pid !== target.pid + || body?.port !== target.port + || body?.providerReloadCapability !== LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION + || !verifyLocalAttestationProof( + runtime.attestationSecret, + nonce, + target.pid, + target.port, + proofResponse.headers.get(LOCAL_ATTESTATION_PROOF_HEADER), + ) + ) { + return { kind: "unavailable", reason: "attestation" }; + } + + const currentRuntime = readRuntime(target.pid); + if ( + !currentRuntime?.attestationSecret + || currentRuntime.pid !== runtime.pid + || currentRuntime.port !== runtime.port + || currentRuntime.hostname !== runtime.hostname + || currentRuntime.attestationSecret !== runtime.attestationSecret + ) { + return { kind: "unavailable", reason: "runtime-mismatch" }; + } + + const expiresAt = (deps.now ?? Date.now)() + LOCAL_PROVIDER_RELOAD_CAPABILITY_TTL_MS; + const capability = createLocalProviderReloadCapability( + runtime.attestationSecret, + nonce, + LOCAL_PROVIDER_RELOAD_METHOD, + LOCAL_PROVIDER_RELOAD_PATH, + name, + target.pid, + target.port, + expiresAt, + ); + if (!capability) return { kind: "unavailable", reason: "capability" }; + + try { + const response = await fetchImpl(`${baseUrl}${LOCAL_PROVIDER_RELOAD_PATH}`, { + method: LOCAL_PROVIDER_RELOAD_METHOD, + headers: { + [LOCAL_PROVIDER_RELOAD_EXPECTED_PID_HEADER]: String(target.pid), + [LOCAL_PROVIDER_RELOAD_NONCE_HEADER]: nonce, + [LOCAL_PROVIDER_RELOAD_EXPIRES_AT_HEADER]: String(expiresAt), + [LOCAL_PROVIDER_RELOAD_NAME_HEADER]: name, + [LOCAL_PROVIDER_RELOAD_CAPABILITY_HEADER]: capability, + }, + signal: AbortSignal.timeout(timeoutMs), + }); + return response.ok + ? { kind: "reloaded" } + : { kind: "unavailable", reason: "rejected" }; + } catch { + return { kind: "unavailable", reason: "transport" }; + } +} diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index 87280744e7..83c59d8d06 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -29,6 +29,16 @@ import { parseExpectedSystemRestartPid, verifySystemRestartCapability, } from "../lib/system-restart-contract"; +import { + LOCAL_PROVIDER_RELOAD_CAPABILITY_HEADER, + LOCAL_PROVIDER_RELOAD_EXPECTED_PID_HEADER, + LOCAL_PROVIDER_RELOAD_EXPIRES_AT_HEADER, + LOCAL_PROVIDER_RELOAD_NAME_HEADER, + LOCAL_PROVIDER_RELOAD_NONCE_HEADER, + LOCAL_PROVIDER_RELOAD_PATH, + parseExpectedLocalProviderReloadPid, + verifyLocalProviderReloadCapability, +} from "../lib/local-provider-reload-contract"; import { forgetEphemeralSecretPath, forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; import type { OcxConfig } from "../types"; import { @@ -45,6 +55,9 @@ const GUI_SESSION_LIMIT = 128; const LOCAL_READ_REPLAY_LIMIT = 256; const consumedLocalReadCapabilities = new Map(); const admittedLocalReadRequests = new WeakSet(); +const LOCAL_PROVIDER_RELOAD_REPLAY_LIMIT = 256; +const consumedLocalProviderReloadCapabilities = new Map(); +const admittedLocalProviderReloadRequests = new WeakSet(); interface GuiSessionRecord { csrfToken: string; @@ -266,12 +279,13 @@ export function issueGuiSession( * rather than off request headers, which the token holder can forge freely. * The capability principals are process-scoped HMACs bound to the current process * PID and listening port. Local reads are accepted only for two exact GET paths; - * restart remains a separate wire contract for its exact POST. + * restart and provider reload remain separate wire contracts for their exact POSTs. */ export type ManagementPrincipal = | "admin-token" | "gui-session" | "local-read-capability" + | "local-provider-reload-capability" | "system-restart-capability"; export interface LocalManagementAuthContext { @@ -354,6 +368,54 @@ function hasLocalReadCapability( return true; } +function hasLocalProviderReloadCapability( + req: Request, + local: LocalManagementAuthContext | undefined, +): boolean { + if (admittedLocalProviderReloadRequests.has(req)) return true; + if (!local || req.method !== "POST") return false; + let url: URL; + try { + url = new URL(req.url); + } catch { + return false; + } + if (url.pathname !== LOCAL_PROVIDER_RELOAD_PATH || url.search !== "") return false; + const contentLength = req.headers.get("content-length"); + if (contentLength !== "0" || req.headers.has("transfer-encoding")) return false; + const expectedPid = parseExpectedLocalProviderReloadPid( + req.headers.get(LOCAL_PROVIDER_RELOAD_EXPECTED_PID_HEADER), + ); + if (expectedPid.kind !== "present" || expectedPid.pid !== local.pid) return false; + const expiresAtRaw = req.headers.get(LOCAL_PROVIDER_RELOAD_EXPIRES_AT_HEADER); + if (!expiresAtRaw || !/^[1-9]\d*$/.test(expiresAtRaw)) return false; + const expiresAt = Number(expiresAtRaw); + if (!Number.isSafeInteger(expiresAt)) return false; + const name = req.headers.get(LOCAL_PROVIDER_RELOAD_NAME_HEADER); + const capability = req.headers.get(LOCAL_PROVIDER_RELOAD_CAPABILITY_HEADER); + const now = Date.now(); + if (!verifyLocalProviderReloadCapability( + local.attestationSecret, + req.headers.get(LOCAL_PROVIDER_RELOAD_NONCE_HEADER), + req.method, + url.pathname, + name, + local.pid, + local.port, + expiresAt, + capability, + now, + )) return false; + for (const [consumed, retainedUntil] of consumedLocalProviderReloadCapabilities) { + if (retainedUntil <= now) consumedLocalProviderReloadCapabilities.delete(consumed); + } + if (!capability || consumedLocalProviderReloadCapabilities.has(capability)) return false; + if (consumedLocalProviderReloadCapabilities.size >= LOCAL_PROVIDER_RELOAD_REPLAY_LIMIT) return false; + consumedLocalProviderReloadCapabilities.set(capability, expiresAt); + admittedLocalProviderReloadRequests.add(req); + return true; +} + /** * The principal for a request that already passed `requireManagementAuth`. Kept as a * separate resolution (rather than a changed return type) so every existing caller @@ -368,6 +430,7 @@ export function managementPrincipal( local?: LocalManagementAuthContext, ): ManagementPrincipal | null { if (hasSystemRestartCapability(req, local)) return "system-restart-capability"; + if (hasLocalProviderReloadCapability(req, local)) return "local-provider-reload-capability"; if (hasLocalReadCapability(req, local)) return "local-read-capability"; if (!state.available) return null; const actual = req.headers.get("x-opencodex-api-key")?.trim() @@ -386,6 +449,7 @@ export function requireManagementAuth( local?: LocalManagementAuthContext, ): Response | null { if (hasSystemRestartCapability(req, local)) return null; + if (hasLocalProviderReloadCapability(req, local)) return null; if (hasLocalReadCapability(req, local)) return null; if (!state.available) { return Response.json({ diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 12d002ddc8..203cc6a584 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { clearGatherRoutedModelsInflight } from "../../codex/catalog/provider-fetch"; import { DEFAULT_SUBAGENT_MODELS, + adoptPersistedProviderIntoLiveConfig, codexAutoStartEnabled, hasOwnProvider, isValidProviderName, @@ -12,6 +14,7 @@ import { normalizeNonBlankStringArray, providerBaseUrlConfigError, providerHeadersConfigError, + readConfigAdmissionSnapshot, saveConfigPreservingClaudeCode, withConfigMutationLockSync, } from "../../config"; @@ -39,12 +42,13 @@ import { resolveProviderModelDiscovery, } from "../../providers/model-discovery"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; -import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; +import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; +import { clearKeyCooldowns } from "../../providers/key-failover"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; -import { getProviderDiscoveryStatus } from "../../codex/model-cache"; +import { clearModelCache, getProviderDiscoveryStatus } from "../../codex/model-cache"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { resolveCodexHomeDir } from "../../codex/home"; import { readUsageEntries } from "../../usage/log"; @@ -68,6 +72,11 @@ import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerS import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; import { applySystemEnvToggle } from "../system-env"; +import { + LOCAL_PROVIDER_RELOAD_NAME_HEADER, + LOCAL_PROVIDER_RELOAD_PATH, +} from "../../lib/local-provider-reload-contract"; +import { refreshUserCostOverlays } from "../../usage/user-cost-overlays"; import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; @@ -288,7 +297,7 @@ function applyProviderPatchFields( } export async function handleProviderRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config, deps, principal, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; if (url.pathname === "/api/provider-quotas" && req.method === "GET") { const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true"; @@ -315,6 +324,78 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { + const current = readConfigAdmissionSnapshot(); + if ( + current.kind !== "read" + || current.diagnostics.source !== "file" + || current.diagnostics.error !== null + || current.contentSha256 !== admitted.contentSha256 + ) { + sourceChanged = true; + return; + } + currentDiskConfig = current.diagnostics.config; + adoptPersistedProviderIntoLiveConfig( + config, + name, + current.diagnostics.config.providers[name]!, + current.diagnostics.config, + ); + }); + if (sourceChanged || currentDiskConfig === null) { + return jsonResponse({ error: "provider reload source changed" }, 409); + } + reconcileLiveStateStores(); + // The complete disk snapshot owns display overlays, including providers that this + // live routing instance deliberately does not adopt. + refreshUserCostOverlays(currentDiskConfig); + clearGatherRoutedModelsInflight(); + (deps.clearProviderQuotaCache ?? clearProviderQuotaCache)(); + clearAccountQuotaCache(name); + clearKeyCooldowns(name); + clearModelCache(name); + if (name === "openai") (deps.clearThreadAccountMap ?? clearThreadAccountMap)(); + const catalogRefresh = await convergeCodexCatalog(); + return jsonResponse({ success: true, name, catalogRefresh }); + } + // Add (or overwrite) a single provider. Merges into the live in-memory config and // persists — existing providers' real keys are never round-tripped (unlike PUT /api/config, // which would re-save the masked keys from GET). Live routing picks it up immediately. diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index 48c89fc97a..c430018286 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -20,6 +20,7 @@ export interface HealthzIdentity { pid?: unknown; port?: unknown; restartCapability?: unknown; + providerReloadCapability?: unknown; } export interface LivenessIo { diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index a9a9239705..90729a12d7 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -37,6 +37,16 @@ capability, and an unexpected management response so a reachable `401` cannot be their detailed CLI health remains unavailable until restarted with an attested runtime record and capability-aware server. +OAuth and API-key login use the same process-bound pattern for live provider +convergence without transporting provider credentials. After the CLI durably saves +`config.json`, it challenges the exact runtime listener and sends one bodyless +`POST /api/providers/reload` capability bound to the provider name, method, path, +nonce, PID, port, and short expiry. The server consumes it once, re-reads that named +provider from the protected disk config, and updates only live state; the request +contains no provider object, API key, OAuth value, custom header, reusable management +credential, or config digest. Both the proof and reload request use the direct local +transport so environment HTTP proxies cannot observe or fabricate the exchange. + [Decision Log] - 목적과 의도: Keep a lower-privileged local process from collecting the management bearer by impersonating `/healthz` on an unused port. - 기존 구현 및 제약 조건: Liveness must remain public and backward-compatible, but its service string and reported PID are assertions made by the listener itself. diff --git a/tests/config-user-edits.test.ts b/tests/config-user-edits.test.ts index f9e703f197..d64b566572 100644 --- a/tests/config-user-edits.test.ts +++ b/tests/config-user-edits.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { armClaudeCodeBaseline, + adoptPersistedProviderIntoLiveConfig, getConfigPath, getDefaultConfig, loadConfig, @@ -114,6 +115,33 @@ test("guarded binding saves project legacy ownership back onto the live config", }); }); +test("a persisted provider adopted into live state rebases only that provider", () => { + const live = loadConfig(); + armClaudeCodeBaseline(live); + const adopted = { + ...live.providers.test!, + apiKey: "adopted-key", + note: "adopted", + }; + adoptPersistedProviderIntoLiveConfig(live, "test", adopted, { + ...live, + providers: { ...live.providers, test: adopted }, + }); + expect(live.providers.test).toEqual(adopted); + + writeDiskConfig({ + providers: { + ...live.providers, + test: { ...adopted, note: "newer-disk-edit" }, + }, + }); + live.port = 10101; + saveConfigPreservingClaudeCode(live); + + expect((diskConfig().providers as Record).test?.note) + .toBe("newer-disk-edit"); +}); + test("field-scoped persisted mutations use the final disk snapshot for legacy ownership", () => { writePreVersionCustomConfig(); const outcome = mutatePersistedConfig(config => { diff --git a/tests/key-login-live-update.test.ts b/tests/key-login-live-update.test.ts index 6728e952c4..c2b6765679 100644 --- a/tests/key-login-live-update.test.ts +++ b/tests/key-login-live-update.test.ts @@ -2,10 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { loadConfig, saveConfig } from "../src/config"; +import { loadConfig, saveConfig, writePid, writeRuntimePort } from "../src/config"; import { commitKeyLoginProvider, providerConfigFromKeyLoginProvider } from "../src/oauth/login-cli"; import { KEY_LOGIN_PROVIDERS } from "../src/oauth/key-providers"; import { startServer } from "../src/server"; +import { createLocalAttestationSecret } from "../src/lib/local-management-attestation"; import type { OcxConfig } from "../src/types"; import { refreshUserCostOverlays } from "../src/usage/user-cost-overlays"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; @@ -57,9 +58,17 @@ afterEach(() => { describe("CLI key-login live-update overlay preservation", () => { test("notify after key login pushes the merged row and keeps modelCosts on live and disk", async () => { - const server = startServer(0); + const localAttestationSecret = createLocalAttestationSecret(); + const server = startServer(0, { localAttestationSecret }); try { const port = server.port!; + writeRuntimePort({ + pid: process.pid, + port, + hostname: "127.0.0.1", + attestationSecret: localAttestationSecret, + }); + writePid(process.pid); const boot = loadConfig(); boot.port = port; saveConfig(boot); @@ -77,16 +86,13 @@ describe("CLI key-login live-update overlay preservation", () => { const merged = await commitKeyLoginProvider(config, "umans", replacement); expect(merged.modelCosts).toEqual(edited.providers.umans!.modelCosts); - // The proxy's POST /api/providers handler saves its config; it must keep - // the overlay (the merged row was notified), not strip it and undo the - // just-written disk state. + // Reload treats disk as authoritative and never re-saves it. const disk = JSON.parse(readFileSync(join(testDir, "config.json"), "utf-8")) as OcxConfig; expect(disk.providers.umans!.modelCosts).toEqual(edited.providers.umans!.modelCosts); expect(disk.providers.umans!.apiKey).toBe("sk-rotated"); // The running proxy must also carry the overlay in its live config: - // notifyRunningProxy posted the merged row to POST /api/providers, so a - // silent early return or failed POST would leave the in-memory DTO stale + // A silent early return or failed reload would leave the in-memory DTO stale // even though disk is correct. const live = (await fetch(new URL("/api/config", server.url)).then(r => r.json())) as { providers: Record }>; diff --git a/tests/local-management-direct-transport.test.ts b/tests/local-management-direct-transport.test.ts index d5bff9e8cb..3efb344414 100644 --- a/tests/local-management-direct-transport.test.ts +++ b/tests/local-management-direct-transport.test.ts @@ -31,6 +31,30 @@ async function close(server: Server): Promise { } describe("local management direct transport", () => { + test("sends a bodyless POST directly with explicit zero content length", async () => { + let observed: { method: string; contentLength: string | null; body: string } | null = null; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + observed = { + method: request.method, + contentLength: request.headers.get("content-length"), + body: await request.text(), + }; + return Response.json({ ok: true }); + }, + }); + try { + const response = await directLocalHttpFetch(`http://127.0.0.1:${server.port}/api/providers/reload`, { + method: "POST", + }); + expect(response.status).toBe(200); + expect(observed).toEqual({ method: "POST", contentLength: "0", body: "" }); + } finally { + await server.stop(true); + } + }); test("preserves an AbortError for an already-cancelled request", async () => { const controller = new AbortController(); controller.abort(); diff --git a/tests/local-provider-reload-client.test.ts b/tests/local-provider-reload-client.test.ts new file mode 100644 index 0000000000..192e987b21 --- /dev/null +++ b/tests/local-provider-reload-client.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationProof, +} from "../src/lib/local-management-attestation"; +import { + LOCAL_PROVIDER_RELOAD_CAPABILITY_HEADER, + LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION, + LOCAL_PROVIDER_RELOAD_NAME_HEADER, + LOCAL_PROVIDER_RELOAD_PATH, + verifyLocalProviderReloadCapability, +} from "../src/lib/local-provider-reload-contract"; +import { requestBoundLocalProviderReload } from "../src/server/local-provider-reload-client"; +import type { LiveProxy } from "../src/server/proxy-liveness"; + +const secret = "A".repeat(43); +const nonce = "B".repeat(43); +const target: LiveProxy = { + pid: 4242, + port: 10100, + hostname: "127.0.0.1", + source: "runtime", +}; + +function proofResponse(init?: RequestInit): Response { + const challenge = new Headers(init?.headers).get(LOCAL_ATTESTATION_CHALLENGE_HEADER)!; + return Response.json({ + service: "opencodex", + status: "ok", + version: "test", + uptime: 1, + pid: target.pid, + port: target.port, + providerReloadCapability: LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION, + }, { + headers: { + [LOCAL_ATTESTATION_PROOF_HEADER]: createLocalAttestationProof( + secret, + challenge, + target.pid!, + target.port, + )!, + }, + }); +} + +describe("local provider reload client", () => { + test("never posts when the target lacks process-bound runtime identity", async () => { + let calls = 0; + const result = await requestBoundLocalProviderReload( + { ...target, source: "config" }, + "xai", + { fetchImpl: async () => { calls += 1; return new Response(); } }, + ); + expect(result).toEqual({ kind: "unavailable", reason: "unattested-target" }); + expect(calls).toBe(0); + }); + + test("requires listener proof before the reload POST", async () => { + const requests: string[] = []; + const result = await requestBoundLocalProviderReload(target, "xai", { + readRuntime: () => ({ ...target, attestationSecret: secret }), + createNonce: () => nonce, + fetchImpl: async input => { + requests.push(String(input)); + return Response.json({ + service: "opencodex", + pid: target.pid, + port: target.port, + providerReloadCapability: LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION, + }); + }, + }); + expect(result).toEqual({ kind: "unavailable", reason: "attestation" }); + expect(requests).toEqual(["http://127.0.0.1:10100/healthz"]); + }); + + test("sends only a name-bound bodyless one-shot capability after proof", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const now = 1_800_000_000_000; + const result = await requestBoundLocalProviderReload(target, "xai", { + readRuntime: () => ({ ...target, attestationSecret: secret }), + createNonce: () => nonce, + now: () => now, + fetchImpl: async (input, init) => { + requests.push({ url: String(input), init }); + return requests.length === 1 ? proofResponse(init) : Response.json({ success: true }); + }, + }); + + expect(result).toEqual({ kind: "reloaded" }); + expect(requests).toHaveLength(2); + expect(requests[1]!.url).toBe(`http://127.0.0.1:10100${LOCAL_PROVIDER_RELOAD_PATH}`); + expect(requests[1]!.init?.method).toBe("POST"); + expect(requests[1]!.init?.body).toBeUndefined(); + const headers = new Headers(requests[1]!.init?.headers); + expect(headers.get(LOCAL_PROVIDER_RELOAD_NAME_HEADER)).toBe("xai"); + expect(headers.has("authorization")).toBe(false); + expect(headers.has("x-opencodex-api-key")).toBe(false); + expect(verifyLocalProviderReloadCapability( + secret, + nonce, + "POST", + LOCAL_PROVIDER_RELOAD_PATH, + "xai", + target.pid!, + target.port, + Number(headers.get("x-opencodex-provider-reload-expires-at")), + headers.get(LOCAL_PROVIDER_RELOAD_CAPABILITY_HEADER), + now, + )).toBe(true); + expect(JSON.stringify(requests[1])).not.toContain("apiKey"); + expect(JSON.stringify(requests[1])).not.toContain("admin-secret"); + }); + + test("stops when the protected runtime record changes after proof", async () => { + let reads = 0; + let calls = 0; + const result = await requestBoundLocalProviderReload(target, "xai", { + readRuntime: () => { + reads += 1; + return reads === 1 + ? { ...target, attestationSecret: secret } + : { ...target, port: target.port + 1, attestationSecret: secret }; + }, + createNonce: () => nonce, + fetchImpl: async (_input, init) => { + calls += 1; + return proofResponse(init); + }, + }); + expect(result).toEqual({ kind: "unavailable", reason: "runtime-mismatch" }); + expect(calls).toBe(1); + }); +}); diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 8c7746135c..7e1ee8c553 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -36,6 +36,7 @@ import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; import * as destinationPolicy from "../src/lib/destination-policy"; import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; +import { LOCAL_PROVIDER_RELOAD_NAME_HEADER, LOCAL_PROVIDER_RELOAD_PATH } from "../src/lib/local-provider-reload-contract"; // Full-suite Windows load: startServer + multi-step provider PATCH/GET flows exceed the // default 5s per-test budget (same flake class as 810fa115 / claude-management-api). @@ -125,6 +126,120 @@ afterEach(() => { }); describe("provider management validation", () => { + test("provider reload adopts only the validated disk row without rewriting config", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + apiKey: "old-live-key", + }, + stable: { + adapter: "openai-chat", + baseUrl: "https://stable.example.test/v1", + apiKey: "stable-live-key", + }, + }, + }; + saveConfig(liveConfig); + const diskConfig = structuredClone(liveConfig); + diskConfig.providers.xai = { + ...diskConfig.providers.xai!, + apiKey: "new-disk-key", + headers: { "x-operator-header": "operator-owned" }, + }; + saveConfig(diskConfig); + const diskBefore = readFileSync(join(TEST_DIR, "config.json")); + const stableBefore = structuredClone(liveConfig.providers.stable); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") + .mockResolvedValue(null); + try { + const request = new Request(`http://127.0.0.1${LOCAL_PROVIDER_RELOAD_PATH}`, { + method: "POST", + headers: { [LOCAL_PROVIDER_RELOAD_NAME_HEADER]: "xai" }, + }); + const response = await handleManagementAPI( + request, + new URL(request.url), + liveConfig, + { createManagementConvergeCodex: catalogConvergenceFactory() }, + "local-provider-reload-capability", + ); + expect(response?.status).toBe(200); + expect(liveConfig.providers.xai).toEqual(diskConfig.providers.xai); + expect(liveConfig.providers.stable).toEqual(stableBefore); + expect(readFileSync(join(TEST_DIR, "config.json"))).toEqual(diskBefore); + } finally { + resolvedError.mockRestore(); + } + }); + + test("provider reload rejects an untrusted principal and a disk rewrite during DNS validation", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + apiKey: "old-live-key", + }, + }, + }; + saveConfig(liveConfig); + const diskConfig = structuredClone(liveConfig); + diskConfig.providers.xai = { ...diskConfig.providers.xai!, apiKey: "first-disk-key" }; + saveConfig(diskConfig); + + const untrusted = new Request(`http://127.0.0.1${LOCAL_PROVIDER_RELOAD_PATH}`, { + method: "POST", + headers: { [LOCAL_PROVIDER_RELOAD_NAME_HEADER]: "xai" }, + }); + expect((await handleManagementAPI( + untrusted, + new URL(untrusted.url), + liveConfig, + { createManagementConvergeCodex: catalogConvergenceFactory() }, + "admin-token", + ))?.status).toBe(403); + + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") + .mockImplementation(async () => { + const changed = loadConfig(); + changed.providers.xai = { ...changed.providers.xai!, apiKey: "second-disk-key" }; + saveConfig(changed); + return null; + }); + try { + const request = new Request(`http://127.0.0.1${LOCAL_PROVIDER_RELOAD_PATH}`, { + method: "POST", + headers: { [LOCAL_PROVIDER_RELOAD_NAME_HEADER]: "xai" }, + }); + const response = await handleManagementAPI( + request, + new URL(request.url), + liveConfig, + { createManagementConvergeCodex: catalogConvergenceFactory() }, + "local-provider-reload-capability", + ); + expect(response?.status).toBe(409); + expect(liveConfig.providers.xai?.apiKey).toBe("old-live-key"); + expect(loadConfig().providers.xai?.apiKey).toBe("second-disk-key"); + } finally { + resolvedError.mockRestore(); + } + }); + test("validates and exposes structured-output model opt-outs", () => { const provider = { adapter: "openai-chat", diff --git a/tests/oauth-login-cli-live-update.test.ts b/tests/oauth-login-cli-live-update.test.ts index 06e993e524..47f1b54bbd 100644 --- a/tests/oauth-login-cli-live-update.test.ts +++ b/tests/oauth-login-cli-live-update.test.ts @@ -3,10 +3,15 @@ import { managementFetch as fetch } from "./helpers/management-auth"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { loadConfig, saveConfig } from "../src/config"; +import { loadConfig, saveConfig, writePid, writeRuntimePort } from "../src/config"; import { upsertOAuthProvider } from "../src/oauth"; -import { notifyRunningProxyAfterOAuthLogin } from "../src/oauth/login-cli"; +import { + commitKeyLoginProvider, + notifyRunningProxy, + notifyRunningProxyAfterOAuthLogin, +} from "../src/oauth/login-cli"; import { startServer } from "../src/server"; +import { createLocalAttestationSecret } from "../src/lib/local-management-attestation"; import type { OcxConfig } from "../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; @@ -53,10 +58,74 @@ afterEach(() => { }); describe("CLI OAuth live-update credential preservation", () => { + test("does not post provider credentials when a legacy health listener has no verified pid", async () => { + const receivedPaths: string[] = []; + let healthProbeCount = 0; + const listener = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/healthz") { + healthProbeCount += 1; + return Response.json({ status: "ok", version: "2.6.16", uptime: 5 }); + } + receivedPaths.push(url.pathname); + return Response.json({ ok: true }); + }, + }); + try { + saveConfig(keyModeXaiConfig(listener.port)); + + await notifyRunningProxy("xai"); + + expect(healthProbeCount).toBeGreaterThan(0); + expect(receivedPaths).toEqual([]); + } finally { + await listener.stop(true); + } + }, 15_000); + + test("does not probe or post for a provider outside the login-owned allowlist", async () => { + const receivedPaths: string[] = []; + const listener = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + receivedPaths.push(new URL(request.url).pathname); + return Response.json({ status: "ok" }); + }, + }); + try { + const custom = keyModeXaiConfig(listener.port); + custom.defaultProvider = "custom"; + custom.providers.custom = { + adapter: "openai-chat", + baseUrl: "https://custom.example.test/v1", + apiKey: "custom-sentinel-key", + }; + saveConfig(custom); + + await notifyRunningProxy("custom"); + + expect(receivedPaths).toEqual([]); + } finally { + await listener.stop(true); + } + }); + test("notify after OAuth login keeps key billing on live and disk configs", async () => { - const server = startServer(0); + const localAttestationSecret = createLocalAttestationSecret(); + const server = startServer(0, { localAttestationSecret }); try { const port = server.port!; + writeRuntimePort({ + pid: process.pid, + port, + hostname: "127.0.0.1", + attestationSecret: localAttestationSecret, + }); + writePid(process.pid); const boot = loadConfig(); boot.port = port; saveConfig(boot); @@ -68,6 +137,7 @@ describe("CLI OAuth live-update credential preservation", () => { expect(afterLogin.providers.xai!.authMode).toBe("key"); expect(afterLogin.providers.xai!.apiKey).toBe("live-update-sentinel-key"); + const beforeNotify = readFileSync(join(testDir, "config.json")); await notifyRunningProxyAfterOAuthLogin("xai"); const listed = await fetch(new URL("/api/providers", server.url)).then(r => r.json()) as Array<{ @@ -90,8 +160,93 @@ describe("CLI OAuth live-update credential preservation", () => { expect(disk.providers.xai!.authMode).toBe("key"); expect(disk.providers.xai!.apiKey).toBe("live-update-sentinel-key"); expect(disk.providers.xai!.apiKeyPool?.some(entry => entry.key === "live-update-sentinel-key")).toBe(true); + expect(readFileSync(join(testDir, "config.json"))).toEqual(beforeNotify); } finally { await server.stop(true); } }, 15_000); }); + +/** + * Regression: the reload outcome used to be discarded, so a CLI talking to a proxy that + * predates attested reload printed unconditional success while the running process kept + * routing with the previous credential. The credential does reach disk — that part was + * always fine — but the operator had no way to learn a restart was required. + */ +describe("live reload outcome is reported to the caller", () => { + test("an unattested running proxy yields a diagnosable reason, not silence", async () => { + let healthProbeCount = 0; + const listener = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/healthz") { + healthProbeCount += 1; + // A pre-update proxy: healthy, but it advertises no attestation capability. + return Response.json({ status: "ok", version: "2.6.16", uptime: 5 }); + } + return Response.json({ ok: true }); + }, + }); + try { + saveConfig(keyModeXaiConfig(listener.port)); + + const result = await notifyRunningProxy("xai"); + + expect(healthProbeCount).toBeGreaterThan(0); + // The caller can tell "could not reload" apart from "nothing to reload". + expect(result).not.toBeNull(); + expect(result!.kind).toBe("unavailable"); + } finally { + await listener.stop(true); + } + }, 15_000); + + test("no running proxy is null, which must not warn", async () => { + // Nothing is listening, so there is nothing to reload and nothing to warn about. + const result = await notifyRunningProxy("xai"); + expect(result).toBeNull(); + }, 15_000); + + test("a provider outside the live-reload allowlist is null rather than a failure", async () => { + const custom = keyModeXaiConfig(); + custom.providers.custom = { + adapter: "openai-chat", + baseUrl: "https://custom.example.test/v1", + apiKey: "custom-sentinel-key", + }; + saveConfig(custom); + + expect(await notifyRunningProxy("custom")).toBeNull(); + }); + + /** + * `onLiveReload?.(await notifyRunningProxy(name))` reads as "reload, then hand the result + * to an optional callback", but optional-call short-circuiting skips the entire argument + * list when the callback is absent — so the reload silently never happens for every caller + * that does not pass one. That regression was caught once; pin it. + */ + test("commit still reloads when no outcome callback is supplied", async () => { + let healthProbeCount = 0; + const listener = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + if (new URL(request.url).pathname === "/healthz") healthProbeCount += 1; + return Response.json({ status: "ok", version: "2.6.16", uptime: 5 }); + }, + }); + try { + saveConfig(keyModeXaiConfig(listener.port)); + const config = loadConfig(); + + // No callback argument on purpose: this is the short-circuit shape. + await commitKeyLoginProvider(config, "xai", config.providers.xai!); + + expect(healthProbeCount).toBeGreaterThan(0); + } finally { + await listener.stop(true); + } + }, 15_000); +}); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 888fbeb4b1..671baa67ec 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -42,6 +42,7 @@ import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isol import { ownedServiceHomeInspection } from "./helpers/owned-service-home-inspection"; import { configuredAdminToken } from "../src/lib/admin-secrets"; import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../src/lib/system-restart-contract"; +import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../src/lib/local-provider-reload-contract"; const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; const previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -689,6 +690,7 @@ describe("server local API auth", () => { expect(Object.keys(healthBody).sort()).toEqual([ "pid", "port", + "providerReloadCapability", "restartCapability", "service", "status", @@ -696,6 +698,7 @@ describe("server local API auth", () => { "version", ]); expect(healthBody.restartCapability).toBe(SYSTEM_RESTART_CAPABILITY_VERSION); + expect(healthBody.providerReloadCapability).toBe(LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION); expect("rss" in healthBody).toBe(false); } finally { await server.stop(true); diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index ac19a50b5d..f4d256edeb 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -46,6 +46,18 @@ import { SYSTEM_RESTART_PATH, createSystemRestartCapability, } from "../src/lib/system-restart-contract"; +import { + LOCAL_PROVIDER_RELOAD_CAPABILITY_HEADER, + LOCAL_PROVIDER_RELOAD_CAPABILITY_TTL_MS, + LOCAL_PROVIDER_RELOAD_EXPECTED_PID_HEADER, + LOCAL_PROVIDER_RELOAD_EXPIRES_AT_HEADER, + LOCAL_PROVIDER_RELOAD_METHOD, + LOCAL_PROVIDER_RELOAD_NAME_HEADER, + LOCAL_PROVIDER_RELOAD_NONCE_HEADER, + LOCAL_PROVIDER_RELOAD_PATH, + createLocalProviderReloadCapability, + verifyLocalProviderReloadCapability, +} from "../src/lib/local-provider-reload-contract"; import { setSystemRestartIoForTests } from "../src/server/management/system-restart"; const previousHome = process.env.OPENCODEX_HOME; @@ -337,6 +349,124 @@ describe("management and data-plane credential separation", () => { } }); + test("a provider-reload capability is one-shot and exact to its operation", () => { + const secret = "A".repeat(43); + const nonce = "J".repeat(43); + const expiresAt = Date.now() + LOCAL_PROVIDER_RELOAD_CAPABILITY_TTL_MS; + const unavailable = { available: false, reason: "injected unavailable state" } as const; + const local = { attestationSecret: secret, pid: process.pid, port: 10100 }; + const headers = { + [LOCAL_PROVIDER_RELOAD_EXPECTED_PID_HEADER]: String(process.pid), + [LOCAL_PROVIDER_RELOAD_NONCE_HEADER]: nonce, + [LOCAL_PROVIDER_RELOAD_EXPIRES_AT_HEADER]: String(expiresAt), + [LOCAL_PROVIDER_RELOAD_NAME_HEADER]: "xai", + "content-length": "0", + [LOCAL_PROVIDER_RELOAD_CAPABILITY_HEADER]: createLocalProviderReloadCapability( + secret, + nonce, + LOCAL_PROVIDER_RELOAD_METHOD, + LOCAL_PROVIDER_RELOAD_PATH, + "xai", + process.pid, + local.port, + expiresAt, + )!, + }; + + const request = new Request(`http://127.0.0.1:${local.port}${LOCAL_PROVIDER_RELOAD_PATH}`, { + method: LOCAL_PROVIDER_RELOAD_METHOD, + headers, + }); + expect(requireManagementAuth(request, unavailable, remoteConfig(), local)).toBeNull(); + expect(managementPrincipal(request, unavailable, remoteConfig(), local)) + .toBe("local-provider-reload-capability"); + + const replay = new Request(request.url, { method: LOCAL_PROVIDER_RELOAD_METHOD, headers }); + expect(requireManagementAuth(replay, unavailable, remoteConfig(), local)?.status).toBe(503); + const wrongName = new Request(request.url, { + method: LOCAL_PROVIDER_RELOAD_METHOD, + headers: { ...headers, [LOCAL_PROVIDER_RELOAD_NAME_HEADER]: "openai" }, + }); + expect(requireManagementAuth(wrongName, unavailable, remoteConfig(), local)?.status).toBe(503); + const query = new Request(`${request.url}?name=xai`, { method: LOCAL_PROVIDER_RELOAD_METHOD, headers }); + expect(requireManagementAuth(query, unavailable, remoteConfig(), local)?.status).toBe(503); + const body = new Request(request.url, { + method: LOCAL_PROVIDER_RELOAD_METHOD, + headers: { ...headers, "content-length": "2" }, + body: "{}", + }); + expect(requireManagementAuth(body, unavailable, remoteConfig(), local)?.status).toBe(503); + }); + + test("provider-reload capability binds method path process endpoint and TTL", () => { + const secret = "A".repeat(43); + const nonce = "K".repeat(43); + const now = 1_800_000_000_000; + const pid = 4242; + const port = 10100; + const name = "xai"; + const validExpiry = now + LOCAL_PROVIDER_RELOAD_CAPABILITY_TTL_MS; + const capability = createLocalProviderReloadCapability( + secret, + nonce, + LOCAL_PROVIDER_RELOAD_METHOD, + LOCAL_PROVIDER_RELOAD_PATH, + name, + pid, + port, + validExpiry, + )!; + const verify = ( + method = LOCAL_PROVIDER_RELOAD_METHOD, + path = LOCAL_PROVIDER_RELOAD_PATH, + selectedName = name, + selectedPid = pid, + selectedPort = port, + expiresAt = validExpiry, + candidate = capability, + ) => verifyLocalProviderReloadCapability( + secret, + nonce, + method, + path, + selectedName, + selectedPid, + selectedPort, + expiresAt, + candidate, + now, + ); + + expect(verify()).toBe(true); + expect(verify("GET")).toBe(false); + expect(verify(LOCAL_PROVIDER_RELOAD_METHOD, "/api/providers")).toBe(false); + expect(verify(LOCAL_PROVIDER_RELOAD_METHOD, LOCAL_PROVIDER_RELOAD_PATH, "openai")).toBe(false); + expect(verify(LOCAL_PROVIDER_RELOAD_METHOD, LOCAL_PROVIDER_RELOAD_PATH, name, pid + 1)).toBe(false); + expect(verify(LOCAL_PROVIDER_RELOAD_METHOD, LOCAL_PROVIDER_RELOAD_PATH, name, pid, port + 1)).toBe(false); + expect(verify(LOCAL_PROVIDER_RELOAD_METHOD, LOCAL_PROVIDER_RELOAD_PATH, name, pid, port, now, capability)).toBe(false); + + const tooLate = now + LOCAL_PROVIDER_RELOAD_CAPABILITY_TTL_MS + 1; + const tooLateCapability = createLocalProviderReloadCapability( + secret, + nonce, + LOCAL_PROVIDER_RELOAD_METHOD, + LOCAL_PROVIDER_RELOAD_PATH, + name, + pid, + port, + tooLate, + )!; + expect(verify( + LOCAL_PROVIDER_RELOAD_METHOD, + LOCAL_PROVIDER_RELOAD_PATH, + name, + pid, + port, + tooLate, + tooLateCapability, + )).toBe(false); + }); + test("management-token temp cleanup forgets successful ACL memos and retains failed removals", () => { const temporary = join(testHome, ".admin-token.tmp"); const previousUsername = process.env.USERNAME;