Skip to content
Closed
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
16 changes: 14 additions & 2 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,19 @@ async function awaitMainAccountPolicyBindingSettled(
if (!settled) throw new CodexMainProfileDrainingError();
}

/** Memory-only ownership check shared by Direct admission and pre-auth hard-lock routing. */
export async function mainAccountHardLockAppliesToCaller(
headers: Headers,
policy: CodexAuthPolicyConfig,
options: Pick<ResolveCodexAuthContextOptions,
"substituteMainCredentialForDirect" | "signal" | "mainAccountPolicyBindingWaitMs"> = {},
): Promise<boolean> {
if (options.substituteMainCredentialForDirect === true) return true;
if (!hasCallerCodexBearer(headers)) return false;
if (isMainAccountHardLockEnabled(policy)) await awaitMainAccountPolicyBindingSettled(options);
return callerMatchesObservedMain(headers);
}

/**
* Resolve the credential for one Codex turn. A main hard-lock reached through unpinned pool
* selection is annotated with the earliest known pool recovery, so the refusal can name a retry
Expand Down Expand Up @@ -1172,8 +1185,7 @@ async function resolveCodexAuthContextUnannotated(
// Trusted substitution still has to claim and validate stored main below.
if (!substituteStoredMain && !hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
if (!substituteStoredMain) {
if (isMainAccountHardLockEnabled(policy)) await awaitMainAccountPolicyBindingSettled(options);
if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy);
if (await mainAccountHardLockAppliesToCaller(headers, policy, options)) assertMainAccountPolicy(policy);
assertCallerOwnedMainPoolNotCooled();
if (reserve) {
const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy });
Expand Down
20 changes: 20 additions & 0 deletions src/server/responses/core-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { OcxProviderConfig, OcxConfig } from "../../types";
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
import type { CodexAuthContext } from "../../codex/auth-context";
import type { RouteResult } from "../../router";
import { routeConcreteModel } from "../../router";
import type { HandleResponsesOptions } from "./core-options";
import {
hasForwardableCodexBearer,
Expand All @@ -24,6 +25,7 @@ import {
CodexAuthContextError,
applyCodexAuthContextToProvider,
stripCodexRuntimeProviderFields,
mainAccountHardLockAppliesToCaller,
} from "../../codex/auth-context";
import { codexAccountSelectionForTurn, tryClaimNativeMainProfileForTurn } from "../lifecycle";
import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup";
Expand Down Expand Up @@ -590,3 +592,21 @@ export async function refreshNativeMainForwardAuth(args: {
}) ?? nativeMainRefreshFailureResponse(error) };
}
}

/** Inspect the original credential domain, before fallback changes the route or drops headers. */
export function mainHardLockFallbackCallerCheck(
req: Request, config: OcxConfig, options: HandleResponsesOptions,
): (sourceModel: string) => Promise<boolean> {
return async sourceModel => {
let sourceRoute: ReturnType<typeof routeConcreteModel>;
try { sourceRoute = routeConcreteModel(config, sourceModel); }
// This preview cannot replace the normal routing phase's existing error response.
catch { return false; }
const headers = codexRouteCredentialDomainHeaders(req, sourceRoute, options, false, config);
const { substituteMainCredential } = codexRouteCredentialOwnership(headers, config, sourceRoute, options);
return mainAccountHardLockAppliesToCaller(headers, options.codexAuthPolicy ?? config, {
substituteMainCredentialForDirect: substituteMainCredential,
signal: options.abortSignal ?? req.signal,
});
};
}
20 changes: 13 additions & 7 deletions src/server/responses/main-hard-lock-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { codexQuotaScopeForModel } from "../../codex/routing/health-store";
import { getEligiblePoolAccounts } from "../../codex/routing/selection";
import { OPENAI_CODEX_PROVIDER_ID, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
import { providerCodexAccountMode } from "../../providers/registry";
export type MainHardLockCallerCheck = (sourceModel: string) => Promise<boolean>;

function isConfiguredComboSelector(config: OcxConfig, modelId: string): boolean {
const comboId = resolveComboId(config, modelId);
Expand Down Expand Up @@ -72,11 +73,12 @@ function rawModelOf(body: unknown): { raw: Record<string, unknown>; model: strin
* - Luna Reserve keeps its own admission policy (`assertReserveAdmission`); its helper
* traffic is not operator chat and must not silently change models.
*/
export function applyMainHardLockFallbackOverride(
export async function applyMainHardLockFallbackOverride(
body: unknown,
config: OcxConfig,
now = Date.now(),
): MainHardLockFallbackOverride | null {
now?: number,
callerCheck?: MainHardLockCallerCheck,
): Promise<MainHardLockFallbackOverride | null> {
const parsed = rawModelOf(body);
if (!parsed) return null;
const fallback = resolveMainAccountHardLockFallback(config);
Expand All @@ -85,14 +87,18 @@ export function applyMainHardLockFallbackOverride(
if (!isBareOpenAiFamilyModel(parsed.model)
|| parsed.model === NATIVE_RESERVE_MODEL
|| isConfiguredComboSelector(config, parsed.model)) return null;
if (getMainAccountHardLockStatus(config, now).state !== "blocked") return null;
const accountMode = providerCodexAccountMode(
OPENAI_CODEX_PROVIDER_ID,
config.providers?.[OPENAI_CODEX_PROVIDER_ID],
);
if (accountMode !== "direct"
&& getEligiblePoolAccounts(config, undefined, now, codexQuotaScopeForModel(parsed.model)).length > 0) {
return null;
if (accountMode === "direct") {
// Direct may forward a different caller's healthy credential. Only stored-main substitution
// or an observed-main match owes this lock. Recheck usage after the startup binding wait.
if (!callerCheck || !await callerCheck(parsed.model)) return null;
if (getMainAccountHardLockStatus(config, now ?? Date.now()).state !== "blocked") return null;
} else {
if (getMainAccountHardLockStatus(config, now ?? Date.now()).state !== "blocked") return null;
if (getEligiblePoolAccounts(config, undefined, now ?? Date.now(), codexQuotaScopeForModel(parsed.model)).length > 0) return null;
}

parsed.raw.model = fallback.model;
Expand Down
12 changes: 11 additions & 1 deletion src/server/responses/request-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import type {
import {
codexRouteCredentialDomainHeaders,
codexRouteCredentialOwnership,
mainHardLockFallbackCallerCheck,
resolveResponsesCodexAuth,
withClaudeNativeSession,
} from "./core-auth";
Expand Down Expand Up @@ -364,7 +365,16 @@ export async function prepareResponsesRequest(
// must run before comboIdFromRawBody reads `model`.
if (!concreteSelection && !options.compactionRoutingOverride && !isCompactionTrigger && !options.internalDecisionCall
&& !shadowCallIntercepted && !shadowCallMatches && !memoryModelApplies && !threadSpawn && inboundWire === "responses") {
options.mainHardLockFallbackOverride = applyMainHardLockFallbackOverride(body, config);
try {
options.mainHardLockFallbackOverride = await applyMainHardLockFallbackOverride(
body, config, undefined, mainHardLockFallbackCallerCheck(req, config, options),
);
} catch (error) {
if (options.abortSignal?.aborted || req.signal.aborted) return clientCancelledResponse();
const mapped = mapCodexAuthContextErrorToResponse(error, { now: Date.now() });
if (mapped) return mapped;
throw error;
}
}
const comboId = !concreteSelection ? comboIdFromRawBody(body, config) : null;
// A decision model may be a plain model or a non-JEV combo. A JEV combo here would recurse
Expand Down
14 changes: 10 additions & 4 deletions structure/codex-account-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,14 @@ protects:

- `getEligiblePoolAccounts` is consulted under the request model's quota scope whenever canonical
`openai` is in Pool mode, so "the Pool can serve it" shares the dispatch's own eligibility and
scoped-cooldown rules. An empty eligible list (or Direct mode, which never consults stored Pool
accounts) is what makes the fallback the request's only way through; the lock excludes the main
account there itself.
scoped-cooldown rules. An empty eligible list permits fallback; the lock excludes the main
account from that list itself.
- Direct does not consult stored Pool accounts. Fallback applies only when the original source
route's credential ownership selects stored-main substitution, or the caller's bearer and
effective workspace match the currently observed main credential. Caller-owned checks wait
boundedly for pending startup policy binding, then recheck identity and the blocked state before
rewriting. Other callers retain their native route; a missing caller bearer keeps its normal
authentication refusal.
- Only bare Codex-native selectors qualify. Provider-qualified rows, combos, account namespaces
(including `nativeAlias` combos), compaction triggers, shadow-intercepted helper calls and policy
routes name their own destination on purpose, and Luna Reserve keeps its own admission. A
Expand All @@ -105,7 +110,8 @@ model — rather than clearing it. Settings projection and writes live beside th
`src/server/management/config-routes.ts`; `null` clears the section. Coverage:
`tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts` and the fallback cases in
`tests/config/settings-main-account-hard-lock.test.ts` plus
`gui/tests/main-account-hard-lock-setting.test.tsx`.
`gui/tests/main-account-hard-lock-setting.test.tsx`. Startup-binding rechecks are covered by
`tests/codex-integration/main-account-policy-binding-wait.test.ts`.

## Stored-account authentication policy

Expand Down
Loading
Loading