diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 5b75a21b41..fe190e4db2 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -20,6 +20,13 @@ plus `openai-apikey/` for the configured API key. Pool includes main plus Direct uses only the caller/main bearer. The routes do not fall back to one another. Shipped v1 configs migrate to marker 2 and preserve `config.json.pre-openai-tiers-v2.bak` for manual restore. +Within Pool mode, a request carrying a validated native Codex login can use that login when the +selected stored account is cooling down and no eligible stored alternative or recovery probe is +available. This also covers a new request blocked before sending, following the same caller +validation used after an upstream rejection. Existing model-permission and main-account policy +checks still apply. The fallback preserves the stored account's cooldown and does not persist the +caller credential as the Pool selection. An exact account binding remains bound to that account. + ## Config injection `ocx init`, `ocx start`, and `ocx sync` call the injector. On the default loopback bind, it keeps diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index ea7ff40cde..976da6c96a 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -7,6 +7,12 @@ opencodex는 Codex가 읽는 두 가지, 즉 설정(`$CODEX_HOME/config.toml`, 프록시는 bare `openai` Codex 로그인 경로 하나와 Pool(기본) 및 Direct 계정 모드, 그리고 설정된 API 키용 `openai-apikey/`을 제공합니다. Pool은 메인 계정과 추가된 계정을 포함하고, Direct는 호출자/메인 bearer만 사용합니다. 경로들은 서로 fallback하지 않습니다. shipped v1 config는 marker 2로 이관되며, 수동 복원을 위해 `config.json.pre-openai-tiers-v2.bak`를 보존합니다. +Pool 모드에서는 선택된 저장 계정이 쿨다운 중이고 사용 가능한 다른 저장 계정이나 복구 probe가 +없을 때, 요청에 포함된 검증된 native Codex 로그인을 사용할 수 있습니다. 상류 거절 후 재시도와 +같은 호출자 검증을 적용하므로, 전송 전에 막힌 새 요청도 이 경로를 사용할 수 있습니다. 기존 모델 +권한과 main 계정 정책 검사는 유지됩니다. 이 fallback은 저장 계정의 쿨다운을 해제하거나 호출자 +인증을 Pool 선택으로 저장하지 않습니다. 특정 계정에 정확히 고정된 요청은 그 계정에 계속 묶입니다. + ## 설정 주입 `ocx init`, `ocx start`, `ocx sync`는 모두 인젝터를 호출합니다. 기본 loopback 바인드에서는 Codex의 빌트인 `openai` 프로바이더 id를 그대로 유지한 채, 그 프로바이더가 opencodex를 바라보게 합니다. diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index c3de264721..cccb9e1242 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,9 +1,10 @@ -import { createHmac, randomBytes } from "node:crypto"; +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, + getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, } from "./account-store"; @@ -49,7 +50,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"; +import { extractAccountId, extractEmail } from "../oauth/chatgpt"; import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "./main-account-hard-lock"; import { captureMainAccountIdentityGeneration, @@ -62,7 +63,7 @@ import { } from "./main-account-cache"; import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported, isCodexReserveRequestEligible } from "./loopback-target"; import type { DataPlaneAdmission } from "../server/auth-cors"; -import { getMainReserveAuthorization, isMainReserveAuthorizationLive, type MainReserveAuthorization } from "./reserve-availability"; +import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability"; import { UpstreamRetryEvidenceError } from "../lib/upstream-retry"; const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512; @@ -455,6 +456,54 @@ function callerMatchesObservedMain(headers: Headers): boolean { return matchesMainQuotaCredential(bearer, effectiveAccountId); } +/** Constant-time digest comparison so bearer bytes never drive branch timing. */ +function sameCredentialMaterial(a: string, b: string): boolean { + return timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest()); +} + +/** + * The early-cooldown caller-main fallback must not resurrect the subscription that is cooling + * down. Fail closed on ambiguity: an unreadable caller identity cannot be distinguished from the + * cooled account. A distinct workspace account id is always safe; an exact materialized + * bearer + account tuple marks the same subscription. Beyond that, the stable native user id is + * the strongest available evidence: it survives an email change and a token rotation, and it + * separates members who share one workspace account id even when neither credential carries an + * email. Email remains the fallback when no comparable user id exists on both sides. Coexisting + * personal/business registrations with the same email and account id over-deny during the + * cooldown — the safe direction. + */ +function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountId: string): boolean { + const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + if (!bearer) return true; + const callerAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer); + if (callerAccountId === undefined) return true; + if (accountId === MAIN_CODEX_ACCOUNT_ID) { + // No physical-main read to identify the caller: the observed-main equality tag suffices. + return callerMatchesObservedMain(headers); + } + const stored = getCodexAccountCredential(accountId); + const entry = config.codexAccounts?.find(account => account.id === accountId); + const cooledAccountId = stored?.chatgptAccountId || entry?.chatgptAccountId; + if (!cooledAccountId) return true; + if (cooledAccountId !== callerAccountId) return false; + if (stored?.accessToken && sameCredentialMaterial(bearer, stored.accessToken)) return true; + // Same namespace on both sides, never `sub`: this is the ChatGPT per-user identity the reserve + // path already trusts. A credential whose own two encodings of it disagree cannot identify + // anyone, so it fails closed even when the disagreement is on the stored side. + const callerUser = nativeUserIdClaims(bearer); + const cooledUser = stored?.accessToken + ? nativeUserIdClaims(stored.accessToken) + : { userId: undefined, conflict: false }; + if (callerUser.conflict || cooledUser.conflict) return true; + if (callerUser.userId !== undefined && cooledUser.userId !== undefined) { + return callerUser.userId === cooledUser.userId; + } + const callerEmail = extractEmail(undefined, bearer)?.trim().toLowerCase() || undefined; + const cooledEmail = entry?.email?.trim().toLowerCase() || undefined; + if (callerEmail !== undefined && cooledEmail !== undefined) return callerEmail === cooledEmail; + return true; +} + function captureObservedMainWriter(): MainQuotaWriter | undefined { const identityKey = getObservedMainQuotaIdentityKey(); return identityKey === undefined ? undefined : { @@ -894,6 +943,14 @@ export async function resolveCodexAuthContext( ? tryAcquireCodexQuotaScopeProbeLease(accountId, probeQuotaScope) ?? undefined : tryAcquireCodexQuotaProbeLease(accountId) ?? undefined; if (!probeLeaseId) { + // The selector can retain the configured Pool account when no stored + // alternate is eligible. A validated caller may still serve this request, + // just as it can after an upstream rejection, without changing Pool state. + if (requestScopedMainCredential && fixedAccountId === undefined + && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID + && !callerIsCooledPoolAccount(headers, config, accountId)) { + return await resolveCallerOwnedMainContext(); + } throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope); } } diff --git a/src/codex/reserve-availability.ts b/src/codex/reserve-availability.ts index 7d03e840ac..b96b9e8153 100644 --- a/src/codex/reserve-availability.ts +++ b/src/codex/reserve-availability.ts @@ -44,14 +44,30 @@ function owned(token: Token, writer: MainQuotaWriter): boolean { function record(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -function userId(token: string): string | undefined { +/** + * The ChatGPT per-user identity carried by a native credential, plus whether the token's own two + * encodings of it disagree. Precedence stays on the RAW claims, so an empty or non-string + * `chatgpt_user_id` still blocks the `user_id` fallback exactly as before; `conflict` is a + * separate observation for callers that must fail closed on an ambiguous identity. + */ +export function nativeUserIdClaims(token: string): { userId: string | undefined; conflict: boolean } { + const none = { userId: undefined, conflict: false }; try { const payload: unknown = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8")); const auth = record(payload) ? payload["https://api.openai.com/auth"] : undefined; - if (!record(auth)) return; - const value = auth.chatgpt_user_id ?? auth.user_id; - return typeof value === "string" && value.length > 0 ? value : undefined; - } catch { return; } + if (!record(auth)) return none; + const named = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; + const primary = named(auth.chatgpt_user_id); + const secondary = named(auth.user_id); + return { + userId: named(auth.chatgpt_user_id ?? auth.user_id), + conflict: primary !== undefined && secondary !== undefined && primary !== secondary, + }; + } catch { return none; } +} +function userId(token: string): string | undefined { + return nativeUserIdClaims(token).userId; } function identityMatches(data: WhamUsageResponse, token: Token): boolean { if (data.account_id != null && data.account_id !== token.chatgptAccountId) return false; diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 73417b85f7..ac09216229 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -55,6 +55,7 @@ import { CODEX_QUOTA_PROBE_INTERVAL_MS, clearCodexUpstreamHealth, clearThreadAccountMap, + getCodexQuotaHealthSnapshot, recordCodexUpstreamOutcome, resetCodexRoutingForManualSelection, } from "../../src/codex/routing"; @@ -1262,6 +1263,147 @@ describe("Codex auth context", () => { expect(directEntitlementChecks).toBe(1); }); + test("a fresh request can reuse caller main after the selected Pool account enters cooldown", async () => { + const cfg = { ...config(), autoSwitchThreshold: 0 }; + const now = 1_800_000_000_000; + const originalNow = Date.now; + const inbound = new Headers({ + authorization: "Bearer caller-keyring-token", + "chatgpt-account-id": "caller-keyring-account", + }); + saveCodexAccountCredential("pool-a", { + accessToken: "pool_token", refreshToken: "pool_refresh", + expiresAt: now + 24 * 60 * 60_000, chatgptAccountId: "pool_acc", + }); + try { + Date.now = () => now; + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + now, modelId: "gpt-5.6-terra", resetAt: now + 600_000, fixedAccount: true, + }); + const cooldown = getCodexQuotaHealthSnapshot("pool-a", "shared"); + expect(cooldown).not.toBeNull(); + Date.now = () => now + 1_000; + const options = { requestScopedMainCredential: true, modelId: "gpt-5.6-terra" }; + await expect(resolveCodexAuthContext(inbound, cfg, "pool", { + ...options, excludeAccountId: "pool-a", + })).resolves.toMatchObject({ kind: "main", accountId: null }); + + const context = await resolveCodexAuthContext(inbound, cfg, "pool", options); + expect(context).toMatchObject({ kind: "main", accountId: null }); + const forwarded = headersForCodexAuthContext(inbound, context); + expect(forwarded.get("authorization")).toBe("Bearer caller-keyring-token"); + expect(forwarded.get("chatgpt-account-id")).toBe("caller-keyring-account"); + expect(cfg.activeCodexAccountId).toBe("pool-a"); + expect(cfg.activeCodexAccountPinned).toBeUndefined(); + expect(getCodexQuotaHealthSnapshot("pool-a", "shared")).toEqual(cooldown); + } finally { + Date.now = originalNow; + } + }); + + test("cooldown caller-main fallback never resurrects the cooled subscription", async () => { + const now = 1_800_000_000_000; + const originalNow = Date.now; + const cfg = { ...config(), autoSwitchThreshold: 0 }; + // config() registers pool-a with email pool@example.test and workspace account pool_acc. + const callerJwt = (email?: string) => `header.${Buffer.from(JSON.stringify({ + exp: Math.floor(now / 1000) + 86_400, + ...(email ? { email } : {}), + "https://api.openai.com/auth": { chatgpt_account_id: "pool_acc" }, + })).toString("base64url")}.signature`; + saveCodexAccountCredential("pool-a", { + accessToken: "pool_token", refreshToken: "pool_refresh", + expiresAt: now + 24 * 60 * 60_000, chatgptAccountId: "pool_acc", + }); + try { + Date.now = () => now; + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + now, modelId: "gpt-5.6-terra", resetAt: now + 600_000, fixedAccount: true, + }); + const cooldown = getCodexQuotaHealthSnapshot("pool-a", "shared"); + expect(cooldown).not.toBeNull(); + Date.now = () => now + 1_000; + const options = { requestScopedMainCredential: true, modelId: "gpt-5.6-terra" }; + const resolve = (headers: Headers) => resolveCodexAuthContext(headers, cfg, "pool", options); + + // The cooled account's exact materialized credential cannot use the fallback. + await expect(resolve(new Headers({ + authorization: "Bearer pool_token", "chatgpt-account-id": "pool_acc", + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + // A rotated token of the same account (same workspace id and email) is still that subscription. + await expect(resolve(new Headers({ + authorization: `Bearer ${callerJwt("pool@example.test")}`, + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + // A distinct team member on the shared workspace account id may serve the request. + await expect(resolve(new Headers({ + authorization: `Bearer ${callerJwt("teammate@example.test")}`, + }))).resolves.toMatchObject({ kind: "main", accountId: null }); + // An unreadable caller identity fails closed. + await expect(resolve(new Headers({ + authorization: "Bearer opaque-caller-token", + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + // The workspace account id without a readable email cannot be distinguished: fail closed. + await expect(resolve(new Headers({ + authorization: `Bearer ${callerJwt()}`, + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + + // Nothing mutated the cooldown or the Pool selection. + expect(cfg.activeCodexAccountId).toBe("pool-a"); + expect(getCodexQuotaHealthSnapshot("pool-a", "shared")).toEqual(cooldown); + } finally { + Date.now = originalNow; + } + }); + + test("cooldown caller-main fallback follows the stable user id, not the recorded email", async () => { + const now = 1_800_000_000_000; + const originalNow = Date.now; + const cfg = { ...config(), autoSwitchThreshold: 0 }; + // config() registers pool-a with email pool@example.test on workspace account pool_acc. + const jwt = (claims: Record, email?: string) => `header.${Buffer.from(JSON.stringify({ + exp: Math.floor(now / 1000) + 86_400, + ...(email ? { email } : {}), + "https://api.openai.com/auth": { chatgpt_account_id: "pool_acc", ...claims }, + })).toString("base64url")}.signature`; + const storeCooled = (accessToken: string) => saveCodexAccountCredential("pool-a", { + accessToken, refreshToken: "pool_refresh", + expiresAt: now + 24 * 60 * 60_000, chatgptAccountId: "pool_acc", + }); + storeCooled(jwt({ chatgpt_user_id: "user-cooled" }, "pool@example.test")); + try { + Date.now = () => now; + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + now, modelId: "gpt-5.6-terra", resetAt: now + 600_000, fixedAccount: true, + }); + const cooldown = getCodexQuotaHealthSnapshot("pool-a", "shared"); + expect(cooldown).not.toBeNull(); + Date.now = () => now + 1_000; + const options = { requestScopedMainCredential: true, modelId: "gpt-5.6-terra" }; + const resolve = (headers: Headers) => resolveCodexAuthContext(headers, cfg, "pool", options); + + // Distinct members of one workspace account, with no email claim anywhere to separate them. + await expect(resolve(new Headers({ + authorization: `Bearer ${jwt({ chatgpt_user_id: "user-teammate" })}`, + }))).resolves.toMatchObject({ kind: "main", accountId: null }); + // The same user stays inside its own cooldown after an email change and a token rotation. + await expect(resolve(new Headers({ + authorization: `Bearer ${jwt({ chatgpt_user_id: "user-cooled" }, "renamed@example.test")}`, + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + + // A stored credential whose own user-id claims disagree identifies nobody, so even a caller + // the email rule would have waved through as a teammate fails closed. + storeCooled(jwt({ chatgpt_user_id: "user-cooled", user_id: "user-other" }, "pool@example.test")); + await expect(resolve(new Headers({ + authorization: `Bearer ${jwt({ chatgpt_user_id: "user-teammate" }, "teammate@example.test")}`, + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + + expect(cfg.activeCodexAccountId).toBe("pool-a"); + expect(getCodexQuotaHealthSnapshot("pool-a", "shared")).toEqual(cooldown); + } finally { + Date.now = originalNow; + } + }); + test("selects pool auth independently of the routed provider", async () => { saveCodexAccountCredential("pool-a", { accessToken: "pool_token", diff --git a/tests/codex-integration/main-account-hard-lock-auth.test.ts b/tests/codex-integration/main-account-hard-lock-auth.test.ts index d5e2ac70f9..c3caaabe2c 100644 --- a/tests/codex-integration/main-account-hard-lock-auth.test.ts +++ b/tests/codex-integration/main-account-hard-lock-auth.test.ts @@ -26,7 +26,7 @@ import { observeMainQuotaIdentity, } from "../../src/codex/main-account-cache"; import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; -import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth } from "../../src/codex/routing"; +import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth, getCodexQuotaHealthSnapshot, recordCodexUpstreamOutcome } from "../../src/codex/routing"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar } from "../../src/providers/openai-sidecar"; import { mapCodexAuthContextErrorToResponse } from "../../src/server/responses/codex-auth-error"; import { handleResponses } from "../../src/server/responses/core"; @@ -343,6 +343,34 @@ describe("main quota policy at native admission", () => { })).rejects.toBeInstanceOf(CodexMainAccountHardLockError); }); + for (const percent of [98.99, 99]) { + test(`Pool cooldown caller fallback keeps the main ${percent}% policy boundary`, async () => { + const cfg = config(); + addAlternative(cfg); + cfg.activeCodexAccountId = "hard-lock-pool"; + observeMainQuotaCredential(bearer(), accountId); + quota(percent); + const now = Date.now(); + recordCodexUpstreamOutcome(cfg, "hard-lock-pool", 429, { + now, modelId: "gpt-5.6-terra", resetAt: now + 600_000, fixedAccount: true, + }); + const cooldown = getCodexQuotaHealthSnapshot("hard-lock-pool", "shared"); + expect(cooldown).not.toBeNull(); + spyOn(Date, "now").mockReturnValue(now + 1_000); + forbidPhysicalReads(); + const context = resolveCodexAuthContext(caller(), cfg, "pool", { + requestScopedMainCredential: true, modelId: "gpt-5.6-terra", + }); + if (percent < 99) { + await expect(context).resolves.toMatchObject({ kind: "main", accountId: null }); + } else { + await expect(context).rejects.toBeInstanceOf(CodexMainAccountHardLockError); + } + expect(cfg.activeCodexAccountId).toBe("hard-lock-pool"); + expect(getCodexQuotaHealthSnapshot("hard-lock-pool", "shared")).toEqual(cooldown); + }); + } + test("unmatched, spoofed-claim, and conflicting-workspace callers do not inherit main policy", async () => { observeMainQuotaCredential(bearer(), accountId); quota(99); diff --git a/tests/codex-integration/reserve-availability.test.ts b/tests/codex-integration/reserve-availability.test.ts index d5298ea2f7..38d10e7ada 100644 --- a/tests/codex-integration/reserve-availability.test.ts +++ b/tests/codex-integration/reserve-availability.test.ts @@ -3,7 +3,8 @@ import { clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity, } from "../../src/codex/main-account-cache"; import { - getMainReserveAuthorization, isMainReserveAuthorizationLive, observeMainReserveRevocation, + getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, + observeMainReserveRevocation, } from "../../src/codex/reserve-availability"; import type { WhamUsageResponse } from "../../src/codex/quota-types"; @@ -239,3 +240,31 @@ describe("owned main Reserve capability", () => { } finally { timer.mockRestore(); response.resolve(Response.json(grant())); } }); }); + +describe("native user identity claims", () => { + const token = (auth: Record) => + `fixture.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": auth })).toString("base64url")}.signature`; + + test("precedence stays on the raw claims, so an unusable chatgpt_user_id blocks the fallback", () => { + expect(nativeUserIdClaims(token({ chatgpt_user_id: "user-a", user_id: "user-a" }))) + .toEqual({ userId: "user-a", conflict: false }); + expect(nativeUserIdClaims(token({ user_id: "user-b" }))).toEqual({ userId: "user-b", conflict: false }); + // An empty or non-string primary claim selects nothing rather than falling through. + expect(nativeUserIdClaims(token({ chatgpt_user_id: "", user_id: "user-c" }))) + .toEqual({ userId: undefined, conflict: false }); + expect(nativeUserIdClaims(token({ chatgpt_user_id: 17, user_id: "user-d" }))) + .toEqual({ userId: undefined, conflict: false }); + }); + + test("two disagreeing encodings report a conflict without changing the selected id", () => { + expect(nativeUserIdClaims(token({ chatgpt_user_id: "user-a", user_id: "user-b" }))) + .toEqual({ userId: "user-a", conflict: true }); + }); + + test("absent, unparseable, and foreign-namespace tokens report nothing", () => { + expect(nativeUserIdClaims(token({}))).toEqual({ userId: undefined, conflict: false }); + expect(nativeUserIdClaims("not-a-token")).toEqual({ userId: undefined, conflict: false }); + expect(nativeUserIdClaims(`fixture.${Buffer.from(JSON.stringify({ sub: "user-a" })).toString("base64url")}.sig`)) + .toEqual({ userId: undefined, conflict: false }); + }); +});