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
39 changes: 33 additions & 6 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
import { FORWARD_HEADERS } from "../adapters/openai-responses";
import { captureConfigGeneration } from "../lib/state-store-sweeper";
import { retainedUtf8Bytes } from "../lib/admission";
import { extractAccountId } from "../oauth/chatgpt";

const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512;
const CODEX_APP_AFFINITY_KEY = randomBytes(32);
Expand Down Expand Up @@ -97,7 +98,7 @@ export type CodexAuthContext =
}
| {
// Main Codex account participating in rotation: token injected from ~/.codex/auth.json
// (Option A). Distinct from "main" (passthrough fallback that forwards the client token).
// (Option A). Distinct from "main" (request-owned passthrough or Direct mode).
kind: "main-pool";
accountId: string;
writerGeneration: number;
Expand Down Expand Up @@ -336,6 +337,8 @@ export interface ResolveCodexAuthContextOptions {
resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements;
/** Direct requests admitted with a proxy bearer substitute the stored native-main credential. */
substituteMainCredentialForDirect?: boolean;
/** A validated native Codex bearer may serve this request without entering Pool state. */
requestScopedMainCredential?: boolean;
/** Test seam for a Direct request's own forwarded ChatGPT credential. */
isDirectCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise<boolean>;
}
Expand All @@ -353,13 +356,13 @@ export async function resolveCodexAuthContext(
options: ResolveCodexAuthContextOptions = {},
): Promise<CodexAuthContext> {
const writerGeneration = captureConfigGeneration();
const requestScopedMainCredential = options.requestScopedMainCredential === true
&& hasCallerCodexBearer(headers);
const fixedAccountId = options.accountId;
if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
throw new Error("Codex auth context cannot select and exclude an account simultaneously");
}
// An explicit namespace binding is stronger than the provider's default mode. It must use the
// selected stored credential even while the canonical OpenAI provider is globally Direct.
if (mode === "direct" && fixedAccountId === undefined) {
const resolveCallerOwnedMainContext = async (): Promise<CodexAuthContext> => {
if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
const substituteStoredMain = options.substituteMainCredentialForDirect === true;
if (!substituteStoredMain) {
Expand Down Expand Up @@ -405,8 +408,21 @@ export async function resolveCodexAuthContext(
// the enclosing turn lease until the request or transferred stream settles.
directSelectionAdmission.release();
}
};
// An explicit namespace binding is stronger than the provider's default mode. It must use the
// selected stored credential even while the canonical OpenAI provider is globally Direct.
// A request-owned bearer is deliberately not represented as `main-pool`: Pool account ids own
// durable health, quota, and affinity state, while this credential exists for one request only.
if ((mode === "direct" && fixedAccountId === undefined)
|| (requestScopedMainCredential && fixedAccountId === MAIN_CODEX_ACCOUNT_ID)) {
return resolveCallerOwnedMainContext();
}
const affinityKey = fixedAccountId === undefined ? codexPoolAffinityKey(headers) : undefined;
// A caller bearer can still accompany a request that selects a configured Pool account. Do not
// let that request read, delete, or create a file-main affinity binding while deciding whether a
// stored account is available; only the stored credential selected below may own Pool state.
const affinityKey = fixedAccountId === undefined && !requestScopedMainCredential
? codexPoolAffinityKey(headers)
: undefined;
// Retained startup recovery makes the physical main identity ineligible. Routing
// can still preserve service by selecting a healthy configured pool account.
const nativeMainTrafficBlocked = isNativeMainTrafficBlocked();
Expand All @@ -433,7 +449,9 @@ export async function resolveCodexAuthContext(
// Temporary switch drain keeps the candidate until the atomic claim rejects
// it. Retained recovery makes main wholly ineligible so pool routing continues.
nativeMainSelectionOnly,
isMainAccountTokenLive: options.isMainAccountTokenLive,
isMainAccountTokenLive: requestScopedMainCredential
? () => false
: options.isMainAccountTokenLive,
modelEligibleAccountIds,
};
// A pre-drain selector reserves the native identity while reconciliation and
Expand Down Expand Up @@ -466,6 +484,9 @@ export async function resolveCodexAuthContext(
if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
const selected = resolution.status === "selected" ? resolution.accountId : null;
if (!selected) {
if (requestScopedMainCredential && fixedAccountId === undefined && !options.excludeAccountId) {
return await resolveCallerOwnedMainContext();
}
if (fixedAccountId !== undefined) {
throw new CodexPoolAuthenticationError(
modelEligibleAccountIds && !modelEligibleAccountIds.has(fixedAccountId)
Expand Down Expand Up @@ -672,6 +693,12 @@ export function materializeCodexUpstreamAuth(
selected.set("chatgpt-account-id", ctx.chatgptAccountId);
return selected;
}
if (ctx.kind === "main" && options.substituteMainCredential !== true
&& !selected.has("chatgpt-account-id")) {
const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
const accountId = bearer ? extractAccountId(undefined, bearer) : undefined;
if (accountId) selected.set("chatgpt-account-id", accountId);
}
if (ctx.kind === "main" && options.substituteMainCredential === true) {
const stored = getMainAccountToken();
// Fail BEFORE any upstream I/O. Falling through here would send the admission secret.
Expand Down
9 changes: 9 additions & 0 deletions src/server/auth-cors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { timingSafeEqual } from "node:crypto";
import { extractAccountId } from "../oauth/chatgpt";
import { formatErrorResponse } from "../bridge";
import {
codexAutoStartEnabled,
Expand Down Expand Up @@ -431,6 +432,14 @@ export function validateForwardAdmissionCredential(headers: Headers, config: Ocx
if (bearer && isProxyAdmissionSecret(bearer, config)) throw new ForwardAdmissionCredentialError();
}

/** Whether Authorization carries a caller-owned native Codex credential safe to forward. */
export function hasForwardableCodexBearer(headers: Headers, config: OcxConfig): boolean {
const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
const accountId = headers.get("chatgpt-account-id")?.trim()
|| (bearer ? extractAccountId(undefined, bearer) : undefined);
return !!bearer && !!accountId && !isProxyAdmissionSecret(bearer, config);
}

/**
* Resolving form of `hasValidApiAuth`: identical header precedence, identical
* decision, but it names the admission instead of collapsing it to a boolean.
Expand Down
11 changes: 10 additions & 1 deletion src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ import {
upstreamHostHealthKey,
type UpstreamHostAdmissionLease,
} from "../../codex/upstream-host-health";
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
import {
ForwardAdmissionCredentialError,
hasForwardableCodexBearer,
validateForwardAdmissionCredential,
} from "../auth-cors";
import type { DataPlaneAdmission } from "../auth-cors";
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers";
Expand Down Expand Up @@ -165,6 +169,7 @@ async function resolveAlternateCompactContext(args: {
const authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, {
...(selectedModelId ? { modelId: selectedModelId } : {}),
excludeAccountId,
requestScopedMainCredential: hasForwardableCodexBearer(req.headers, config),
beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
});
if (!authCtx.accountId || authCtx.accountId === excludeAccountId) return null;
Expand Down Expand Up @@ -326,6 +331,9 @@ export async function handleResponsesCompact(
// consume that credential. See the longer note in core.ts resolveResponsesCodexAuth.
const substituteMainCredential = admission?.source === "bearer"
&& route.codexAccountMode !== undefined;
const requestScopedMainCredential = route.codexAccountMode !== undefined
&& !substituteMainCredential
&& hasForwardableCodexBearer(req.headers, config);
if (route.codexAccountMode === "direct" && !substituteMainCredential) {
try { validateForwardAdmissionCredential(req.headers, config); }
catch (err) {
Expand Down Expand Up @@ -372,6 +380,7 @@ export async function handleResponsesCompact(
accountId: route.codexAccountId,
modelId: selectedModelId,
substituteMainCredentialForDirect: substituteMainCredential,
requestScopedMainCredential,
beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
});
logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config);
Expand Down
11 changes: 10 additions & 1 deletion src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,11 @@ import {
fetchWithTransientRetry,
prepareSameTarget429Wait,
} from "../../lib/upstream-retry";
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
import {
ForwardAdmissionCredentialError,
hasForwardableCodexBearer,
validateForwardAdmissionCredential,
} from "../auth-cors";
import type { DataPlaneAdmission } from "../auth-cors";
import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget";
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
Expand Down Expand Up @@ -1075,6 +1079,7 @@ async function retryCodexPoolOnAlternateAccount(
{
excludeAccountId: firstAuthCtx.accountId,
modelId: route.modelId,
requestScopedMainCredential: hasForwardableCodexBearer(req.headers, config),
beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
resolveCodexModelEntitlements: entitlementResolver,
},
Expand Down Expand Up @@ -1655,6 +1660,9 @@ async function resolveResponsesCodexAuth(
// no-ChatGPT-login install keeps working.
const substituteMainCredential = options.admission?.source === "bearer"
&& (route.codexAccountMode !== undefined || isCanonicalOpenAiForwardProvider(route.provider));
const requestScopedMainCredential = route.codexAccountMode !== undefined
&& !substituteMainCredential
&& hasForwardableCodexBearer(req.headers, config);
if (route.codexAccountMode === "direct" && !substituteMainCredential) {
validateForwardAdmissionCredential(req.headers, config);
}
Expand All @@ -1664,6 +1672,7 @@ async function resolveResponsesCodexAuth(
accountId: route.codexAccountId,
modelId: route.modelId,
substituteMainCredentialForDirect: substituteMainCredential,
requestScopedMainCredential,
beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
resolveCodexModelEntitlements: options.resolveCodexModelEntitlements,
});
Expand Down
40 changes: 40 additions & 0 deletions tests/codex-auth-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
tryAdmitTurn,
} from "../src/server/lifecycle";
import type { CodexModelEntitlementSnapshot } from "../src/codex/model-entitlements";
import { hasForwardableCodexBearer } from "../src/server/auth-cors";

let testDir: string;
let previousOpencodexHome: string | undefined;
Expand Down Expand Up @@ -1018,6 +1019,45 @@ describe("Codex auth context", () => {
});
expect(cfg.activeCodexAccountId).toBe("pool-a");
});

test("uses a validated native caller bearer for main without persisting it", async () => {
const cfg = config();
cfg.codexAccounts = [];
cfg.activeCodexAccountId = undefined;
const inbound = new Headers({
authorization: "Bearer caller-keyring-token",
"chatgpt-account-id": "caller-keyring-account",
"openai-beta": "responses=experimental",
});
markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);

try {
expect(hasForwardableCodexBearer(inbound, cfg)).toBe(true);
expect(hasForwardableCodexBearer(new Headers({
authorization: ["Bearer ocx", "data", "not-forwardable"].join("_"),
"chatgpt-account-id": "caller-keyring-account",
}), cfg)).toBe(false);
const ctx = await resolveCodexAuthContext(inbound, cfg, "pool", {
requestScopedMainCredential: true,
});
expect(ctx).toMatchObject({
kind: "main",
accountId: null,
});
expect(ctx).not.toHaveProperty("accessToken");
expect(ctx).not.toHaveProperty("chatgptAccountId");
expect(ctx).not.toHaveProperty("affinityKey");
expect(ctx).not.toHaveProperty("writerGeneration");

const upstream = materializeCodexUpstreamAuth(inbound, ctx);
expect(upstream.get("authorization")).toBe("Bearer caller-keyring-token");
expect(upstream.get("chatgpt-account-id")).toBe("caller-keyring-account");
expect(upstream.get("openai-beta")).toBe("responses=experimental");
expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true);
} finally {
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
}
});
test("selects pool auth independently of the routed provider", async () => {
saveCodexAccountCredential("pool-a", {
accessToken: "pool_token",
Expand Down
18 changes: 18 additions & 0 deletions tests/reasoning-replay-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
bindReasoningReplayScope,
clearReasoningReplayCacheForTests,
commitReasoningReplayServingIdentity,
durableReplayCredentialIdentity,
peekReasoningForCall,
reasoningReplayCodexCredentialIdentity,
reasoningReplayCredentialIdentity,
Expand Down Expand Up @@ -348,6 +349,23 @@ describe("reasoning replay provider and credential identity", () => {
expect(destination).not.toContain("opaque-secret");
});

test("request-owned Codex bearers are distinct in process and refuse durable replay identity", () => {
const callerA = reasoningReplayCodexCredentialIdentity({
authorization: "Bearer caller-token-a",
chatgptAccountId: "caller-account-a",
});
const callerB = reasoningReplayCodexCredentialIdentity({
authorization: "Bearer caller-token-b",
chatgptAccountId: "caller-account-b",
});

expect(callerA).toBeDefined();
expect(callerB).toBeDefined();
expect(callerA).not.toBe(callerB);
expect(durableReplayCredentialIdentity("codex", undefined, undefined, Buffer.alloc(32, 7)))
.toBeUndefined();
});

test("a bridge created before credential rotation writes under the holder's current identity", async () => {
const oldScope = scope();
rememberReasoningForCall(CALL_ID, "old reasoning", oldScope);
Expand Down
Loading
Loading