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..329b306194 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,11 @@ ocx login anthropic 아닙니다. 진행 중 요청, 식별되지 않은 키링 계정, 프록시 밖 요청은 사용량을 더 쓸 수 있습니다. 추가 계정과 다른 공급자는 계속 사용할 수 있습니다. +보호 기능이 켜져 있으면 소유권이 확인된 시작 과정에서 native 프로필 복구와 정리를 마친 뒤 +메인 인증정보의 메모리 내 식별 연결을 복원하므로, 저장된 99% 차단이 재시작 후에도 유지됩니다. +연결을 준비하는 동안 호출자 인증정보를 쓰는 Direct 또는 메인 fallback 요청은 잠시 503을 +받을 수 있습니다. 이 초기화를 위해 다른 서비스 소유이거나 소유권이 미확인인 홈의 인증정보를 읽지는 않습니다. + 차단 중에는 해당 메인 계정의 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..2f30059a5f 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,11 @@ 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 or main-fallback requests can briefly receive 503 while that binding is +pending. 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..7a3348858a 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 { extractAccountId } from "../oauth/chatgpt"; import { forgetCodexAccountPause } from "./account-pause"; import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority"; import { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh-state"; @@ -75,6 +76,32 @@ 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 { + const result = readCodexTokensResult(authPath); + 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. + const idTokenAccountId = extractAccountId(tokens.id_token); + const accessTokenAccountId = extractAccountId(undefined, tokens.access_token); + 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..878960d261 100644 --- a/src/codex/auth-collision.ts +++ b/src/codex/auth-collision.ts @@ -31,12 +31,13 @@ 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. * Never returns or logs the raw error or any token material. */ -export function readCodexTokensResult(): CodexTokenReadResult { +export function readCodexTokensResult(authPath = join(resolveCodexHomeDir(), "auth.json")): CodexTokenReadResult { let raw: string; try { - raw = readFileSync(join(resolveCodexHomeDir(), "auth.json"), "utf-8"); + raw = 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/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..ae864c5306 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"; @@ -138,6 +141,127 @@ afterEach(() => { }); 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", "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 } : {}), + "https://api.openai.com/auth": { chatgpt_account_id: scenario === "conflicting-token-identities" + ? "hard-lock-conflicting-access-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 === "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"; + 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"); + } + 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/main-account-policy-startup-child.ts b/tests/helpers/main-account-policy-startup-child.ts new file mode 100644 index 0000000000..534ee3cdb7 --- /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" | "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); +}