diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index ed3c6a565d..fd99e29147 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -93,6 +93,12 @@ ocx login anthropic 아닙니다. 진행 중 요청, 식별되지 않은 키링 계정, 프록시 밖 요청은 사용량을 더 쓸 수 있습니다. 추가 계정과 다른 공급자는 계속 사용할 수 있습니다. +보호 기능이 켜져 있으면 소유권이 확인된 시작 과정에서 native 프로필 복구와 정리를 마친 뒤 +메인 인증정보의 메모리 내 식별 연결을 복원하므로, 저장된 99% 차단이 재시작 후에도 유지됩니다. +연결을 준비하는 동안 호출자 인증정보를 쓰는 Direct, 메인 계정 지정, 메인 fallback, 메인 pin +요청은 잠시 503을 받을 수 있고, 저장된 Pool 계정은 그동안에도 그대로 쓸 수 있습니다. +이 초기화를 위해 다른 서비스 소유이거나 소유권이 미확인인 홈의 인증정보를 읽지는 않습니다. + 차단 중에는 해당 메인 계정의 Luna Reserve도 쓸 수 없습니다. 일반 사용량이 소진되지 않으면 Reserve가 활성화되지 않을 수 있습니다. 스위치를 끄면 원래 처리 방식으로 돌아가지만 서버가 허용하는 사용량이 늘어나지는 않습니다. 계정의 사용량 새로고침으로 최신 수치를 확인할 수 있으며, 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 21760eef0f..bc5f56e02d 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -154,6 +154,12 @@ by default. This protects new requests using the identified main account, not th already-running requests, unmatched caller-owned keyring credentials, and traffic outside the proxy can still spend quota. Added accounts and other providers remain available. +With protection enabled, an owned startup restores the main credential's in-memory identity +binding after native-profile recovery and cleanup, so a persisted 99% block survives a restart. +Caller-owned Direct, exact-main, main-fallback, and main-pin requests can briefly receive 503 +while that binding is pending; healthy stored Pool accounts stay eligible throughout. No +credential is read from a foreign or unconfirmed service home for this initialization. + While this policy blocks main, Luna Reserve on that account is blocked too. Staying below ordinary quota exhaustion may prevent Reserve activation. Disabling the switch restores normal local handling, not additional upstream entitlement. Use the account quota refresh action to obtain a diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 703e08f247..88208bfb1b 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -7,12 +7,13 @@ import { } from "../config"; import { removeCodexAccountCredential } from "./account-store"; import { clearAccountNeedsReauth } from "./account-runtime-state"; -import { getMainChatgptAccountId } from "./auth-collision"; +import { getMainChatgptAccountId, readCodexTokensResult } from "./auth-collision"; import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; import { clearAccountQuota } from "./quota"; import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing"; import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; -import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaIdentity } from "./main-account-cache"; +import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "./main-account-cache"; +import { extractAccountIdClaims } from "../oauth/chatgpt"; import { forgetCodexAccountPause } from "./account-pause"; import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority"; import { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh-state"; @@ -75,6 +76,38 @@ export function reconcileMainCodexAccountRuntimeState(): boolean { return true; } +/** + * Rebuild the memory-only policy binding from a startup-owned, recovered auth path. + * The caller holds the native owner and exclusive claim; an incoming bearer is never evidence. + * A failed read creates no binding and cannot revoke a prior verified observation or its block. + * Only a valid replacement observation or confirmed account transition supersedes that evidence. + */ +export function initializeMainAccountPolicyBinding(authPath: string): boolean { + // Startup observes the pinned owned path inside the exclusive claim: bound the read so a + // replaced non-regular or oversized file cannot stall startup inside that claim. + const result = readCodexTokensResult(authPath, { bounded: true }); + if (result.status !== "ok") return false; + const { tokens } = result; + if (typeof tokens.access_token !== "string" || !tokens.access_token + || typeof tokens.account_id !== "string" || !tokens.account_id) return false; + if (tokens.id_token != null && typeof tokens.id_token !== "string") return false; + const accountId = tokens.account_id; + // An owned file may contain an opaque bearer, but every decoded identity must agree — + // including the two account-id encodings within a single token. + const idTokenClaims = extractAccountIdClaims(tokens.id_token); + const accessTokenClaims = extractAccountIdClaims(tokens.access_token); + if (idTokenClaims.conflict || accessTokenClaims.conflict) return false; + const idTokenAccountId = idTokenClaims.accountId; + const accessTokenAccountId = accessTokenClaims.accountId; + if ((idTokenAccountId !== undefined && idTokenAccountId !== accountId) + || (accessTokenAccountId !== undefined && accessTokenAccountId !== accountId)) return false; + const previousAccountId = observedMainChatgptAccountId; + observedMainChatgptAccountId = accountId; + if (previousAccountId !== undefined && previousAccountId !== accountId) purgeMainCodexAccountRuntimeState(); + observeMainQuotaIdentity(accountId); + return observeMainQuotaCredential(tokens.access_token, accountId) !== undefined; +} + /** * Apply a transaction-confirmed physical native-login change without waiting for * a later auth.json observation. The caller owns credential commit/rollback. diff --git a/src/codex/auth-collision.ts b/src/codex/auth-collision.ts index 52c9242e8c..1dc17e897e 100644 --- a/src/codex/auth-collision.ts +++ b/src/codex/auth-collision.ts @@ -6,6 +6,7 @@ import { resolveCodexHomeDir } from "./home"; import { extractAccountId } from "../oauth/chatgpt"; import { isSelectableCodexPoolAccount } from "./account-id"; import { codexPlanKey } from "./plan"; +import { MAX_AUTH_BYTES, readBounded } from "./native-profile-store"; export interface CodexTokens { access_token: string; @@ -31,12 +32,21 @@ function hasErrnoCode(error: unknown, code: string): boolean { /** * Reads the Codex CLI credential file and classifies the outcome. Reads once instead of doing an * `existsSync` pre-check, so a file replaced between check and read cannot be misread as absent. + * An already-owned lifecycle may supply its pinned auth path instead of resolving ambient home. + * `bounded` opts into the native-profile bounded reader (regular file, size-capped, no-follow, + * non-blocking) for startup observation paths that run inside the owner claim; bounded violations + * classify as `unreadable`. Legacy callers keep the unbounded read. * Never returns or logs the raw error or any token material. */ -export function readCodexTokensResult(): CodexTokenReadResult { +export function readCodexTokensResult( + authPath = join(resolveCodexHomeDir(), "auth.json"), + options?: { bounded?: boolean }, +): CodexTokenReadResult { let raw: string; try { - raw = readFileSync(join(resolveCodexHomeDir(), "auth.json"), "utf-8"); + raw = options?.bounded === true + ? readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8") + : readFileSync(authPath, "utf-8"); } catch (error) { return { status: hasErrnoCode(error, "ENOENT") ? "missing" : "unreadable" }; } diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 2f319b3144..c3de264721 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -21,7 +21,7 @@ import { isMainAccountTokenLive, type NativeMainRefreshDependencies, } from "./main-account"; -import { isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup"; +import { isMainAccountPolicyBindingPending, isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup"; import type { NativeMainStartupBlockReason } from "./native-profile-startup"; import { codexQuotaScopeForModel, @@ -598,13 +598,19 @@ export async function resolveCodexAuthContext( throw new CodexReserveUnavailableError(); } const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId; - const preserveRequestOwnedMainPin = requestScopedMainCredential + const requestOwnedMainPinCandidate = requestScopedMainCredential && fixedAccountId === undefined && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID && isEffectiveCodexAccountPinned(config) && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID) - && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy)) && requestOwnedMainPinHasQuotaHeadroom(config); + // During an owned startup, equality cannot be established until recovery and the + // memory-only policy binding finish. This read-only fence never probes a foreign home. + if (policy.codexMainAccountHardLock === true && requestOwnedMainPinCandidate && isMainAccountPolicyBindingPending()) { + throw new CodexMainProfileDrainingError(); + } + const preserveRequestOwnedMainPin = requestOwnedMainPinCandidate + && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy)); if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) { throw new Error("Codex auth context cannot select and exclude an account simultaneously"); } @@ -612,6 +618,9 @@ export async function resolveCodexAuthContext( if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); const substituteStoredMain = options.substituteMainCredentialForDirect === true; if (!substituteStoredMain) { + if (policy.codexMainAccountHardLock === true && isMainAccountPolicyBindingPending()) { + throw new CodexMainProfileDrainingError(); + } if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy); if (reserve) { const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy }); diff --git a/src/codex/native-profile-startup.ts b/src/codex/native-profile-startup.ts index 25f59ba23e..3e2211031d 100644 --- a/src/codex/native-profile-startup.ts +++ b/src/codex/native-profile-startup.ts @@ -1,4 +1,6 @@ import { NativeProfileManager } from "./native-profile-manager"; +import { loadConfig } from "../config"; +import { initializeMainAccountPolicyBinding } from "./account-lifecycle"; import { clearAccountNeedsReauth } from "./account-runtime-state"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { @@ -73,6 +75,7 @@ interface StartupEntry { owner: NativeMainOwnerReference; unsubscribe: () => void; recoveryStarted: boolean; + policyBindingPending: boolean; settled: Promise; resolveAcquisition?: (value: NativeMainStartupGateSnapshot) => void; deps: NativeMainStartupGateDeps; @@ -172,17 +175,21 @@ async function runOwnedStageSweep(entry: StartupEntry): Promise { } function scheduleStageSweep(entry: StartupEntry): void { - if (entry.sweepStopping || entry.sweepTimer || startupEntries.get(entry.homeId) !== entry) return; + if (entry.sweepStopping || entry.sweepTimer || entry.sweepInFlight || entry.policyBindingPending + || startupEntries.get(entry.homeId) !== entry) return; const intervalMs = Math.max(10, entry.deps.stageSweepIntervalMs ?? NATIVE_STAGE_SWEEP_INTERVAL_MS); entry.sweepTimer = setTimeout(() => { entry.sweepTimer = undefined; if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry) return; + const sweepEpoch = entry.epoch; entry.sweepInFlight = (async () => { const safe = await runOwnedStageSweep(entry); - if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry) return; + if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry + || entry.epoch !== sweepEpoch || entry.policyBindingPending) return; if (!safe) snapshot = { status: "blocked", homeId: entry.homeId, reason: "stage-cleanup-required" }; else if (snapshot.homeId === entry.homeId && snapshot.status === "blocked" && snapshot.reason === "stage-cleanup-required") { - snapshot = ready(entry.homeId); + if (loadConfig().codexMainAccountHardLock === true) rearmOwnedMainPolicyBinding(entry); + else snapshot = ready(entry.homeId); } })().finally(() => { entry.sweepInFlight = undefined; @@ -218,8 +225,29 @@ function convergeOwnedStartup(entry: StartupEntry): void { )); const stageSweepSafe = recoveryState === "none" ? await runOwnedStageSweep(entry) : false; if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch && recoveryState === "none" && stageSweepSafe) { - clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); - snapshot = ready(entry.homeId); + if (loadConfig().codexMainAccountHardLock === true) { + await withNativeMainOwnerOperation(entry.manager.context, () => withNativeMainExclusiveClaim( + entry.manager.context, + async () => { + if (startupEntries.get(entry.homeId) !== entry || entry.epoch !== currentEpoch) return; + if (probe(entry.manager.context) !== "none") { + snapshot = { status: "blocked", homeId: entry.homeId, reason: "manual-recovery" }; + return; + } + // The HMAC is deliberately not persisted. Bind only the pinned owned home, + // after recovery/cleanup, and before caller-owned admission can observe ready. + if (loadConfig().codexMainAccountHardLock === true) { + initializeMainAccountPolicyBinding(entry.manager.context.authPath); + } + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + snapshot = ready(entry.homeId); + }, + { waitMs: 10_000 }, + )); + } else { + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + snapshot = ready(entry.homeId); + } } else if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch && recoveryState === "none") { snapshot = { status: "blocked", homeId: entry.homeId, reason: "stage-cleanup-required" }; } else if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch) { @@ -230,12 +258,35 @@ function convergeOwnedStartup(entry: StartupEntry): void { snapshot = { status: "blocked", homeId: entry.homeId, reason: "manual-recovery" }; } } + entry.policyBindingPending = false; if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch) scheduleStageSweep(entry); return snapshot; })(); if (acquisitionWaiter) void entry.settled.then(acquisitionWaiter); } +/** Join an active startup, or rearm its held owner before publishing another ready transition. */ +function rearmOwnedMainPolicyBinding(entry: StartupEntry): boolean { + if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry) return false; + const owner = entry.owner.snapshot(); + if (entry.policyBindingPending && (owner.status === "held" || owner.status === "acquiring")) { + snapshot = { status: "blocked", homeId: entry.homeId, reason: "recovery-pending" }; + settled = entry.settled; + return true; + } + if (owner.status !== "held") { + snapshot = { status: "blocked", homeId: entry.homeId, reason: ownerBlockedReason(owner) }; + return false; + } + if (entry.sweepTimer) clearTimeout(entry.sweepTimer); + entry.sweepTimer = undefined; + entry.epoch = ++epoch; + entry.policyBindingPending = true; + entry.recoveryStarted = false; + convergeOwnedStartup(entry); + return true; +} + function observeOwner(entry: StartupEntry, owner: NativeMainOwnerSnapshot): void { if (startupEntries.get(entry.homeId) !== entry) return; if (owner.status === "acquiring") { @@ -283,6 +334,7 @@ export function startNativeMainStartupLifecycle( owner, unsubscribe: () => {}, recoveryStarted: false, + policyBindingPending: true, settled: acquisition, resolveAcquisition, deps, @@ -291,6 +343,12 @@ export function startNativeMainStartupLifecycle( }; startupEntries.set(homeId, entry); entry.unsubscribe = owner.subscribe(ownerState => observeOwner(entry!, ownerState)); + } else if (!entry.policyBindingPending + && snapshot.status === "ready" && snapshot.homeId === homeId + && loadConfig().codexMainAccountHardLock === true) { + // A new same-process listener can enable protection or follow a credential replacement. + // Re-read its pinned home through the held owner before admitting caller-owned main. + rearmOwnedMainPolicyBinding(entry); } entry.refs += 1; let released = false; @@ -602,6 +660,8 @@ export function blockNativeMainRecovery( export function completeNativeMainRecovery(homeId: string): boolean { if (snapshot.status !== "blocked" || snapshot.homeId !== homeId) return false; + const entry = startupEntries.get(homeId); + if (entry && loadConfig().codexMainAccountHardLock === true) return rearmOwnedMainPolicyBinding(entry); epoch += 1; clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); snapshot = ready(homeId); @@ -615,6 +675,13 @@ export function nativeMainStartupGateSnapshot(): NativeMainStartupGateSnapshot { return { ...snapshot }; } +/** Read-only: caller-owned credentials must not trigger physical-main ownership reprobes. */ +export function isMainAccountPolicyBindingPending(): boolean { + const current = nativeMainStartupGateSnapshot(); + return current.status === "blocked" && current.reason === "recovery-pending" + && current.homeId !== null && startupEntries.get(current.homeId)?.policyBindingPending === true; +} + export function waitForNativeMainStartupGate(): Promise { const reason = activeServiceOwnershipBlockReason(); if (reason) return Promise.resolve(serviceOwnershipSnapshot(reason)); diff --git a/src/codex/native-profile-store.ts b/src/codex/native-profile-store.ts index 794e8ea006..bbd1b5d82e 100644 --- a/src/codex/native-profile-store.ts +++ b/src/codex/native-profile-store.ts @@ -41,7 +41,7 @@ const KEYRING_SERVICE = "opencodex.native-main-profile.v1"; const SHARED_METADATA_DIR = ".opencodex-native-main-profiles"; const INSTANCE_STAGING_DIR = "native-main-profile-staging"; const LEGACY_METADATA_DIR = "native-main-profiles"; -const MAX_AUTH_BYTES = 4 * 1024 * 1024; +export const MAX_AUTH_BYTES = 4 * 1024 * 1024; export const MAX_NATIVE_PROFILE_METADATA_BYTES = 4 * 1024 * 1024; export const MAX_NATIVE_PROFILE_JOURNAL_BYTES = 17 * 1024 * 1024; export const MAX_NATIVE_PROFILES = 32; @@ -376,7 +376,7 @@ export function resolveNativeProfileContext(options: { codexHome?: string; confi }; } -function readBounded(path: string, limit: number, testSeam?: BoundedReadTestSeam): Buffer { +export function readBounded(path: string, limit: number, testSeam?: BoundedReadTestSeam): Buffer { let fd: number | undefined; let failed = false; try { diff --git a/src/oauth/chatgpt.ts b/src/oauth/chatgpt.ts index bb4d1c8497..bb746ad472 100644 --- a/src/oauth/chatgpt.ts +++ b/src/oauth/chatgpt.ts @@ -50,6 +50,33 @@ export function extractEmail(idToken?: string, accessToken?: string): string | u return undefined; } +/** + * Identity-agreement view of one token for security-sensitive bindings. `accountId` follows the + * existing extractAccountId precedence (top-level, then namespaced, then organizations[0]). + * `conflict` is true only when the two chatgpt_account_id encodings are both present and + * disagree — organizations entries are workspace memberships, not identity, so they never + * participate. Never logs token material. + */ +export function extractAccountIdClaims(token?: string): { accountId: string | undefined; conflict: boolean } { + if (!token) return { accountId: undefined, conflict: false }; + const payload = decodeJwtPayload(token); + if (!payload) return { accountId: undefined, conflict: false }; + const top = typeof payload.chatgpt_account_id === "string" ? payload.chatgpt_account_id : undefined; + const ns = payload["https://api.openai.com/auth"]; + const namespaced = ns && typeof ns === "object" + && typeof (ns as Record).chatgpt_account_id === "string" + ? (ns as Record).chatgpt_account_id as string + : undefined; + const orgs = payload.organizations; + const org = Array.isArray(orgs) && orgs[0] && typeof orgs[0].id === "string" + ? orgs[0].id as string + : undefined; + return { + accountId: top ?? namespaced ?? org, + conflict: top !== undefined && namespaced !== undefined && top !== namespaced, + }; +} + export function credsFromToken(data: Record): OAuthCredentials { const idToken = typeof data.id_token === "string" ? data.id_token : undefined; // This parses a response from an external boundary, so the access token is diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 91627acaf0..c17f4d4ba8 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -160,6 +160,17 @@ workspace already observed under native ownership; an unrelated or unmatched key is not attributed to stored main and introduces no physical-main read. Credential equality tags remain process-local and never enter disk, logs, or management DTOs. +When protection is enabled, owned startup rebuilds this binding from its pinned auth path under +the native owner and exclusive claim, after journal recovery and stage cleanup, before publishing +ready. Caller-owned Direct, exact-main, fallback, and main-pin admission stays temporarily fenced +during that initialization; stored Pool alternatives remain eligible. Foreign/unknown service-home +paths neither initialize the binding nor trigger an ownership reprobe from caller-owned admission. +A new listener with protection enabled rearms the same guarded path on an existing ready lifecycle, +including when the physical credential was replaced after the earlier listener started. +Failed initialization creates no new binding. A previously verified same-process binding and its +safety state remain until a valid replacement observation or confirmed account transition; malformed +or conflicting input alone is not replacement evidence. + This is not a reservation of the last 1%: already-admitted, parallel, unmatched-keyring, or direct upstream traffic can still reach exhaustion. While blocked, main cannot use Luna reserve either. Keeping ordinary usage below exhaustion may prevent Reserve activation; the policy never changes 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 d0959817c1..d5e2ac70f9 100644 --- a/tests/codex-integration/main-account-hard-lock-auth.test.ts +++ b/tests/codex-integration/main-account-hard-lock-auth.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -33,6 +34,8 @@ import { handleResponsesCompact } from "../../src/server/responses/compact"; import { setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { helperPath, repoRoot } from "../helpers/repo-root"; +import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; const MAIN = mainAccount.MAIN_CODEX_ACCOUNT_ID; const accountId = "hard-lock-main-fixture"; @@ -137,7 +140,162 @@ afterEach(() => { removeTreeWithRetry(home); }); +describe("startup policy binding read is bounded", () => { + // FIFO and symlink cases need POSIX semantics; Windows keeps the portable cases. + const boundedReadCases: string[] = ["valid", "oversize", "directory", "missing", + ...(process.platform === "win32" ? [] : ["fifo-retained", "fifo-hang-proof", "symlink"])]; + test.each(boundedReadCases)("bounded startup read handles %s", scenario => { + const child = Bun.spawnSync([process.execPath, helperPath("bounded-auth-read-child.ts")], { + cwd: repoRoot(), + env: { ...process.env, OCX_BOUNDED_READ_CASE: scenario, + HOME: home, USERPROFILE: home, TMP: home, TEMP: home, TMPDIR: home, + XDG_RUNTIME_DIR: home, LOCALAPPDATA: join(home, "LocalAppData"), + OPENCODEX_HOME: home, CODEX_HOME: home }, + timeout: SPAWN_BUDGET_MS - INTERNAL_DEADLINE_MS, stdout: "pipe", stderr: "pipe", + }); + // A regressed unbounded FIFO read never reaches here: the spawn timeout kills the child. + expect({ exitCode: child.exitCode, stderr: child.stderr.toString() }).toMatchObject({ exitCode: 0 }); + const line = child.stdout.toString().split(/\r?\n/).find(value => value.startsWith("BOUNDED_READ_RESULT=")); + expect(line).toBeDefined(); + const result = JSON.parse(line!.slice("BOUNDED_READ_RESULT=".length)); + if (scenario === "valid") { + expect(result).toMatchObject({ bound: true, matched: true }); + } else if (scenario === "fifo-retained") { + expect(result).toMatchObject({ firstBound: true, bound: false, retained: true }); + } else { + expect(result.bound).toBe(false); + } + if (scenario === "symlink") expect(result.matched).toBe(false); + }, SPAWN_BUDGET_MS); +}); + describe("main quota policy at native admission", () => { + test.each(["owned-99", "owned-98", "foreign", "unknown", "recovery", "second-listener", + "invalid-access-token", "invalid-account-id", "invalid-id-token", "mismatched-identity", "renewed-listener", + "stage-retry", "manual-recovery", "stale-sweep", "retained-unknown-binding", + "conflicting-token-identities", "conflicting-claims", "owned-opaque-99"] as const)( + "fresh startup restores durable main policy only after owned recovery (%s)", scenario => { + const restoredId = scenario === "recovery" ? "hard-lock-recovered-main" : accountId; + const restoredBearer = scenario === "owned-opaque-99" ? "opaque-owned-startup-bearer" : `header.${Buffer.from(JSON.stringify({ exp: tokenExpiry, + ...(["renewed-listener", "manual-recovery", "stale-sweep"].includes(scenario) ? { startupTokenRevision: 1 } : {}), + ...(scenario === "conflicting-claims" ? { chatgpt_account_id: restoredId } : {}), + "https://api.openai.com/auth": { chatgpt_account_id: scenario === "conflicting-token-identities" + ? "hard-lock-conflicting-access-account" + : scenario === "conflicting-claims" ? "hard-lock-conflicting-claim-account" : restoredId } })).toString("base64url")}.signature`; + const quota = { weeklyPercent: scenario === "owned-98" ? 98 : 99, updatedAt: Date.now() - 7 * 60 * 60_000 }; + const identityKey = createHash("sha256").update("opencodex-main-quota-v1\0").update(restoredId).digest("hex"); + if (scenario.startsWith("invalid-") || scenario === "mismatched-identity") { + writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { + access_token: scenario === "invalid-access-token" ? 17 : bearer(), + account_id: scenario === "invalid-account-id" ? { invalid: true } + : scenario === "mismatched-identity" ? "conflicting-physical-account" : accountId, + ...(scenario === "invalid-id-token" ? { id_token: 17 } : {}), + } })); + } + if (scenario === "conflicting-token-identities" || scenario === "conflicting-claims" || scenario === "owned-opaque-99") { + writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { + access_token: restoredBearer, account_id: accountId, + ...(scenario === "conflicting-token-identities" ? { id_token: bearer() } : {}), + } })); + } + writeFileSync(join(home, "config.json"), JSON.stringify({ + ...config(), port: 0, hostname: "127.0.0.1", codexMainAccountHardLock: scenario !== "second-listener", + providers: { openai: { ...config().providers.openai, codexAccountMode: "direct" } }, + })); + writeFileSync(join(home, "config.toml"), 'model = "gpt-5.6-sol"\n'); + writeFileSync(join(home, "codex-quota-cache.json"), JSON.stringify({ + version: 1, quotas: { [MAIN]: quota }, mainPolicyQuota: { identityKey, quota }, + })); + const fixturePath = join(home, "startup-fixture.json"); + writeFileSync(fixturePath, JSON.stringify({ scenario, accountId: restoredId, bearer: restoredBearer, + originalAccountId: accountId, originalBearer: bearer() })); + const child = Bun.spawnSync([process.execPath, helperPath("main-account-policy-startup-child.ts")], { + cwd: repoRoot(), env: { ...process.env, OCX_POLICY_STARTUP_FIXTURE: fixturePath, + HOME: home, USERPROFILE: home, TMP: home, TEMP: home, TMPDIR: home, + XDG_RUNTIME_DIR: home, LOCALAPPDATA: join(home, "LocalAppData") }, + timeout: SPAWN_BUDGET_MS - INTERNAL_DEADLINE_MS, stdout: "pipe", stderr: "pipe", + }); + expect({ exitCode: child.exitCode, signal: child.signalCode, stderr: child.stderr.toString() }).toMatchObject({ exitCode: 0 }); + const line = child.stdout.toString().split(/\r?\n/).find(value => value.startsWith("POLICY_STARTUP_RESULT=")); + expect(line).toBeDefined(); + const result = JSON.parse(line!.slice("POLICY_STARTUP_RESULT=".length)); + expect(result.before).toMatchObject({ matched: false, policy: null, tokenReads: 0 }); + expect(result.listeners[0].tokenReads).toBe(0); + expect(result.unexpectedNetwork).toEqual([]); + expect(result.policyReadsPinned).toBe(true); + expect(result.beforePrimaryUpstreamCalls).toBe(scenario === "retained-unknown-binding" ? 3 : 0); + const unowned = scenario === "foreign" || scenario === "unknown"; + const unverified = scenario.startsWith("invalid-") || scenario === "mismatched-identity" + || scenario === "conflicting-token-identities" || scenario === "conflicting-claims"; + if (unowned) { + expect(result.firstAdmission.admitted).toBe(true); + expect(result.after.tokenReads).toBe(0); + } else { + expect(result.firstAdmission).toEqual({ admitted: false, error: "CodexMainProfileDrainingError" }); + expect(result.settled.status).toBe("ready"); + // Every owned startup reads the pinned file before it can accept or reject a binding, so + // policyReadsPinned above cannot pass on an empty read list. + expect(result.after.tokenReads).toBeGreaterThan(0); + } + if (unowned || unverified) { + expect(result.after).toMatchObject({ matched: false, policy: null }); + expect(result.response.status).toBe(200); + expect(result.primaryUpstreamCalls).toBe(1); + } else { + expect(result.after).toMatchObject({ matched: true, policy: quota }); + expect(result.response.status).toBe(scenario === "owned-98" ? 200 : 429); + expect(result.primaryUpstreamCalls).toBe(scenario === "owned-98" ? 1 : 0); + if (scenario !== "owned-98") expect(result.response.hardLockError).toBe(true); + } + if (scenario === "recovery") { + expect(result.heldRecovery.observation).toMatchObject({ matched: false, policy: null, tokenReads: 0 }); + expect(result.heldRecovery.poolFallback).toEqual({ admitted: false, error: "CodexMainProfileDrainingError" }); + expect(result.heldRecovery.mainPin).toEqual({ admitted: false, error: "CodexMainProfileDrainingError" }); + expect(result.heldRecovery.storedAlternative).toMatchObject({ admitted: true, kind: "pool" }); + expect(result.heldRecovery.automaticAlternative).toMatchObject({ admitted: true, kind: "pool" }); + expect(result.originalResponse.status).toBe(200); + } + if (scenario === "second-listener") { + expect(result.firstServerSettled).toMatchObject({ matched: false, policy: null, tokenReads: 0 }); + } + if (scenario === "second-listener" || scenario === "renewed-listener") { + expect(result.listeners).toHaveLength(2); + expect(result.listeners[1].tokenReads).toBe(result.firstServerSettled.tokenReads); + } + if (scenario === "renewed-listener") { + expect(result.firstServerSettled).toMatchObject({ matched: false, policy: quota }); + expect(result.originalResponse.status).toBe(200); + } + if (scenario === "stage-retry" || scenario === "manual-recovery") { + expect(result.laterRecovery.blocked).toMatchObject({ matched: false, policy: null, tokenReads: 0, + gate: { status: "blocked", reason: scenario === "stage-retry" ? "stage-cleanup-required" : "manual-recovery" } }); + } + if (scenario === "stage-retry") expect(result.laterRecovery.sweepCalls).toBeGreaterThanOrEqual(2); + if (scenario === "manual-recovery") { + expect(result.laterRecovery).toMatchObject({ apiStatus: 200, duplicateCompleted: true, joined: true, recoveryCalls: 1 }); + expect(result.laterRecovery.pending).toMatchObject({ matched: false, tokenReads: 0, + gate: { status: "blocked", reason: "recovery-pending" } }); + expect(result.originalResponse.status).toBe(200); + } + if (scenario === "stale-sweep") { + expect(result.laterRecovery.pending.gate).toMatchObject({ status: "blocked", reason: "recovery-pending" }); + expect(result.laterRecovery.admission).toEqual({ admitted: false, error: "CodexMainProfileDrainingError" }); + expect(result.originalResponse.status).toBe(200); + } + if (scenario === "retained-unknown-binding") { + expect(result.retainedUnknown.map((entry: { kind: string }) => entry.kind)) + .toEqual(["malformed", "conflicting", "conflicting-tokens"]); + for (const entry of result.retainedUnknown) { + expect(entry.observed).toMatchObject({ matched: true, policy: quota }); + expect(entry.main).toMatchObject({ status: 429, hardLockError: true }); + expect(entry.other.status).toBe(200); + } + expect(result.validReplacement).toMatchObject({ oldMatched: false, newMatched: true, policy: null, + old: { status: 200, hardLockError: false } }); + } + }, SPAWN_BUDGET_MS, + ); + test("short-only 99 blocks exact main and main-only Pool without probe or reauth", async () => { quota(99); const cfg = config(); diff --git a/tests/helpers/bounded-auth-read-child.ts b/tests/helpers/bounded-auth-read-child.ts new file mode 100644 index 0000000000..a5fd21bdd4 --- /dev/null +++ b/tests/helpers/bounded-auth-read-child.ts @@ -0,0 +1,61 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Child-process fixture for the bounded startup policy-binding read. A regressed (unbounded) +// FIFO read would block forever, so the parent's spawn timeout is the hang detector; the child +// itself only reports bind outcomes. +const caseName = process.env.OCX_BOUNDED_READ_CASE!; +const root = mkdtempSync(join(tmpdir(), "ocx-bounded-auth-read-")); +const accountId = "bounded-read-account"; +const bearer = `header.${Buffer.from(JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + 86_400, + "https://api.openai.com/auth": { chatgpt_account_id: accountId }, +})).toString("base64url")}.signature`; +const validAuth = JSON.stringify({ tokens: { + access_token: bearer, refresh_token: "bounded-read-refresh", account_id: accountId, +} }); + +const { initializeMainAccountPolicyBinding } = await import("../../src/codex/account-lifecycle"); +const { matchesMainQuotaCredential } = await import("../../src/codex/main-account-cache"); + +const validPath = join(root, "auth-valid.json"); +writeFileSync(validPath, validAuth); +const result: Record = { case: caseName }; + +if (caseName === "valid") { + result.bound = initializeMainAccountPolicyBinding(validPath); + result.matched = matchesMainQuotaCredential(bearer, accountId); +} else if (caseName === "fifo-retained" || caseName === "fifo-hang-proof") { + const fifoPath = join(root, "auth-fifo"); + execFileSync("mkfifo", [fifoPath]); + if (caseName === "fifo-retained") result.firstBound = initializeMainAccountPolicyBinding(validPath); + const startedAt = Date.now(); + result.bound = initializeMainAccountPolicyBinding(fifoPath); + result.elapsedMs = Date.now() - startedAt; + if (caseName === "fifo-retained") result.retained = matchesMainQuotaCredential(bearer, accountId); +} else if (caseName === "symlink") { + // The link target is a fully valid auth file: following the link would bind, so a refused + // bind proves the no-follow read rather than a content failure. + const linkPath = join(root, "auth-link.json"); + symlinkSync(validPath, linkPath); + result.bound = initializeMainAccountPolicyBinding(linkPath); + result.matched = matchesMainQuotaCredential(bearer, accountId); +} else if (caseName === "oversize") { + const bigPath = join(root, "auth-big.json"); + // Valid JSON whose tokens would bind if read: only the size cap can keep this false. + writeFileSync(bigPath, JSON.stringify({ tokens: { + access_token: bearer, refresh_token: "bounded-read-refresh", account_id: accountId, + }, padding: "a".repeat(5 * 1024 * 1024) })); + result.bound = initializeMainAccountPolicyBinding(bigPath); +} else if (caseName === "directory") { + const dirPath = join(root, "auth-dir"); + mkdirSync(dirPath); + result.bound = initializeMainAccountPolicyBinding(dirPath); +} else if (caseName === "missing") { + result.bound = initializeMainAccountPolicyBinding(join(root, "auth-absent.json")); +} else { + throw new Error(`unknown bounded-read case: ${caseName}`); +} +console.log("BOUNDED_READ_RESULT=" + JSON.stringify(result)); diff --git a/tests/helpers/main-account-policy-startup-child.ts b/tests/helpers/main-account-policy-startup-child.ts new file mode 100644 index 0000000000..387a12cfcd --- /dev/null +++ b/tests/helpers/main-account-policy-startup-child.ts @@ -0,0 +1,292 @@ +import { spyOn } from "bun:test"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +interface Fixture { + scenario: "owned-99" | "owned-98" | "foreign" | "unknown" | "recovery" | "second-listener" + | "invalid-access-token" | "invalid-account-id" | "invalid-id-token" | "mismatched-identity" | "renewed-listener" + | "stage-retry" | "manual-recovery" | "stale-sweep" | "retained-unknown-binding" + | "conflicting-token-identities" | "conflicting-claims" | "owned-opaque-99"; + accountId: string; + bearer: string; + originalAccountId: string; + originalBearer: string; +} + +const fixture: Fixture = JSON.parse(readFileSync(process.env.OCX_POLICY_STARTUP_FIXTURE!, "utf8")); +let upstreamCalls = 0; +const unexpectedNetwork: string[] = []; +// Install before product imports. Every response is synthetic; no endpoint can escape the fixture. +globalThis.fetch = Object.assign(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = new URL(request.url); + if (url.hostname === "chatgpt.com" && url.pathname.endsWith("/responses")) { + upstreamCalls++; + return Response.json({ + id: "resp_policy_startup", object: "response", status: "completed", created_at: 1, + model: "gpt-5.6-sol", output: [], usage: { input_tokens: 1, output_tokens: 0, total_tokens: 1 }, + }); + } + unexpectedNetwork.push(`${url.hostname}${url.pathname}`); + throw new Error("Unexpected network request in startup policy fixture"); +}, { preconnect() {} }) as typeof fetch; + +const { setIcaclsRunnerForTests } = await import("../../src/lib/windows-secret-acl"); +setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); +const authCollision = await import("../../src/codex/auth-collision"); +const readTokens = authCollision.readCodexTokensResult; +const tokenReads: Array = []; +const tokenSpy = spyOn(authCollision, "readCodexTokensResult").mockImplementation(authPath => { + tokenReads.push(authPath); + return readTokens(authPath); +}); +const { NativeProfileManager } = await import("../../src/codex/native-profile-manager"); +const { matchesMainQuotaCredential } = await import("../../src/codex/main-account-cache"); +const { getMainPolicyQuota } = await import("../../src/codex/quota"); +const { resolveCodexAuthContext } = await import("../../src/codex/auth-context"); +const { saveCodexAccountCredential } = await import("../../src/codex/account-store"); +const { blockNativeMainRecovery, completeNativeMainRecovery, nativeMainStartupGateSnapshot, waitForNativeMainStartupGate } = await import("../../src/codex/native-profile-startup"); +const { handleNativeProfileAPI } = await import("../../src/codex/native-profile-api"); +const { startServer } = await import("../../src/server"); +const { handleResponses } = await import("../../src/server/responses/core"); +const { loadConfig, saveConfig } = await import("../../src/config"); + +let config = loadConfig(); +const observe = () => ({ + matched: matchesMainQuotaCredential(fixture.bearer, fixture.accountId), + policy: getMainPolicyQuota(), + tokenReads: tokenReads.length, + gate: nativeMainStartupGateSnapshot(), +}); +const before = observe(); +function barrier() { + let enter!: () => void; + let release!: () => void; + const entered = new Promise(resolve => { enter = resolve; }); + const released = new Promise(resolve => { release = resolve; }); + return { entered, release: () => release(), async wait() { enter(); await released; } }; +} +async function waitForReady() { + const deadline = Date.now() + 15_000; + while (nativeMainStartupGateSnapshot().status !== "ready") { + if (Date.now() >= deadline) throw new Error("startup policy fixture did not become ready"); + await Bun.sleep(1); + } +} +const manager = new NativeProfileManager({ + codexHome: process.env.CODEX_HOME!, configDir: process.env.OPENCODEX_HOME!, + keyProvider: { + async get() { return { keyRef: "memory:policy-startup", key: Buffer.alloc(32, 7) }; }, + async create() { return { keyRef: "memory:policy-startup", key: Buffer.alloc(32, 7) }; }, + }, + hardenPath: async () => {}, processProbe: async () => ({ status: "clear", count: 0 }), +}); +let recovered = false; +let recoveryCalls = 0; +let sweepCalls = 0; +const oldSweep = barrier(); +const bindingSweep = barrier(); +const writeRecoveredAuth = () => writeFileSync(manager.context.authPath, JSON.stringify({ tokens: { + access_token: fixture.bearer, refresh_token: "fixture-refresh", account_id: fixture.accountId, +} })); +let enterRecovery!: () => void; +let releaseRecovery!: () => void; +const recoveryEntered = new Promise(resolve => { enterRecovery = resolve; }); +const recoveryRelease = new Promise(resolve => { releaseRecovery = resolve; }); +if (fixture.scenario === "recovery" || fixture.scenario === "manual-recovery") { + // The existing recovery seam changes the physical credential only when the held recovery runs. + manager.recover = async () => { + recoveryCalls++; + writeRecoveredAuth(); + recovered = true; + return { status: "none" } as Awaited>; + }; + saveCodexAccountCredential("startup-pool", { + accessToken: "fixture-pool-access", refreshToken: "fixture-pool-refresh", + expiresAt: Date.now() + 86_400_000, chatgptAccountId: "fixture-pool-account", + }); +} +if (["stage-retry", "manual-recovery", "stale-sweep"].includes(fixture.scenario)) { + manager.stageSweepRequired = () => true; + manager.sweepStages = async () => { + const call = ++sweepCalls; + let plaintextMayRemain = false; + if (fixture.scenario === "stage-retry") { + if (call === 1) plaintextMayRemain = true; + if (call === 2) await oldSweep.wait(); + } else if (fixture.scenario === "manual-recovery") { + if (call === 1) await bindingSweep.wait(); + } else { + if (call === 2) { await oldSweep.wait(); plaintextMayRemain = true; } + if (call === 3) await bindingSweep.wait(); + } + return { plaintextMayRemain } as Awaited>; + }; +} + +const listeners: Array> = []; +const realServe = Bun.serve; +Bun.serve = ((options: Parameters[0]) => { + listeners.push(observe()); + return realServe(options); +}) as typeof Bun.serve; +const ownership = fixture.scenario === "foreign" || fixture.scenario === "unknown" ? fixture.scenario : "owned"; +const start = () => startServer(0, { + inspectNativeCodexOwnership: () => ({ ownership, reason: "synthetic policy-startup fixture" }), + nativeMainStartup: { + manager, + ...(["stage-retry", "stale-sweep"].includes(fixture.scenario) ? { stageSweepIntervalMs: 10 } : {}), + ...(fixture.scenario === "manual-recovery" ? { + probeRecoveryState: () => recovered ? "none" as const : "manual" as const, + } : {}), + ...(fixture.scenario === "recovery" ? { + probeRecoveryState: () => recovered ? "none" as const : "journal" as const, + beforeRecovery: async () => { enterRecovery(); await recoveryRelease; }, + } : {}), + }, +}); +const servers: Array> = []; +const headers = (token = fixture.bearer, id = fixture.accountId) => + new Headers({ authorization: `Bearer ${token}`, "chatgpt-account-id": id }); +const admit = async ( + mode: "direct" | "pool" = "direct", + options: Parameters[3] = {}, + policy = config, +) => { + try { const context = await resolveCodexAuthContext(headers(), policy, mode, options); return { admitted: true, kind: context.kind }; } + catch (error) { return { admitted: false, error: (error as Error).name }; } +}; +const wire = async (token = fixture.bearer, id = fixture.accountId) => { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { ...Object.fromEntries(headers(token, id)), "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.6-sol", input: "synthetic startup probe", stream: false }), + }), config, { model: "", provider: "" }); + const text = await response.text(); + return { status: response.status, hardLockError: text.includes("codexMainAccountHardLock") }; +}; + +try { + servers.push(start()); + let firstServerSettled: ReturnType | undefined; + if (fixture.scenario === "second-listener" || fixture.scenario === "renewed-listener") { + await waitForNativeMainStartupGate(); + firstServerSettled = observe(); + if (fixture.scenario === "renewed-listener") { + writeFileSync(manager.context.authPath, JSON.stringify({ tokens: { + access_token: fixture.bearer, refresh_token: "fixture-refresh", account_id: fixture.accountId, + } })); + } + config = { ...config, codexMainAccountHardLock: true }; + saveConfig(config); + servers.push(start()); + } + const firstAdmission = await admit(); + let heldRecovery: Record | undefined; + let laterRecovery: Record | undefined; + let retainedUnknown: Array> | undefined; + let validReplacement: Record | undefined; + const otherAccountId = "hard-lock-verified-other"; + const otherBearer = `header.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 86_400, + "https://api.openai.com/auth": { chatgpt_account_id: otherAccountId } })).toString("base64url")}.signature`; + if (fixture.scenario === "recovery") { + await recoveryEntered; + heldRecovery = { + observation: observe(), + poolFallback: await admit("pool", { requestScopedMainCredential: true }), + mainPin: await admit("pool", { requestScopedMainCredential: true }, { ...config, activeCodexAccountPinned: "__main__" }), + storedAlternative: await admit("pool", { accountId: "startup-pool" }, { + ...config, codexAccounts: [{ id: "startup-pool", email: "pool@example.test", isMain: false }], + }), + automaticAlternative: await admit("pool", { requestScopedMainCredential: true }, { + ...config, codexAccounts: [{ id: "startup-pool", email: "pool@example.test", isMain: false }], + }), + }; + releaseRecovery(); + } + if (fixture.scenario === "stage-retry") { + await waitForNativeMainStartupGate(); + laterRecovery = { blocked: observe() }; + await oldSweep.entered; + oldSweep.release(); + await waitForReady(); + laterRecovery.sweepCalls = sweepCalls; + } + if (fixture.scenario === "manual-recovery") { + await waitForNativeMainStartupGate(); + laterRecovery = { blocked: observe() }; + const request = new Request("http://localhost/api/native-main-profiles/recover", { + method: "POST", headers: { "content-type": "application/json" }, body: "{}", + }); + const response = await handleNativeProfileAPI(request, new URL(request.url), config, { + manager, probeRecoveryState: () => recovered ? "none" : "manual", + }); + laterRecovery.apiStatus = response?.status; + await response?.text(); + laterRecovery.pending = observe(); + if (nativeMainStartupGateSnapshot().status === "blocked") { + await bindingSweep.entered; + const firstFlight = waitForNativeMainStartupGate(); + laterRecovery.duplicateCompleted = completeNativeMainRecovery(manager.context.homeId); + laterRecovery.joined = firstFlight === waitForNativeMainStartupGate(); + laterRecovery.recoveryCalls = recoveryCalls; + bindingSweep.release(); + } + } + if (fixture.scenario === "stale-sweep") { + await waitForNativeMainStartupGate(); + await oldSweep.entered; + writeRecoveredAuth(); + blockNativeMainRecovery(manager.context.homeId); + completeNativeMainRecovery(manager.context.homeId); + await bindingSweep.entered; + oldSweep.release(); + // Deliver the older sweep result while the new binding's explicit barrier is still held. + await Bun.sleep(0); + laterRecovery = { pending: observe(), admission: await admit() }; + bindingSweep.release(); + } + if (fixture.scenario === "retained-unknown-binding") { + await waitForNativeMainStartupGate(); + retainedUnknown = []; + for (const kind of ["malformed", "conflicting", "conflicting-tokens"] as const) { + writeFileSync(manager.context.authPath, kind === "malformed" ? "{" : JSON.stringify({ tokens: { + access_token: otherBearer, account_id: fixture.accountId, + ...(kind === "conflicting-tokens" ? { id_token: fixture.bearer } : {}), + } })); + servers.push(start()); + await waitForNativeMainStartupGate(); + retainedUnknown.push({ kind, observed: observe(), main: await wire(), + other: await wire(otherBearer, otherAccountId) }); + } + } + const settled = await waitForNativeMainStartupGate(); + const after = observe(); + const settledAdmission = await admit(); + const beforePrimaryUpstreamCalls = upstreamCalls; + const response = await wire(); + const primaryUpstreamCalls = upstreamCalls - beforePrimaryUpstreamCalls; + const originalResponse = ["recovery", "renewed-listener", "manual-recovery", "stale-sweep"].includes(fixture.scenario) + ? await wire(fixture.originalBearer, fixture.originalAccountId) : undefined; + if (fixture.scenario === "retained-unknown-binding") { + writeFileSync(manager.context.authPath, JSON.stringify({ tokens: { access_token: otherBearer, account_id: otherAccountId } })); + servers.push(start()); + await waitForNativeMainStartupGate(); + validReplacement = { oldMatched: matchesMainQuotaCredential(fixture.bearer, fixture.accountId), + newMatched: matchesMainQuotaCredential(otherBearer, otherAccountId), policy: getMainPolicyQuota(), old: await wire() }; + } + console.log("POLICY_STARTUP_RESULT=" + JSON.stringify({ + scenario: fixture.scenario, before, listeners, firstServerSettled, firstAdmission, heldRecovery, laterRecovery, + retainedUnknown, validReplacement, + settled, after, settledAdmission, response, beforePrimaryUpstreamCalls, primaryUpstreamCalls, originalResponse, + unexpectedNetwork, + policyReadsPinned: tokenReads.every(path => path === manager.context.authPath), + })); +} finally { + releaseRecovery(); + oldSweep.release(); + bindingSweep.release(); + Bun.serve = realServe; + for (const server of servers.reverse()) await server.stop(true); + tokenSpy.mockRestore(); + setIcaclsRunnerForTests(null); +} diff --git a/tests/oauth/chatgpt-oauth.test.ts b/tests/oauth/chatgpt-oauth.test.ts index 24213b7e44..29f35f808d 100644 --- a/tests/oauth/chatgpt-oauth.test.ts +++ b/tests/oauth/chatgpt-oauth.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { decodeJwtPayload, extractAccountId, extractEmail } from "../../src/oauth/chatgpt"; +import { decodeJwtPayload, extractAccountId, extractAccountIdClaims, extractEmail } from "../../src/oauth/chatgpt"; function fakeJwt(payload: Record): string { const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); @@ -63,6 +63,39 @@ describe("ChatGPT OAuth JWT helpers", () => { expect(extractAccountId(undefined, undefined)).toBeUndefined(); }); + test("extractAccountIdClaims accepts agreeing account-id encodings", () => { + const jwt = fakeJwt({ + chatgpt_account_id: "acct_same", + "https://api.openai.com/auth": { chatgpt_account_id: "acct_same" }, + }); + expect(extractAccountIdClaims(jwt)).toEqual({ accountId: "acct_same", conflict: false }); + }); + + test("extractAccountIdClaims flags conflicting account-id encodings", () => { + const jwt = fakeJwt({ + chatgpt_account_id: "acct_top", + "https://api.openai.com/auth": { chatgpt_account_id: "acct_ns_other" }, + }); + expect(extractAccountIdClaims(jwt)).toEqual({ accountId: "acct_top", conflict: true }); + }); + + test("extractAccountIdClaims treats organizations as membership, never as a conflict", () => { + // id_token_add_organizations makes org ids legitimately differ from the account id. + const jwt = fakeJwt({ + chatgpt_account_id: "acct_main", + organizations: [{ id: "org_member" }, { id: "org_other" }], + }); + expect(extractAccountIdClaims(jwt)).toEqual({ accountId: "acct_main", conflict: false }); + const orgOnly = fakeJwt({ organizations: [{ id: "org_fallback" }, { id: "org_second" }] }); + expect(extractAccountIdClaims(orgOnly)).toEqual({ accountId: "org_fallback", conflict: false }); + }); + + test("extractAccountIdClaims reads nothing from a claim-free or malformed token", () => { + expect(extractAccountIdClaims(fakeJwt({ sub: "user" }))).toEqual({ accountId: undefined, conflict: false }); + expect(extractAccountIdClaims(undefined)).toEqual({ accountId: undefined, conflict: false }); + expect(extractAccountIdClaims("not-a-jwt")).toEqual({ accountId: undefined, conflict: false }); + }); + test("extractEmail extracts and lowercases email", () => { const jwt = fakeJwt({ email: "User@Example.COM" }); expect(extractEmail(jwt)).toBe("user@example.com");