diff --git a/devlog/_plan/261014_native_main_memory_credentials/10_native_main_memory_design.md b/devlog/_plan/261014_native_main_memory_credentials/10_native_main_memory_design.md new file mode 100644 index 00000000000..1f6f004ac60 --- /dev/null +++ b/devlog/_plan/261014_native_main_memory_credentials/10_native_main_memory_design.md @@ -0,0 +1,75 @@ +# Native-main in-memory credential store + +## Scope and base + +This unit implements the in-memory alternative to the hard-link publication guard for native-main `CODEX_HOME/auth.json`. The inspected base is `a63267c13`; the auth implementation files named in the supplied behavior report have no diff from `3e58c2ce3` to this base. + +The supplied behavior report is useful for the main refresh and profile-switch paths, but its reader inventory is not exhaustive for this checkout. The source search also found the account-pause reader, Orca import's main-identity check, and direct profile-manager envelope reads. The implementation and tests cover those paths. + +## `auth.json` reader and writer inventory at the base + +### Effective Codex home credential (`/auth.json`) + +| Source path | Operation at the base | New boundary | +| --- | --- | --- | +| `src/codex/main-account.ts` | `readMainAuthJsonCredential()` reads/decodes the file for status, refresh, and reauth. Refresh and reauth each call `atomicWriteFile()`. | Read through the native-main memory store; refresh and reauth publish through its writer. | +| `src/codex/auth-collision.ts` | `readCodexTokensResult()` uses `readFileSync()` or `readBounded()`; `readCodexTokens()` and `getMainChatgptAccountId()` are memory-facing callers. | Parse the memory snapshot; preserve the result API and failure statuses. | +| `src/codex/auth-api/main-account-probe.ts` | Reads through `readCodexTokensResult()` both for the initial probe and its bounded current-credential check. | Continue through the memory-backed reader. | +| `src/codex/auth-api/account-pause-group.ts` | Reads via `readCodexTokensResult()` and separately calls `readBounded()` to retain the auth envelope. | Parse both credential and envelope from the memory snapshot. | +| `src/codex/account-lifecycle.ts` | `initializeMainAccountPolicyBinding()` reads a supplied auth path through `readCodexTokensResult(..., { bounded: true })`. | Initialize/observe through the store, then bind from memory. | +| `src/codex/native-profile-manager.ts` | Reads the live envelope for registration, switch, recovery, verification, and doctor. Writes the selected envelope, switch rollback, and recovery rollback with its async atomic writer. | Read live envelopes from memory; send all live auth writes through the central writer while keeping the encrypted journal, boundary hooks, verification, and rollback. Staging `auth.json` files remain separate. | +| `src/codex/native-profile-store.ts` | Its generic `readNativeEnvelopeResult(path)`/`readNativeEnvelope(path)` helpers perform bounded reads; the manager uses them on the live path. | The manager's live-path default uses the memory snapshot. Generic readers remain for non-live/staged paths and independent store tests. | +| `src/codex/orca-import.ts` | `prepare()` bounded-reads `/auth.json` to reserve the main identity during an explicit offline import. | Read the memory snapshot for the effective native-main path. Orca-managed homes remain independent source files. | +| `src/codex/home.ts` | Checks whether `auth.json` exists to decide whether the local Codex home is in use; it does not read credential bytes. | Path discovery remains a filesystem-presence check, outside credential consumption. | +| `src/cli/doctor.ts` | Reports whether `/auth.json` exists; it does not read credential bytes. | Diagnostic existence check remains unchanged. | +| `src/codex/native-main-auth-temp.ts` | Removes only owned stale `auth.json.ocx.*.tmp` residues; it does not write or read the canonical credential. | Temporary-file cleanup remains separate from the canonical single writer. | + +### Other `auth.json` paths found by the whole-`src/` search + +| Source path | Operation at the base | Path and relationship | +| --- | --- | --- | +| `src/oauth/store.ts` | Reads and writes provider OAuth credentials and keeps the one-time migration backup. | `/auth.json`, a separate provider store. | +| `src/server/configured-credentials.ts` | Reads provider OAuth credentials when rebuilding configured-credential state. | Same OpenCodex provider store. | +| `src/providers/devin-provider-merge-migration.ts` | Snapshots the store before moving the retired Devin provider slot and rekeys that slot through the OAuth store API. | Same OpenCodex provider store. | +| `src/config.ts` | Re-pins the existing secret-file permissions. | Same OpenCodex provider store; it does not read credential bytes. | +| `src/oauth/local-token-detect.ts` | Reads the installed Grok CLI credential. | A separate Grok CLI credential store. | +| `src/oauth/meta-muse.ts` | Reads a pointer with no credential secret. | A separate Muse configuration pointer. | +| `src/oauth/command-code.ts` | Reads CommandCode's local login record. | A separate CommandCode login record. | +| `src/codex/orca-auth-source.ts`, `src/codex/orca-import.ts` | Reads explicitly managed Orca account-home credentials. The import also read the effective main file directly at base. | UUID-owned Orca homes; the effective-main import check now uses memory. | +| `src/codex/native-profile-manager.ts` | Reads a staged login and securely removes its staged auth file. | Instance-local plaintext staging home, not the live CODEX_HOME file. | +| `src/codex/home.ts`, `src/cli/doctor.ts` | Test CODEX_HOME auth path presence; doctor also checks the OpenCodex provider-store path and parent-directory writeability. | Discovery and diagnostics only; neither reads token bytes. | +| `src/codex/native-main-auth-temp.ts` | Removes only owned stale `auth.json.ocx.*.tmp` residues. | Same-directory temporary write artifacts, not the canonical credential. | + +`src/server/responses/core-auth.ts` and several other files mention auth.json only in comments; they do not open or write it. + +## Requirements and implementation map + +| # | Owner requirement | Implementation and proof | +| --- | --- | --- | +| 1 | One writer for effective native-main auth, for refresh, device reauth, profile switch, rollback, and recovery. | `src/codex/native-main-credentials.ts` owns temp-file/rename publication; callers use its APIs. A source guard rejects other effective-path readers/writers. | +| 2 | OpenCodex readers use memory, never file bytes. | The store owns the raw memory snapshot; `main-account.ts`, `auth-collision.ts`, account-pause, lifecycle binding, profile manager, and Orca import read it. A source guard covers the effective path. | +| 3 | Reliable observing: directory watcher, short stat-first timer with conditional hashing, plus pre-refresh and pre-write observation; no handle keeps the process alive and shutdown closes both. | The store watches the CODEX_HOME directory, checks on the configured interval, hashes only changed stat signatures, and shares one observer across triggers. Tests simulate a dropped event and verify the timer path. | +| 4 | Adopt valid Codex content; preserve memory through empty, partial, malformed, or unreadable states and never overwrite a hiccup. | The observer accepts usable token envelopes or an API-key envelope, adopts any valid external change regardless of token expiry or account fields, and retains its last good snapshot on hiccups. | +| 5 | Treat deletion as logout only after grace; return a restored valid file within grace; do not refresh/write during grace; permanently clear memory after confirmed deletion. | `NATIVE_MAIN_AUTH_DELETE_GRACE_MS` is a named ten-minute duration to cover the interactive browser-login gap. Store state and tests cover grace, restore, expiry, and no write-back. | +| 6 | Preserve safe publication: temp file + rename, re-observe immediately before rename, and adopt a valid external file on conflict. | The central writer wraps existing atomic writers with a final shared observation and compares an immutable expected file identity/digest and staged-candidate generation before rename; any observed valid external change supersedes the pending candidate. | +| 7 | Publish promptly after refresh and retry non-conflict write failures without debounce; retain a successful grant in memory. | Refresh calls the writer in the same claimed operation; transient I/O failures retain the grant and schedule an unref'd retry starting at 50 ms, exponentially backed off to one second. Invalid, unreadable, and grace-period missing files retain the rotated grant without replacing the hiccup. | +| 8 | Keep the CODEX_HOME exclusive claim around cross-process refresh and write coordination. | Refresh and reauth retain their existing outer claim; native-profile API and startup transactions hold the claim around the central writer; the retry path reacquires the same claim. | + +## Tests-first baseline + +Before implementation, the new memory-store regression file was run against unchanged base `a63267c13`: + +```text +bun test tests/codex-integration/native-main-credential-memory.test.ts +0 pass / 12 fail +``` + +The failures showed the intended gaps: empty and half-written files lost the last good credential; delete-and-restore had no in-memory grace state; watcher/timer/pre-write observation and confirmed-deletion APIs were absent; a refused refresh publication still raised `MainAuthJsonChangedDuringRefreshError`; and the source guard found direct file reads in `main-account.ts`, `auth-collision.ts`, `account-pause-group.ts`, and `native-profile-manager.ts`. These were the base-red failures; implementation-era and final validation results are recorded in the change report. + +## Deletion grace choice + +The grace is ten minutes. The relevant upstream behavior deletes `auth.json` before the interactive browser login finishes; a grace measured in seconds would cover a rewrite gap but not a user completing the browser step. Ten minutes covers a normal browser handoff while still making an abandoned login converge to logout in a bounded period. Memory remains usable during that interval, but all refresh and write attempts are blocked. Once confirmed, the cache and pending refresh publication are cleared, so neither an in-memory retry nor a later caller can recreate the deleted file. + +## Remaining accepted risk + +A Codex in-place write can still land between the final observation and OpenCodex's rename. The observer narrows that interval and preserves valid changes seen before the rename, but only a lock shared with Codex can close it. The upstream Codex writer does not currently take that lock. The hard-link guard prototype remains out of scope. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 08bc166c3b9..e6eebbaba15 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -44,6 +44,7 @@ runs helper features around provider requests. | `codexMainAccountHardLockThresholds?` | `{ short?: number; long?: number }` | `{ short: 90, long: 98 }` | Independent 5h and weekly/monthly thresholds. Integers 80–100, with `short <= long`. Invalid persisted fields fall back to defaults; the settings API rejects invalid writes before mutation. | | `codexMainAccountHardLockFallback?` | `{ enabled?: boolean; model?: string; reasoningEffort?: string }` | off | Extended hard-lock settings: while the lock blocks and no Codex account can serve the request, qualifying native-Codex-model requests reroute to `model` with `reasoningEffort` instead of failing. `enabled: false` pauses the reroute and keeps the model. An absent section or model keeps the refusal behavior; a usable Pool account still serves first. `reasoningEffort` must be a declared effort; invalid persisted sections degrade to off and the settings API rejects invalid writes before mutation. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). | | `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Who refreshes the native main ChatGPT login in `CODEX_HOME`. `"codex"` asks the Codex binary (app-server `getAuthStatus`, Codex 0.130.0 or newer, found through the same runtime resolution as `ocx sync`, including the ChatGPT app bundle) and never writes `auth.json`; this also works when Codex stores its login in the OS keyring. `"opencodex"` keeps OpenCodex's guarded `auth.json` writer. Unset picks `"codex"` when a supported binary is found; without one, both unset and `"codex"` fall back to the guarded writer. `ocx doctor` shows the active mode under Native main refresh. | +| `nativeMainAuthGraceSeconds?` | `number` | `60` | How long OpenCodex keeps the last good native main login in memory while `auth.json` in `CODEX_HOME` is missing, empty, half-written, or unreadable. The period starts at the first such observation, and a read error does not restart it. A complete file ends it and is used at once, so a new login or an account switch is never delayed. When the period ends without a complete file, the login is cleared from memory; this is how a logout takes effect. An integer from 1 to 86400; an invalid value is ignored with a warning on load and refused on write. Read when the credential store starts: restart the proxy after a change. | | `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Servers sharing this configuration directory coordinate reservations and settlements so one process does not replace another's request record. Logs carry a hashed account key only. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. | | `spendPoolAliases?` | `Record` | unset | Explicit historical salted pool-alias to canonical provider mappings. Keep this key at the top level, outside `spend`. See [Historical pool continuity](#historical-pool-continuity). | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d9502640531..c12041b9136 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -651,7 +651,7 @@ "codex-log-guard-processes.test.ts": "codex-integration", "codex-log-guard-protection.test.ts": "codex-integration", "codex-log-guard-status-zero-write.test.ts": "codex-integration", - "codex-main-account-refresh.test.ts": "codex-integration", + "codex-main-account-refresh.test.ts": "codex-integration", "native-main-credential-memory.test.ts": "codex-integration", "native-main-credential-literal-guard.test.ts": "codex-integration", "codex-main-rotation.test.ts": "codex-integration", "codex-management-convergence.test.ts": "codex-integration", "codex-metadata-integrity.test.ts": "codex-integration", diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 2753a7362f6..448551e62d6 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -25,6 +25,7 @@ import { readInstalledServiceToken } from "../lib/service-secrets"; import { PROXY_ENV_KEYS, proxyEnvPresent } from "../lib/proxy-env"; import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability"; import { readCodexTokens } from "../codex/auth-collision"; +import { observeNativeMainAuthFile } from "../codex/native-main-credentials"; import { withNativeMainSharedClaim } from "../codex/native-main-claim"; import { probeNativeProfileRecoveryState, resolveNativeProfileContext } from "../codex/native-profile-store"; import { NativeProfileError } from "../codex/native-profile-types"; @@ -308,12 +309,20 @@ const PROBE_TIMEOUT_MS = 8000; export type PathRow = { label: string; path: string; exists: boolean }; +function nativeMainAuthPathExists(path: string): boolean { + const observation = observeNativeMainAuthFile(path); + const exists = observation.fileStatus !== "missing"; + observation.snapshot?.raw.fill(0); + observation.raw?.fill(0); + return exists; +} + export function collectPaths(): PathRow[] { const codexHome = resolveCodexHomeDirImpl(); const opencodexHome = getConfigDir(); return [ { label: "CODEX_HOME", path: codexHome, exists: existsSync(codexHome) }, - { label: "CODEX_HOME/auth.json", path: join(codexHome, "auth.json"), exists: existsSync(join(codexHome, "auth.json")) }, + { label: "CODEX_HOME/auth.json", path: join(codexHome, "auth.json"), exists: nativeMainAuthPathExists(join(codexHome, "auth.json")) }, { label: "OPENCODEX_HOME", path: opencodexHome, exists: existsSync(opencodexHome) }, { label: "OPENCODEX_HOME/config.json", path: getConfigPath(), exists: existsSync(getConfigPath()) }, ]; diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 65e35aa5f10..4c3e8b08717 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -20,6 +20,7 @@ import { clearCodexPoolRefreshFailure } from "./pool-refresh-backoff"; import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "./main-account-cache"; import { extractAccountId, extractAccountIdClaims } from "../oauth/chatgpt"; +import { observeNativeMainAuthFile } from "./native-main-credentials"; import { forgetCodexAccountPause } from "./account-pause"; import { forgetCodexAccountCreditUse } from "./account-credit-use"; import { setCodexAccountUseRemainingQuota } from "./account-use-remaining"; @@ -101,8 +102,12 @@ export function reconcileMainCodexAccountRuntimeState(): boolean { * 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. + // Startup observes the pinned owned path inside the exclusive claim. The observer's bounded + // read is the only credential-file read; every policy consumer below uses its memory copy. + const observed = observeNativeMainAuthFile(authPath); + observed.raw?.fill(0); + observed.snapshot?.raw.fill(0); + if (!observed.canRefreshOrWrite) return false; const result = readCodexTokensResult(authPath, { bounded: true }); if (result.status !== "ok") return false; const { tokens } = result; diff --git a/src/codex/account-usability.ts b/src/codex/account-usability.ts index df7ef92d5be..00fceafcaf3 100644 --- a/src/codex/account-usability.ts +++ b/src/codex/account-usability.ts @@ -95,7 +95,7 @@ export function codexAccountUnusableReason( } const credential = getMainAccountCredentialStatus(); if (credential.refreshGrantRejected) return "needs_reauth"; - if (isAccountNeedsReauth(accountId) && !credential.hasRefreshGrant) return "needs_reauth"; + if (isAccountNeedsReauth(accountId)) return "needs_reauth"; // Main account: a refresh grant is enough to route; materialization refreshes before I/O. const mainLive = options.isMainAccountTokenLive ? options.isMainAccountTokenLive() diff --git a/src/codex/auth-api/account-pause-group.ts b/src/codex/auth-api/account-pause-group.ts index 9a43b18f3a9..766a50a85bd 100644 --- a/src/codex/auth-api/account-pause-group.ts +++ b/src/codex/auth-api/account-pause-group.ts @@ -7,7 +7,8 @@ import { linkedCodexAccountIds } from "../account-identity"; import { resolveCodexHomeDir } from "../home"; import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; import { withNativeMainSharedClaim } from "../native-main-claim"; -import { MAX_AUTH_BYTES, readBounded, resolveNativeProfileContext } from "../native-profile-store"; +import { resolveNativeProfileContext } from "../native-profile-store"; +import { getNativeMainAuthMemory } from "../native-main-credentials"; import { NativeProfileError } from "../native-profile-types"; import { isNativeMainClaimUnavailable, nativeMainProfileBusyResponse } from "./http"; @@ -17,8 +18,10 @@ type MainAuth = CodexTokenReadResult | { status: "api-key-only" }; function readMainAuth(authPath: string): MainAuth { const main = readCodexTokensResult(authPath, { bounded: true }); if (main.status !== "invalid") return main; + const observation = getNativeMainAuthMemory(authPath); + if (observation.status !== "ok" || !observation.snapshot) return main; try { - const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8")); + const j: unknown = JSON.parse(observation.snapshot.raw.toString("utf-8")); if (j === null || typeof j !== "object" || Array.isArray(j)) return main; const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown }; const apiKeyOnly = envelope.tokens == null @@ -27,6 +30,9 @@ function readMainAuth(authPath: string): MainAuth { return apiKeyOnly ? { status: "api-key-only" } : main; } catch { return main; + } finally { + observation.snapshot.raw.fill(0); + observation.raw?.fill(0); } } diff --git a/src/codex/auth-collision.ts b/src/codex/auth-collision.ts index 1dc17e897e0..f6a118f3dde 100644 --- a/src/codex/auth-collision.ts +++ b/src/codex/auth-collision.ts @@ -1,4 +1,3 @@ -import { readFileSync } from "node:fs"; import { join } from "node:path"; import { getCodexAccountCredential } from "./account-store"; import { loadConfig } from "../config"; @@ -6,7 +5,10 @@ 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"; +import { + getNativeMainAuthMemory, + nativeMainAuthStrictNoFollowAllowed, +} from "./native-main-credentials"; export interface CodexTokens { access_token: string; @@ -24,32 +26,29 @@ export type CodexTokenReadResult = | { status: "ok"; tokens: CodexTokens } | { status: "missing" | "invalid" | "unreadable" }; -function hasErrnoCode(error: unknown, code: string): boolean { - return typeof error === "object" && error !== null && "code" in error - && (error as { code?: unknown }).code === code; -} - /** - * 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. + * Classifies the native-main credential from the in-memory store. An already-owned lifecycle may + * supply its pinned auth path instead of resolving ambient home. File observation and bounds are + * owned by the central store; bounded callers also retain the prior no-follow symlink policy. + * Never returns or logs raw file errors or token material. */ export function readCodexTokensResult( authPath = join(resolveCodexHomeDir(), "auth.json"), options?: { bounded?: boolean }, ): CodexTokenReadResult { - let raw: string; - try { - 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" }; + const observation = getNativeMainAuthMemory(authPath); + if (observation.status !== "ok" || !observation.snapshot) { + const status = observation.status === "missing" || observation.status === "invalid" + ? observation.status + : "unreadable"; + return { status }; + } + if (options?.bounded && !nativeMainAuthStrictNoFollowAllowed(observation)) { + observation.snapshot.raw.fill(0); + observation.raw?.fill(0); + return { status: "unreadable" }; } + const raw = observation.snapshot.raw.toString("utf-8"); try { const j = JSON.parse(raw) as { tokens?: { access_token?: string; account_id?: string; id_token?: string }; @@ -65,6 +64,9 @@ export function readCodexTokensResult( }; } catch { return { status: "invalid" }; + } finally { + observation.snapshot.raw.fill(0); + observation.raw?.fill(0); } } diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index c4cd30b325d..5855aba1927 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -121,7 +121,7 @@ function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean { * Exported for exactly one reason: two copies of this fence is how #4850 happened. Final * authentication honoured the ownership boundary while request preview, computing its fence from * recovery and drain state alone, still handed pool eligibility the default liveness probe and - * opened the physical `auth.json` twice per spawn. A predicate one caller can forget is a + * read the native-main credential twice per spawn. A predicate one caller can forget is a * predicate the other caller will eventually disagree with. * * Read-free by construction, which is what makes it usable on the fenced side. Every input is @@ -1599,7 +1599,7 @@ async function resolveCodexAuthContextUnannotated( } if (accountId === MAIN_CODEX_ACCOUNT_ID) { - // Main account in rotation: refresh auth.json before upstream I/O and fail closed if it vanished. + // Main account in rotation: refresh the memory-backed native credential before upstream I/O. let token: { accessToken: string; chatgptAccountId: string } | null; let mainQuotaWriter = captureObservedMainWriter(); try { diff --git a/src/codex/desktop-compatibility/native-identity.ts b/src/codex/desktop-compatibility/native-identity.ts index e3f97d59e86..12cf855fbbb 100644 --- a/src/codex/desktop-compatibility/native-identity.ts +++ b/src/codex/desktop-compatibility/native-identity.ts @@ -1,5 +1,5 @@ -import { readFileSync, statSync, type BigIntStats } from 'node:fs'; -import { createHash, randomUUID } from 'node:crypto'; +import { randomUUID } from 'node:crypto'; +import { observeNativeMainAuthFile } from '../native-main-credentials'; import type { UsageIdentity } from './usage-controller'; type Credential = { identity: UsageIdentity; accessToken: string }; @@ -9,26 +9,33 @@ const equal = (a: UsageIdentity, b: UsageIdentity) => a.id === b.id && a.userId /** Local native credentials remain in memory and are never returned by the public reader. */ export function createNativeIdentityReader(authPath: string, upstreamFetch: typeof fetch = fetch) { const readerId = randomUUID(); let generation = 0, previousSnapshot: string | undefined; - const stamp = (value: BigIntStats) => - `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`; const readCredential = (): Credential => { - const before = statSync(authPath, { bigint: true }); - if (before.size > 1048576n) throw new Error('Unexpected native auth size'); - const text = readFileSync(authPath, 'utf8'), after = statSync(authPath, { bigint: true }); - if (stamp(before) !== stamp(after)) throw new Error('Native credentials changed during read'); - const snapshot = `${stamp(after)}:${createHash('sha256').update(text).digest('hex')}`; - if (snapshot !== previousSnapshot) { previousSnapshot = snapshot; generation++; } - const auth = JSON.parse(text); - if (auth.auth_mode !== 'chatgpt' || typeof auth.tokens?.id_token !== 'string' - || typeof auth.tokens?.access_token !== 'string' || !auth.tokens.access_token - || typeof auth.tokens?.account_id !== 'string') throw new Error('Native login required'); - // These local claims are only a binding hint. Activation additionally verifies upstream. - const claims = JSON.parse(Buffer.from(auth.tokens.id_token.split('.')[1], 'base64url').toString('utf8'))['https://api.openai.com/auth']; - if (!claims || claims.chatgpt_account_id !== auth.tokens.account_id - || typeof claims.chatgpt_user_id !== 'string' || !claims.chatgpt_user_id - || !['plus', 'pro'].includes(claims.chatgpt_plan_type)) throw new Error('Unsupported identity'); - return { identity: { id: auth.tokens.account_id, userId: claims.chatgpt_user_id, - plan: claims.chatgpt_plan_type, structure: 'personal', credentialGeneration: `${readerId}:${generation}` }, accessToken: auth.tokens.access_token }; + const read = observeNativeMainAuthFile(authPath); + try { + const snapshot = read.snapshot; + if (read.status !== 'ok' || read.fileStatus !== 'valid' || !snapshot) throw new Error('Native login required'); + if (snapshot.size > 1048576) throw new Error('Unexpected native auth size'); + const text = snapshot.raw.toString('utf8'); + const key = `${snapshot.identity.dev}:${snapshot.identity.ino}:${snapshot.size}:${snapshot.mtimeMs}:${snapshot.ctimeMs}:${snapshot.digest}`; + if (key !== previousSnapshot) { + previousSnapshot = key; + generation++; + } + const auth = JSON.parse(text); + if (auth.auth_mode !== 'chatgpt' || typeof auth.tokens?.id_token !== 'string' + || typeof auth.tokens?.access_token !== 'string' || !auth.tokens.access_token + || typeof auth.tokens?.account_id !== 'string') throw new Error('Native login required'); + // These local claims are only a binding hint. Activation additionally verifies upstream. + const claims = JSON.parse(Buffer.from(auth.tokens.id_token.split('.')[1], 'base64url').toString('utf8'))['https://api.openai.com/auth']; + if (!claims || claims.chatgpt_account_id !== auth.tokens.account_id + || typeof claims.chatgpt_user_id !== 'string' || !claims.chatgpt_user_id + || !['plus', 'pro'].includes(claims.chatgpt_plan_type)) throw new Error('Unsupported identity'); + return { identity: { id: auth.tokens.account_id, userId: claims.chatgpt_user_id, + plan: claims.chatgpt_plan_type, structure: 'personal', credentialGeneration: `${readerId}:${generation}` }, accessToken: auth.tokens.access_token }; + } finally { + read.raw?.fill(0); + read.snapshot?.raw.fill(0); + } }; const readCurrentIdentity = async (): Promise => { try { return readCredential().identity; } catch { return null; } diff --git a/src/codex/inject-coordination.ts b/src/codex/inject-coordination.ts index 04a959a5633..75fcb2735ff 100644 --- a/src/codex/inject-coordination.ts +++ b/src/codex/inject-coordination.ts @@ -5,7 +5,7 @@ * sequence it is, rather than doubling in length around the lock. */ import { createHash } from "node:crypto"; -import { existsSync, lstatSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, unlinkSync } from "node:fs"; import { publishCodexArtifact } from "./inject/config-write-section"; import { withConfigWriteLockHeld, type LockHandle } from "./config-write-lock"; @@ -385,7 +385,7 @@ export function restoreCodexPreImages( publishCodexArtifact(path, held, (destination, hooks) => { if (bytes === null) { hooks.validateBeforeRename?.(destination); - if (existsSync(destination)) require("node:fs").unlinkSync(destination); + if (existsSync(destination)) unlinkSync(destination); hooks.afterRename?.(destination); } else if (readOrNull(destination) !== bytes) atomicWriteFile(destination, bytes, undefined, hooks); }); diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 29595b8bffd..bf2f8c17fde 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -1,9 +1,7 @@ import { observeMainDecisionCredentialUsable } from "./main-account-cache"; import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure } from "./chatgpt-refresh-failure"; import { readBoundedResponseBody } from "../lib/bounded-body"; -import { createHash } from "node:crypto"; -import { readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { readCodexTokens } from "./auth-collision"; import { CHATGPT_CLIENT_ID, @@ -19,7 +17,6 @@ import { refreshGrantFingerprintForToken, withCodexRefreshFileLock, } from "./account-store"; -import { atomicWriteFile, resolveWriteTarget } from "../config/atomic-write"; import { resolveCodexHomeDir } from "./home"; import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard"; import { @@ -38,6 +35,16 @@ import { resolveNativeMainRefreshMode, type CodexAuthStatus, } from "./native-main-refresh-mode"; +import { + getNativeMainAuthMemory, + NativeMainAuthChangedError, + nativeMainAuthFence, + nativeMainAuthFenceMatches, + observeNativeMainAuthFile, + persistRefreshedNativeMainAuthFile, + writeNativeMainAuthFileSyncClaimed, + type NativeMainAuthWriteFence, +} from "./native-main-credentials"; export { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; @@ -53,15 +60,7 @@ let beforeMainAuthJsonRenameForTests: (() => void) | null = null; type MainAuthJsonCredential = { path: string; - rawSha256: string; - /** - * Filesystem identity of the file the hash was taken from. It catches a writer that - * replaces auth.json by rename with identical bytes. It does not see Codex's own writes: - * Codex rewrites auth.json in place (truncate + write), which keeps dev and ino, so only - * the content hash notices those, and only once the new bytes are visible (#2999). - * `null` when the target could not be stat'ed, which fails closed. - */ - identity: { dev: number; ino: number } | null; + fence: NativeMainAuthWriteFence; root: Record; tokens: Record; accessToken?: string; @@ -160,34 +159,14 @@ function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } -function sha256(value: string): string { - return createHash("sha256").update(value).digest("hex"); -} - -/** - * Filesystem identity of a path, or null when it cannot be read. - * - * Null is deliberately NOT "matches anything": a caller that cannot prove identity must - * fail closed, because the whole point here is refusing to overwrite a file we can no - * longer vouch for. - */ -function statIdentity(path: string): { dev: number; ino: number } | null { - try { - const stat = statSync(path); - return { dev: Number(stat.dev), ino: Number(stat.ino) }; - } catch { - return null; - } -} - function readMainAuthJsonCredential(): MainAuthJsonCredential | null { - const path = resolveWriteTarget(join(resolveCodexHomeDir(), "auth.json")); - let raw: string; - try { - raw = readFileSync(path, "utf8"); - } catch { - return null; - } + const path = resolve(join(resolveCodexHomeDir(), "auth.json")); + const observed = getNativeMainAuthMemory(path); + const snapshot = observed.snapshot; + if (observed.status !== "ok" || !snapshot) return null; + const raw = snapshot.raw.toString("utf8"); + const fence = nativeMainAuthFence(snapshot); + snapshot.raw.fill(0); try { const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; @@ -204,8 +183,7 @@ function readMainAuthJsonCredential(): MainAuthJsonCredential | null { ?? ""; return { path, - rawSha256: sha256(raw), - identity: statIdentity(path), + fence, root, tokens, ...(accessToken ? { accessToken } : {}), @@ -214,6 +192,8 @@ function readMainAuthJsonCredential(): MainAuthJsonCredential | null { }; } catch { return null; + } finally { + observed.raw?.fill(0); } } @@ -269,30 +249,21 @@ export function hasMainAccountRefreshGrant(): boolean { } function assertMainAuthJsonSnapshotUnchanged(expected: MainAuthJsonCredential): void { - const current = readMainAuthJsonCredential(); - if (!current || current.path !== expected.path || current.rawSha256 !== expected.rawSha256) { - throw new MainAuthJsonChangedDuringRefreshError(); - } - // Also refuse a rename-based replacement with identical bytes. An unreadable identity on - // either side fails closed. This narrows the window; rename(2) still replaces - // unconditionally after this check returns. - assertMainAuthJsonIdentityUnchanged(expected); -} - -function assertMainAuthJsonIdentityUnchanged(expected: MainAuthJsonCredential): void { - const identity = statIdentity(expected.path); - if (!identity - || !expected.identity - || identity.dev !== expected.identity.dev - || identity.ino !== expected.identity.ino) { - throw new MainAuthJsonChangedDuringRefreshError(); + const observed = observeNativeMainAuthFile(expected.path); + try { + if (!observed.canRefreshOrWrite || !nativeMainAuthFenceMatches(observed.snapshot, expected.fence)) { + throw new MainAuthJsonChangedDuringRefreshError(); + } + } finally { + observed.raw?.fill(0); + observed.snapshot?.raw.fill(0); } } -function persistRefreshedMainAuthJson( +async function persistRefreshedMainAuthJson( expected: MainAuthJsonCredential, refreshed: OAuthCredentials, -): { accessToken: string; chatgptAccountId: string } { +): Promise<{ accessToken: string; chatgptAccountId: string } | null> { assertNotRealCodexHomeUnderTest(resolveCodexHomeDir()); const accessToken = refreshed.access; const refreshToken = refreshed.refresh || expected.refreshToken!; @@ -303,31 +274,37 @@ function persistRefreshedMainAuthJson( refresh_token: refreshToken, account_id: chatgptAccountId, }; - atomicWriteFile( + const outcome = await persistRefreshedNativeMainAuthFile( expected.path, JSON.stringify({ ...expected.root, tokens }, null, 2) + "\n", - undefined, { - beforeRename: () => { - assertMainAuthJsonSnapshotUnchanged(expected); - const hook = beforeMainAuthJsonRenameForTests; - beforeMainAuthJsonRenameForTests = null; - hook?.(); - }, - // Runs immediately before rename(2), after the test hook has had its chance to - // simulate an external writer. Full snapshot check (content AND identity): this is - // the last look we get, so it asks everything it can rather than the cheap question. - validateBeforeRename: () => assertMainAuthJsonSnapshotUnchanged(expected), + expectedSnapshot: expected.fence, + beforeRename: consumeMainAuthJsonBeforeRenameHook(), }, ); + if (outcome.status === "deleted") return null; + if (outcome.status === "stopped") return null; + if (outcome.status === "recovery-required" || outcome.status === "superseded") { + outcome.snapshot?.raw.fill(0); + throw new MainAuthJsonChangedDuringRefreshError(); + } advanceCodexCredentialMutationEpoch(); - return { accessToken, chatgptAccountId }; + const current = readMainAuthJsonCredential(); + return current?.accessToken + ? { accessToken: current.accessToken, chatgptAccountId: current.chatgptAccountId } + : null; } export function setMainAuthJsonBeforeRenameHookForTests(hook: (() => void) | null): void { beforeMainAuthJsonRenameForTests = hook; } +function consumeMainAuthJsonBeforeRenameHook(): (() => void) | undefined { + const hook = beforeMainAuthJsonRenameForTests; + beforeMainAuthJsonRenameForTests = null; + return hook ?? undefined; +} + /** Complete token set a native device reauth commits into the main slot (#3898). */ export interface NativeMainReauthTokens { accessToken: string; @@ -370,20 +347,21 @@ function persistNativeMainReauthTokens( id_token: tokens.idToken, account_id: tokens.chatgptAccountId, }; - atomicWriteFile( - expected.path, - JSON.stringify({ ...expected.root, tokens: nextTokens }, null, 2) + "\n", - undefined, - { - beforeRename: () => { - assertMainAuthJsonSnapshotUnchanged(expected); - const hook = beforeMainAuthJsonRenameForTests; - beforeMainAuthJsonRenameForTests = null; - hook?.(); + try { + writeNativeMainAuthFileSyncClaimed( + expected.path, + JSON.stringify({ ...expected.root, tokens: nextTokens }, null, 2) + "\n", + { + expectedSnapshot: expected.fence, + beforeRename: consumeMainAuthJsonBeforeRenameHook(), }, - validateBeforeRename: () => assertMainAuthJsonSnapshotUnchanged(expected), - }, - ); + ); + } catch (error) { + if (error instanceof NativeMainAuthChangedError) { + throw new MainAuthJsonChangedDuringRefreshError(); + } + throw error; + } advanceCodexCredentialMutationEpoch(); } @@ -506,7 +484,21 @@ async function resolveMainAccountToken( return await withNativeMainExclusiveClaim( resolveNativeProfileContext(), () => withCodexRefreshFileLock(lockKey, signal, async () => { + const observation = observeNativeMainAuthFile(join(resolveCodexHomeDir(), "auth.json")); const locked = readMainAuthJsonCredential(); + // Codex mode refreshes through Codex, which owns its own write, so a symlinked auth.json + // (not writable by OpenCodex) must not block it. OpenCodex mode still stays gated. + if (refreshMode?.mode !== "codex" && !observation.canRefreshOrWrite) { + observation.raw?.fill(0); + observation.snapshot?.raw.fill(0); + if (locked?.accessToken && locked.accessToken !== rejectedAccessToken + && mainAccessTokenFresh(locked.accessToken, Date.now(), 0)) { + return { accessToken: locked.accessToken, chatgptAccountId: locked.chatgptAccountId }; + } + return null; + } + observation.raw?.fill(0); + observation.snapshot?.raw.fill(0); if (!locked) throw new MainAuthJsonChangedDuringRefreshError(); if (credentialRefreshGrantRejected(locked)) throw new MainAccountTokenRefreshError("reauth"); if (!locked.refreshToken @@ -529,8 +521,34 @@ async function resolveMainAccountToken( const refresh = dependencies.refreshToken ?? refreshNativeMainGrant; let refreshed: OAuthCredentials; if (signal.aborted) throw signal.reason; + const beforeRefreshObservation = observeNativeMainAuthFile(locked.path); + const beforeRefresh = readMainAuthJsonCredential(); + if (!beforeRefreshObservation.canRefreshOrWrite + || !beforeRefresh + || !nativeMainAuthFenceMatches(beforeRefreshObservation.snapshot, locked.fence) + || !nativeMainAuthFenceMatches(beforeRefreshObservation.snapshot, beforeRefresh.fence) + || beforeRefresh.fence.digest !== locked.fence.digest + || beforeRefresh.fence.identity.dev !== locked.fence.identity.dev + || beforeRefresh.fence.identity.ino !== locked.fence.identity.ino + || beforeRefresh.fence.observationGeneration !== locked.fence.observationGeneration + || !beforeRefresh.refreshToken + || refreshGrantFingerprintForToken(beforeRefresh.refreshToken) !== lockKey) { + beforeRefreshObservation.raw?.fill(0); + beforeRefreshObservation.snapshot?.raw.fill(0); + if (beforeRefresh?.accessToken + && beforeRefresh.accessToken !== rejectedAccessToken + && mainAccessTokenFresh(beforeRefresh.accessToken, Date.now(), 0)) { + return { + accessToken: beforeRefresh.accessToken, + chatgptAccountId: beforeRefresh.chatgptAccountId, + }; + } + throw new MainAuthJsonChangedDuringRefreshError(); + } + beforeRefreshObservation.raw?.fill(0); + beforeRefreshObservation.snapshot?.raw.fill(0); try { - refreshed = await refresh(locked.refreshToken, { signal: refreshTimeout }); + refreshed = await refresh(beforeRefresh.refreshToken, { signal: refreshTimeout }); } catch (cause) { // A refusal already classified at the endpoint keeps that verdict. An injected refresh // threw an ordinary Error and has only its message, so prose still decides there -- @@ -549,7 +567,7 @@ async function resolveMainAccountToken( // revoked session stayed `needsReauth: false` and every request re-earned its own 401. if (signal.aborted) throw signal.reason; if (reason === "reauth") { - assertMainAuthJsonSnapshotUnchanged(locked); + assertMainAuthJsonSnapshotUnchanged(beforeRefresh); markMainRefreshGrantRejected(mainRefreshGrantKey(locked)!); observeMainDecisionCredentialUsable(false); } throw new MainAccountTokenRefreshError(reason, { cause }); @@ -557,19 +575,20 @@ async function resolveMainAccountToken( // Once the exchange starts, only its own timeout may abort it: a successful // rotating grant retires the old refresh token. Save the only live grant before // honoring caller cancellation, while retaining the external-writer guards. - let result: { accessToken: string; chatgptAccountId: string }; + let result: { accessToken: string; chatgptAccountId: string } | null; try { - result = persistRefreshedMainAuthJson(locked, refreshed); + result = await persistRefreshedMainAuthJson(beforeRefresh, refreshed); } catch (cause) { if (!(cause instanceof MainAuthJsonChangedDuringRefreshError)) throw cause; // Another writer owns auth.json now (#2999). The exchange already succeeded, so // failing the request would waste it. Serve the new access token to this request - // only: leave the file as the other writer left it, keep nothing as the main - // credential, and do not retire the grant. + // only; the store keeps memory aligned with its file observation and does not + // mark the old grant rejected. noteNativeMainRefresh("publish-refused-served-in-flight"); if (dependencies.signal?.aborted) throw dependencies.signal.reason; - return inFlightMainAccountToken(locked, refreshed); + return inFlightMainAccountToken(beforeRefresh, refreshed); } + if (!result) return null; // A permitted in-flight success speaks only for its own profile and grant. clearMainRefreshGrantRejection(mainRefreshGrantKey(locked)!); if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); @@ -586,7 +605,7 @@ async function resolveMainAccountToken( } } -/** The refreshed bearer for one request whose save was refused; never stored anywhere. */ +/** Refreshed bearer for a request whose result the store did not publish to auth.json. */ function inFlightMainAccountToken( expected: MainAuthJsonCredential, refreshed: OAuthCredentials, diff --git a/src/codex/native-main-credential-config.ts b/src/codex/native-main-credential-config.ts new file mode 100644 index 00000000000..c28c52fb5f0 --- /dev/null +++ b/src/codex/native-main-credential-config.ts @@ -0,0 +1,7 @@ +import { DEFAULT_NATIVE_MAIN_AUTH_GRACE_SECONDS } from "../config/schema/native-main-auth-grace"; + +export const NATIVE_MAIN_AUTH_DELETE_GRACE_MS = DEFAULT_NATIVE_MAIN_AUTH_GRACE_SECONDS * 1_000; +export const NATIVE_MAIN_AUTH_CHECK_INTERVAL_MS = 250; +export const NATIVE_MAIN_AUTH_FORCE_READ_INTERVAL_MS = 1_000; +export const NATIVE_MAIN_AUTH_RETRY_INITIAL_MS = 50; +export const NATIVE_MAIN_AUTH_RETRY_MAX_MS = 1_000; diff --git a/src/codex/native-main-credentials.ts b/src/codex/native-main-credentials.ts new file mode 100644 index 00000000000..748663551d2 --- /dev/null +++ b/src/codex/native-main-credentials.ts @@ -0,0 +1,1602 @@ +import { createHash } from "node:crypto"; +import { + closeSync, + constants as fsConstants, + fstatSync, + lstatSync, + openSync, + readSync, + statSync, + watch as watchFsDirectory, + type FSWatcher, + type Stats, +} from "node:fs"; +import { dirname, resolve } from "node:path"; +import { loadConfig } from "../config"; +import { resolveNativeMainAuthGraceMs } from "../config/schema/native-main-auth-grace"; +import { + atomicWriteFile, + atomicWriteFileAsync, + resolveWriteTarget, + type AtomicWriteAsyncHooks, + type AtomicWriteAsyncIO, + type AtomicWriteHooks, + type AtomicWriteIO, + type AtomicWritePublicationEvidence, + type AtomicWriteTargetIdentity, +} from "../config/atomic-write"; +import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch"; +import { + NATIVE_MAIN_AUTH_CHECK_INTERVAL_MS, + NATIVE_MAIN_AUTH_DELETE_GRACE_MS, + NATIVE_MAIN_AUTH_FORCE_READ_INTERVAL_MS, + NATIVE_MAIN_AUTH_RETRY_INITIAL_MS, + NATIVE_MAIN_AUTH_RETRY_MAX_MS, +} from "./native-main-credential-config"; +import { withNativeMainExclusiveClaim } from "./native-main-claim"; +import { + MAX_AUTH_BYTES, + resolveNativeProfileContext, +} from "./native-profile-store"; +import { didRunOptionalShutdownHooks, registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks"; + +export { + NATIVE_MAIN_AUTH_CHECK_INTERVAL_MS, + NATIVE_MAIN_AUTH_DELETE_GRACE_MS, + NATIVE_MAIN_AUTH_FORCE_READ_INTERVAL_MS, + NATIVE_MAIN_AUTH_RETRY_INITIAL_MS, + NATIVE_MAIN_AUTH_RETRY_MAX_MS, +} from "./native-main-credential-config"; + +export type NativeMainAuthStatus = "ok" | "missing" | "invalid" | "unreadable" | "stopped"; +export type NativeMainAuthFileStatus = "valid" | "missing" | "invalid" | "unreadable"; +export type NativeMainAuthLeafProvenance = "regular" | "link" | "unknown"; + +interface NativeMainAuthStatSignature { + dev: number; + ino: number; + size: number; + mode: number; + mtimeMs: number; + ctimeMs: number; +} + +interface NativeMainAuthPhysicalSignature { + target: Readonly | null; + leaf: Readonly | null; +} + +export interface NativeMainAuthIdentity { + dev: number; + ino: number; +} + +export interface NativeMainAuthSnapshot { + path: string; + raw: Buffer; + digest: string; + identity: NativeMainAuthIdentity; + observationGeneration: number; + observationSequence: number; + isSymlink: boolean; + leafProvenance: NativeMainAuthLeafProvenance; + statSignature: Readonly; + physicalSignature: string; + size: number; + mtimeMs: number; + ctimeMs: number; +} + +export interface NativeMainAuthWriteFence { + digest: string; + identity: NativeMainAuthIdentity; + observationGeneration: number; +} + +export interface NativeMainAuthWriteReceipt { + readonly path: string; + readonly digest: string; + readonly identity: Readonly; + readonly observationGeneration: number; + readonly observationSequence: number; + readonly publicationId: number; + readonly statSignature: string; +} + +export interface NativeMainAuthMemoryRead { + status: NativeMainAuthStatus; + fileStatus: NativeMainAuthFileStatus; + graceActive: boolean; + canRefreshOrWrite: boolean; + canWriteFile: boolean; + isSymlink: boolean; + leafProvenance: NativeMainAuthLeafProvenance; + deletionConfirmed: boolean; + observationGeneration: number; + observationSequence: number; + observationError?: string; + snapshot?: NativeMainAuthSnapshot; + raw?: Buffer; + digest?: string; + identity?: NativeMainAuthIdentity; +} + +export interface NativeMainCredentialWatcher { + close(): void; + unref?(): void; +} + +export interface NativeMainCredentialFileSystem { + lstat(path: string): Stats; + stat(path: string): Stats; + read(path: string): Buffer | string; + watchDirectory( + path: string, + onEvent: () => void, + onError: () => void, + ): NativeMainCredentialWatcher; + setInterval(callback: () => void, delayMs: number): NodeJS.Timeout; + clearInterval(timer: NodeJS.Timeout): void; + setTimeout(callback: () => void, delayMs: number): NodeJS.Timeout; + clearTimeout(timer: NodeJS.Timeout): void; +} + +export interface NativeMainCredentialStoreOptions { + authPath: string; + now?: () => number; + graceMs?: number; + fileSystem?: Partial; + writeSyncIO?: AtomicWriteIO; + writeAsyncIO?: AtomicWriteAsyncIO; + claimExclusive?: typeof withNativeMainExclusiveClaim; +} + +export interface NativeMainRefreshWriteOptions { + expectedSnapshot: NativeMainAuthWriteFence; + beforeRename?: () => void; +} + +export interface NativeMainAuthWriteOptions { + expectedSnapshot: NativeMainAuthWriteFence; + beforeRename?: () => void; +} + +export type NativeMainAuthWriteOverride = AtomicWriteAsyncIO; + +export type NativeMainRefreshWriteResult = + | { status: "written"; snapshot: NativeMainAuthSnapshot; receipt: NativeMainAuthWriteReceipt } + | { status: "retained"; snapshot: NativeMainAuthSnapshot } + | { status: "recovery-required"; snapshot?: NativeMainAuthSnapshot } + | { status: "superseded"; snapshot?: NativeMainAuthSnapshot } + | { status: "deleted" | "stopped" }; + +interface ObservationHistoryEntry { + kind: "complete" | "unknown" | "invalid" | "missing"; + generation: number; + digest?: string; + identity?: NativeMainAuthIdentity; + physicalSignature?: string; +} + +type ObservationExistence = "present" | "absent" | "unknown"; + +type ObservationContent = + | { kind: "complete"; raw: string; digest: string; identity: NativeMainAuthIdentity } + | { kind: "incomplete" | "invalid"; digest: string; identity: NativeMainAuthIdentity } + | { kind: "unreadable" }; + +interface NativeMainAuthObservation { + readonly existence: ObservationExistence; + readonly leafProvenance: NativeMainAuthLeafProvenance; + readonly content: Readonly; + readonly statSignature: Readonly | null; + readonly error?: string; + readonly source: "unowned" | "publication" | "receipt-check"; + readonly publicationId?: number; +} + +interface PublicationAuthority { + readonly publicationId: number; + readonly observationSequence: number; + readonly unownedObservationSequence: number; + readonly observationGeneration: number; + readonly identity: Readonly; + readonly digest: string; + readonly raw: string; +} + +export class NativeMainAuthChangedError extends Error { + constructor() { + super("Codex auth.json changed before native-main publication"); + this.name = "NativeMainAuthChangedError"; + } +} + +export class NativeMainAuthDeletedError extends Error { + constructor() { + super("Codex auth.json deletion was confirmed"); + this.name = "NativeMainAuthDeletedError"; + } +} + +export class NativeMainAuthStoppedError extends Error { + constructor() { + super("Native-main credential access is stopped after shutdown."); + this.name = "NativeMainAuthStoppedError"; + } +} + +type AuthRoot = Record; + +interface PendingRefresh { + raw: string; + digest: string; + expectedSnapshot: NativeMainAuthSnapshot; + retryFence: NativeMainAuthWriteFence; + retryPhysicalSignature: string; + generation: number; + retryDelayMs: number; + recoveryRequired: boolean; +} + +const MAX_PUBLICATION_HISTORY = 256; + +export function readNativeMainAuthDescriptorBounded( + initialSize: bigint, + read: (buffer: Buffer, offset: number, length: number, position: number) => number, + currentSize: () => bigint, +): Buffer { + const maximumBytes = BigInt(MAX_AUTH_BYTES); + if (initialSize < 0n || initialSize > maximumBytes) { + throw new Error("native-main credential file is too large"); + } + let buffer = Buffer.alloc(Math.max(1, Number(initialSize))); + let total = 0; + try { + while (true) { + if (total === buffer.byteLength) { + if (total > MAX_AUTH_BYTES) throw new Error("native-main credential file is too large"); + const nextSize = Math.min(MAX_AUTH_BYTES + 1, Math.max(64, buffer.byteLength * 2)); + const grown = Buffer.alloc(nextSize); + buffer.copy(grown, 0, 0, total); + buffer.fill(0); + buffer = grown; + } + const bytesRead = read(buffer, total, buffer.byteLength - total, total); + if (!Number.isInteger(bytesRead) || bytesRead < 0 || bytesRead > buffer.byteLength - total) { + throw new Error("invalid native-main credential read length"); + } + if (bytesRead === 0) break; + total += bytesRead; + if (total > MAX_AUTH_BYTES) throw new Error("native-main credential file is too large"); + } + const finishedSize = currentSize(); + if (finishedSize !== initialSize || BigInt(total) !== finishedSize) { + throw new Error("native-main credential file changed while being read"); + } + return Buffer.from(buffer.subarray(0, total)); + } finally { + buffer.fill(0); + } +} + +function readNativeMainAuthFile(path: string): Buffer { + let descriptor: number | undefined; + let failed = false; + try { + const flags = process.platform === "win32" + ? fsConstants.O_RDONLY + : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK; + descriptor = openSync(path, flags); + const opened = fstatSync(descriptor, { bigint: true }); + if (!opened.isFile()) { + throw new Error("invalid native-main credential file"); + } + return readNativeMainAuthDescriptorBounded( + opened.size, + (target, offset, length, position) => readSync(descriptor!, target, offset, length, position), + () => fstatSync(descriptor!, { bigint: true }).size, + ); + } catch (error) { + failed = true; + throw error; + } finally { + if (descriptor !== undefined) { + try { + closeSync(descriptor); + } catch (error) { + if (!failed) throw error; + } + } + } +} + +function freezeIdentity(value: NativeMainAuthIdentity): Readonly { + return Object.freeze({ dev: value.dev, ino: value.ino }); +} + +const defaultFileSystem: NativeMainCredentialFileSystem = { + lstat: path => lstatSync(path), + stat: path => statSync(path), + read: path => readNativeMainAuthFile(resolveWriteTarget(path)), + watchDirectory: (path, onEvent, onError) => { + const watcher: FSWatcher = watchFsDirectory(path, { persistent: false }, () => onEvent()); + watcher.on("error", onError); + return watcher; + }, + setInterval: (callback, delayMs) => setInterval(callback, delayMs), + clearInterval: timer => clearInterval(timer), + setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimeout: timer => clearTimeout(timer), +}; + +function errnoCode(error: unknown): string | undefined { + return error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; +} + +function object(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function tokenValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +type CompleteAuth = { kind: "chatgpt" | "api-key"; root: AuthRoot }; + +function classifyCompleteAuth(raw: string): CompleteAuth | null { + try { + const root = object(JSON.parse(raw) as unknown); + const tokens = object(root?.tokens); + const accessToken = tokenValue(tokens?.access_token); + const refreshToken = tokenValue(tokens?.refresh_token); + if (!root) return null; + if (root.auth_mode === "apikey" || root.auth_mode === "api_key") { + return tokenValue(root.OPENAI_API_KEY) ? { kind: "api-key", root } : null; + } + if (root.auth_mode !== undefined && root.auth_mode !== "chatgpt") return null; + if (root.auth_mode === undefined && !tokens && tokenValue(root.OPENAI_API_KEY)) { + return { kind: "api-key", root }; + } + if (!tokens || !accessToken || !refreshToken) return null; + return { kind: "chatgpt", root }; + } catch { + return null; + } +} + +function classifyObservedContent( + bytes: Buffer, + stat: Stats, +): Readonly { + const raw = bytes.toString("utf8"); + const contentDigest = digest(bytes); + const observedIdentity = identity(stat); + if (!Buffer.from(raw, "utf8").equals(bytes)) { + return Object.freeze({ kind: "invalid", digest: contentDigest, identity: observedIdentity }); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return Object.freeze({ kind: raw.trim() ? "invalid" : "incomplete", digest: contentDigest, identity: observedIdentity }); + } + const root = object(parsed); + if (!root) return Object.freeze({ kind: "invalid", digest: contentDigest, identity: observedIdentity }); + const classified = classifyCompleteAuth(raw); + if (classified) { + return Object.freeze({ + kind: "complete", + raw, + digest: contentDigest, + identity: observedIdentity, + }); + } + const tokens = object(root.tokens); + if (root.auth_mode === "chatgpt" || root.auth_mode === undefined && tokens) { + return Object.freeze({ kind: "incomplete", digest: contentDigest, identity: observedIdentity }); + } + if (root.auth_mode === "apikey" || root.auth_mode === "api_key") { + return Object.freeze({ kind: tokenValue(root.OPENAI_API_KEY) ? "invalid" : "incomplete", digest: contentDigest, identity: observedIdentity }); + } + if (root.auth_mode === undefined && !tokens && root.OPENAI_API_KEY === undefined && Object.keys(root).length === 0) { + return Object.freeze({ kind: "incomplete", digest: contentDigest, identity: observedIdentity }); + } + return Object.freeze({ kind: "invalid", digest: contentDigest, identity: observedIdentity }); +} + +function parseChatGptAuth(raw: string): AuthRoot | null { + const classified = classifyCompleteAuth(raw); + return classified?.kind === "chatgpt" ? classified.root : null; +} + +function digest(raw: Buffer | string): string { + return createHash("sha256").update(raw).digest("hex"); +} + +function identity(stat: Stats): NativeMainAuthIdentity { + return Object.freeze({ dev: Number(stat.dev), ino: Number(stat.ino) }); +} + +function statSignatureOf(stat: Stats): Readonly { + return Object.freeze({ + dev: Number(stat.dev), + ino: Number(stat.ino), + size: Number(stat.size), + mode: Number(stat.mode), + mtimeMs: Number(stat.mtimeMs), + ctimeMs: Number(stat.ctimeMs), + }); +} + +function physicalSignatureKey(signature: Readonly | null): string | null { + return signature === null ? null : JSON.stringify(signature); +} + +function publicationPhysicalSignatureKey(identity: Readonly): string { + const stat = Object.freeze({ + dev: identity.dev, + ino: identity.ino, + size: identity.size, + mode: identity.mode, + mtimeMs: identity.mtimeMs, + ctimeMs: identity.ctimeMs, + }); + return physicalSignatureKey(Object.freeze({ target: stat, leaf: stat }))!; +} + +function observationContentKey(content: Readonly): string { + return "digest" in content ? `${content.kind}:${content.digest}` : content.kind; +} + +function sameStatSignature( + left: Readonly | null, + right: Readonly | null, +): boolean { + return left === right || !!left && !!right + && left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mode === right.mode + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs; +} + +function snapshotFrom( + path: string, + record: NativeMainAuthObservation & { + content: Readonly<{ kind: "complete"; raw: string; digest: string; identity: NativeMainAuthIdentity }>; + }, + observationGeneration: number, + observationSequence: number, +): NativeMainAuthSnapshot { + const raw = Buffer.from(record.content.raw, "utf8"); + const stat = record.statSignature?.target; + if (!stat) throw new Error("Complete native-main observation omitted its target stat signature."); + return { + path, + raw, + digest: record.content.digest, + identity: { ...record.content.identity }, + observationGeneration, + observationSequence, + isSymlink: record.leafProvenance === "link", + leafProvenance: record.leafProvenance, + statSignature: stat, + physicalSignature: physicalSignatureKey(record.statSignature) ?? "unknown", + size: raw.byteLength, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + }; +} + +export function nativeMainAuthFence(snapshot: NativeMainAuthSnapshot): NativeMainAuthWriteFence { + return { + digest: snapshot.digest, + identity: { ...snapshot.identity }, + observationGeneration: snapshot.observationGeneration, + }; +} + +export function nativeMainAuthReceiptFence(receipt: NativeMainAuthWriteReceipt): NativeMainAuthWriteFence { + return { + digest: receipt.digest, + identity: { ...receipt.identity }, + observationGeneration: receipt.observationGeneration, + }; +} + +export function nativeMainAuthReceiptMatches( + snapshot: NativeMainAuthSnapshot | undefined, + receipt: NativeMainAuthWriteReceipt, +): boolean { + return !!snapshot + && resolve(snapshot.path) === receipt.path + && snapshot.digest === receipt.digest + && snapshot.identity.dev === receipt.identity.dev + && snapshot.identity.ino === receipt.identity.ino + && snapshot.observationGeneration === receipt.observationGeneration + && snapshot.physicalSignature === receipt.statSignature; +} + +export function nativeMainAuthStrictNoFollowAllowed( + observation: Pick, +): boolean { + return observation.status === "ok" + && observation.leafProvenance === "regular" + && observation.snapshot?.leafProvenance === "regular"; +} + +function immutableWriteReceipt( + path: string, + candidateDigest: string, + targetIdentity: AtomicWriteTargetIdentity, + observationGeneration: number, + observationSequence: number, + publicationId: number, + statSignature: string, +): NativeMainAuthWriteReceipt { + return Object.freeze({ + path: resolve(path), + digest: candidateDigest, + identity: Object.freeze({ dev: targetIdentity.dev, ino: targetIdentity.ino }), + observationGeneration, + observationSequence, + publicationId, + statSignature, + }); +} + +export function nativeMainAuthFenceMatches( + snapshot: NativeMainAuthSnapshot | undefined, + fence: NativeMainAuthWriteFence, +): boolean { + return !!snapshot + && snapshot.digest === fence.digest + && snapshot.identity.dev === fence.identity.dev + && snapshot.identity.ino === fence.identity.ino + && snapshot.observationGeneration === fence.observationGeneration; +} + +function snapshotCopy(snapshot: NativeMainAuthSnapshot | undefined): NativeMainAuthSnapshot | undefined { + if (!snapshot) return undefined; + return { + ...snapshot, + raw: Buffer.from(snapshot.raw), + identity: { ...snapshot.identity }, + statSignature: Object.freeze({ ...snapshot.statSignature }), + }; +} + +function mergeRefreshIntoDisk(diskRaw: string, refreshRaw: string): string { + const disk = classifyCompleteAuth(diskRaw); + const refresh = classifyCompleteAuth(refreshRaw); + const diskTokens = object(disk?.root.tokens); + const refreshTokens = object(refresh?.root.tokens); + if (disk?.kind !== "chatgpt" || refresh?.kind !== "chatgpt" || !diskTokens || !refreshTokens) { + throw new NativeMainAuthChangedError(); + } + return JSON.stringify({ + ...disk.root, + tokens: { ...diskTokens, ...refreshTokens }, + }, null, 2) + "\n"; +} + +function cloneRaw(snapshot: NativeMainAuthSnapshot): string { + return snapshot.raw.toString("utf8"); +} + +function sameSnapshot( + left: NativeMainAuthSnapshot | null | undefined, + right: NativeMainAuthSnapshot | null | undefined, +): boolean { + return !!left && !!right + && left.digest === right.digest + && left.identity.dev === right.identity.dev + && left.identity.ino === right.identity.ino; +} + +function nativeMainAuthFenceContentMatches( + snapshot: NativeMainAuthSnapshot | undefined, + fence: NativeMainAuthWriteFence, +): boolean { + return !!snapshot + && snapshot.digest === fence.digest + && snapshot.identity.dev === fence.identity.dev + && snapshot.identity.ino === fence.identity.ino; +} + +export class NativeMainCredentialStore { + readonly authPath: string; + private readonly now: () => number; + private readonly fileSystem: NativeMainCredentialFileSystem; + private readonly writeSyncIO?: AtomicWriteIO; + private readonly writeAsyncIO?: AtomicWriteAsyncIO; + private readonly claimExclusive: typeof withNativeMainExclusiveClaim; + private readonly graceMs: number; + private observationError: string | undefined; + private memory: NativeMainAuthSnapshot | null = null; + private disk: NativeMainAuthSnapshot | null = null; + private fileStatus: NativeMainAuthFileStatus = "missing"; + private signature: string | null = null; + private isSymlink = false; + private leafProvenance: NativeMainAuthLeafProvenance = "unknown"; + private lastForcedReadAt: number | null = null; + private readonly observationLedger = { + observationGeneration: 0, + observationSequence: 0, + unownedObservationSequence: 0, + latestObservation: null as NativeMainAuthObservation | null, + lastCompleteChangeGeneration: 0, + lastHiccupGeneration: 0, + notCompleteSince: null as number | null, + deletionConfirmed: false, + observationHistory: [] as ObservationHistoryEntry[], + observationHistoryFloor: 0, + }; + private watcher: NativeMainCredentialWatcher | null = null; + private timer: NodeJS.Timeout | null = null; + private retryTimer: NodeJS.Timeout | null = null; + private retryAbortController: AbortController | null = null; + private retryInFlight = false; + private pendingRefresh: PendingRefresh | null = null; + private publishingDigest: string | null = null; + private publishingGeneration: number | null = null; + private nextRefreshGeneration = 0; + private nextPublicationGeneration = 0; + private nextReceiptPublicationId = 0; + private readonly activeReceipts = new Map(); + private started = false; + private stopped = false; + + constructor(options: NativeMainCredentialStoreOptions) { + this.authPath = resolve(options.authPath); + this.now = options.now ?? Date.now; + this.fileSystem = { ...defaultFileSystem, ...options.fileSystem }; + this.writeSyncIO = options.writeSyncIO; + this.writeAsyncIO = options.writeAsyncIO; + this.claimExclusive = options.claimExclusive ?? withNativeMainExclusiveClaim; + this.graceMs = options.graceMs ?? NATIVE_MAIN_AUTH_DELETE_GRACE_MS; + } + + start(): void { + if (this.started || this.stopped) return; + this.started = true; + this.ensureWatcher(); + this.timer = this.fileSystem.setInterval(() => { + this.ensureWatcher(); + const forceRead = this.lastForcedReadAt === null + || this.now() - this.lastForcedReadAt >= NATIVE_MAIN_AUTH_FORCE_READ_INTERVAL_MS; + this.observeInBackground(forceRead); + }, NATIVE_MAIN_AUTH_CHECK_INTERVAL_MS); + this.timer.unref?.(); + this.observeNow(); + } + + stop(): void { + this.stopped = true; + this.retryAbortController?.abort(new Error("Native-main credential store stopped.")); + if (this.watcher) { + this.watcher.close(); + this.watcher = null; + } + if (this.timer) { + this.fileSystem.clearInterval(this.timer); + this.timer = null; + } + this.clearRetryTimer(); + this.started = false; + this.clearMemory(); + this.pendingRefresh = null; + this.publishingDigest = null; + this.publishingGeneration = null; + this.activeReceipts.clear(); + } + + observeNow(forceRead = true): NativeMainAuthMemoryRead { + if (this.stopped) return this.readMemory(); + if (forceRead) this.lastForcedReadAt = this.now(); + const record = this.classifyPhysicalObservation(forceRead); + return this.applyObservationTransition(record); + } + + private classifyPhysicalObservation( + forceRead: boolean, + requestedSource?: { + kind: "publication" | "receipt-check"; + publicationId: number; + digest: string; + identity: Readonly; + statSignature?: string; + }, + ): NativeMainAuthObservation { + const freezeSignature = (target: Stats | null, leaf: Stats | null): Readonly | null => { + if (!target && !leaf) return null; + return Object.freeze({ + target: target ? statSignatureOf(target) : null, + leaf: leaf ? statSignatureOf(leaf) : null, + }); + }; + const finish = ( + existence: ObservationExistence, + leafProvenance: NativeMainAuthLeafProvenance, + content: Readonly, + signature: Readonly | null, + error?: string, + ): NativeMainAuthObservation => { + let source: NativeMainAuthObservation["source"] = "unowned"; + if (requestedSource && content.kind === "complete" + && content.digest === requestedSource.digest + && content.identity.dev === requestedSource.identity.dev + && content.identity.ino === requestedSource.identity.ino + && leafProvenance === "regular" + && (requestedSource.statSignature === undefined + || physicalSignatureKey(signature) === requestedSource.statSignature)) { + source = requestedSource.kind; + } + return Object.freeze({ + existence, + leafProvenance, + content, + statSignature: signature, + ...(error ? { error } : {}), + source, + ...(source !== "unowned" && requestedSource ? { publicationId: requestedSource.publicationId } : {}), + }); + }; + const unreadable = (error: string) => Object.freeze({ kind: "unreadable" as const }); + let targetStat: Stats | null = null; + let statFailure: unknown; + try { + targetStat = this.fileSystem.stat(this.authPath); + } catch (error) { + statFailure = error; + } + let leafStat: Stats | null = null; + let leafFailure: unknown; + try { + leafStat = this.fileSystem.lstat(this.authPath); + } catch (error) { + leafFailure = error; + } + const leafProvenance: NativeMainAuthLeafProvenance = leafStat + ? leafStat.isSymbolicLink() ? "link" : leafStat.isFile() ? "regular" : "unknown" + : "unknown"; + if (!targetStat) { + const missing = errnoCode(statFailure) === "ENOENT" && errnoCode(leafFailure) === "ENOENT"; + const existence = leafStat ? "present" : missing ? "absent" : "unknown"; + const error = missing ? undefined : errnoCode(statFailure) === "ENOENT" && leafFailure + ? "lstat-failed" : "stat-failed"; + return finish(existence, leafProvenance, unreadable(error ?? "missing"), freezeSignature(null, leafStat), error); + } + if (!leafStat) { + return finish("present", "unknown", unreadable("lstat-failed"), freezeSignature(targetStat, null), "lstat-failed"); + } + const initialSignature = freezeSignature(targetStat, leafStat)!; + const initialSignatureKey = physicalSignatureKey(initialSignature)!; + if (leafProvenance === "unknown" || !targetStat.isFile() || targetStat.size > MAX_AUTH_BYTES) { + return finish("present", leafProvenance, unreadable("not-regular-file"), initialSignature, "not-regular-file"); + } + if (!forceRead && this.signature === initialSignatureKey && this.observationLedger.latestObservation) { + return finish("present", leafProvenance, this.observationLedger.latestObservation.content, initialSignature); + } + let bytes: Buffer; + try { + const read = this.fileSystem.read(this.authPath); + if (Buffer.isBuffer(read)) { + bytes = Buffer.from(read); + read.fill(0); + } else { + bytes = Buffer.from(read, "utf8"); + } + } catch (error) { + const code = errnoCode(error); + return finish("present", leafProvenance, unreadable(code === "ENOENT" ? "read-missing" : "read-failed"), initialSignature, "read-failed"); + } + if (bytes.byteLength > MAX_AUTH_BYTES) { + const contentDigest = digest(bytes); + const contentIdentity = identity(targetStat); + bytes.fill(0); + return finish("present", leafProvenance, Object.freeze({ kind: "invalid", digest: contentDigest, identity: contentIdentity }), initialSignature, "too-large"); + } + let currentStat: Stats | null = null; + let currentStatFailure: unknown; + try { + currentStat = this.fileSystem.stat(this.authPath); + } catch (error) { + currentStatFailure = error; + } + let currentLeafStat: Stats | null = null; + let currentLeafFailure: unknown; + try { + currentLeafStat = this.fileSystem.lstat(this.authPath); + } catch (error) { + currentLeafFailure = error; + } + if (!currentStat || !currentLeafStat) { + bytes.fill(0); + const currentProvenance: NativeMainAuthLeafProvenance = currentLeafStat + ? currentLeafStat.isSymbolicLink() ? "link" : currentLeafStat.isFile() ? "regular" : "unknown" + : "unknown"; + const existence = currentLeafStat || currentStat ? "present" + : errnoCode(currentStatFailure) === "ENOENT" && errnoCode(currentLeafFailure) === "ENOENT" ? "absent" : "unknown"; + return finish(existence, currentProvenance, unreadable("stat-failed-after-read"), freezeSignature(currentStat, currentLeafStat), "stat-failed-after-read"); + } + const currentProvenance: NativeMainAuthLeafProvenance = currentLeafStat.isSymbolicLink() + ? "link" : currentLeafStat.isFile() ? "regular" : "unknown"; + const currentSignature = freezeSignature(currentStat, currentLeafStat)!; + const currentSignatureKey = physicalSignatureKey(currentSignature)!; + if (currentSignatureKey !== initialSignatureKey || bytes.byteLength !== currentStat.size) { + bytes.fill(0); + return finish("present", currentProvenance, unreadable("file-changed-during-read"), currentSignature, "file-changed-during-read"); + } + const content = classifyObservedContent(bytes, currentStat); + bytes.fill(0); + return finish("present", currentProvenance, content, currentSignature); + } + + private applyObservationTransition(record: NativeMainAuthObservation): NativeMainAuthMemoryRead { + const at = this.now(); + const previousRecord = this.observationLedger.latestObservation; + const signatureChanged = physicalSignatureKey(previousRecord?.statSignature ?? null) + !== physicalSignatureKey(record.statSignature); + const contentChanged = !previousRecord + || observationContentKey(previousRecord.content) !== observationContentKey(record.content); + const provenanceChanged = !previousRecord || previousRecord.leafProvenance !== record.leafProvenance; + const existenceChanged = !previousRecord || previousRecord.existence !== record.existence; + const physicalChange = signatureChanged || contentChanged || provenanceChanged || existenceChanged; + this.observationLedger.observationSequence++; + if (physicalChange) this.observationLedger.observationGeneration++; + if (record.source === "unowned") { + this.observationLedger.unownedObservationSequence = this.observationLedger.observationSequence; + } + for (const [publicationId, active] of this.activeReceipts) { + if (record.source === "unowned" || record.publicationId !== publicationId) { + active.invalidated = true; + } + } + this.observationLedger.latestObservation = record; + this.signature = record.content.kind === "unreadable" || record.existence === "unknown" + ? null : physicalSignatureKey(record.statSignature); + this.observationError = record.error; + this.leafProvenance = record.leafProvenance; + this.isSymlink = record.leafProvenance === "link"; + const historyKind: ObservationHistoryEntry["kind"] = record.existence === "absent" + ? "missing" : record.content.kind === "complete" ? "complete" + : record.content.kind === "invalid" || record.content.kind === "incomplete" ? "invalid" : "unknown"; + const historyEntry: ObservationHistoryEntry = { + kind: historyKind, + generation: this.observationLedger.observationGeneration, + ...(record.content.kind === "complete" ? { + digest: record.content.digest, + identity: { ...record.content.identity }, + physicalSignature: physicalSignatureKey(record.statSignature) ?? "unknown", + } : {}), + }; + const previousHistory = this.observationLedger.observationHistory.at(-1); + const sameHistoryState = previousHistory?.kind === historyEntry.kind + && (historyEntry.kind !== "complete" + || previousHistory.digest === historyEntry.digest + && previousHistory.identity?.dev === historyEntry.identity?.dev + && previousHistory.identity?.ino === historyEntry.identity?.ino + && previousHistory.physicalSignature === historyEntry.physicalSignature); + if (!sameHistoryState) this.observationLedger.observationHistory.push(historyEntry); + if (this.observationLedger.observationHistory.length > MAX_PUBLICATION_HISTORY) { + const removed = this.observationLedger.observationHistory.shift()!; + this.observationLedger.observationHistoryFloor = Math.max(this.observationLedger.observationHistoryFloor, removed.generation); + } + const isHiccup = record.existence !== "present" || record.content.kind !== "complete"; + if (isHiccup) { + this.observationLedger.lastHiccupGeneration = this.observationLedger.observationGeneration; + this.disk?.raw.fill(0); + this.disk = null; + this.observationLedger.notCompleteSince ??= at; + if (at - this.observationLedger.notCompleteSince >= this.graceMs) { + this.observationLedger.deletionConfirmed = true; + this.clearMemory(); + this.pendingRefresh = null; + this.clearRetryTimer(); + } + } else { + this.observationLedger.notCompleteSince = null; + this.observationLedger.deletionConfirmed = false; + } + if (record.existence === "absent") { + this.fileStatus = "missing"; + return this.readMemory(); + } + if (record.existence === "unknown") { + this.fileStatus = "unreadable"; + return this.readMemory(); + } + if (record.content.kind === "incomplete" || record.content.kind === "invalid") { + this.fileStatus = "invalid"; + return this.readMemory(); + } + if (record.content.kind === "unreadable") { + this.fileStatus = "unreadable"; + return this.readMemory(); + } + this.fileStatus = "valid"; + const observed = snapshotFrom(this.authPath, record as NativeMainAuthObservation & { + content: Readonly<{ kind: "complete"; raw: string; digest: string; identity: NativeMainAuthIdentity }>; + }, this.observationLedger.observationGeneration, this.observationLedger.observationSequence); + this.disk?.raw.fill(0); + this.disk = observed; + const pending = this.pendingRefresh; + if (pending) { + const historyLost = this.observationLedger.observationHistoryFloor > pending.retryFence.observationGeneration; + const matchesRetainedBase = nativeMainAuthFenceContentMatches(observed, pending.retryFence) + && observed.physicalSignature === pending.retryPhysicalSignature; + if (historyLost && matchesRetainedBase) { + pending.recoveryRequired = true; + this.clearRetryTimer(); + this.setMemory(pending.raw, observed); + return this.readMemory(); + } + const matchesPendingBase = sameSnapshot(observed, pending.expectedSnapshot); + const matchesRetryFence = nativeMainAuthFenceMatches(observed, pending.retryFence); + const isPendingCandidate = pending.digest === observed.digest; + const matchesPendingBaseAfterHiccup = matchesPendingBase + && this.canContinueRetryAfterHiccup(pending); + if (matchesPendingBaseAfterHiccup || matchesRetryFence || isPendingCandidate && record.source === "publication") { + this.setMemory(pending.raw, observed); + return this.readMemory(); + } + const completeChange = physicalChange || !sameSnapshot(pending.expectedSnapshot, observed); + if (record.source === "unowned" && completeChange) { + this.observationLedger.lastCompleteChangeGeneration = this.observationLedger.observationGeneration; + this.pendingRefresh = null; + this.clearRetryTimer(); + } + } + if (!pending || this.pendingRefresh !== pending) { + const prior = pending?.expectedSnapshot ?? this.memory; + const externalChange = record.source === "unowned" + && (physicalChange || !sameSnapshot(prior, observed)); + if (externalChange) this.observationLedger.lastCompleteChangeGeneration = this.observationLedger.observationGeneration; + this.setMemory(cloneRaw(observed), observed); + if (externalChange && prior && !sameSnapshot(prior, observed)) advanceCodexCredentialMutationEpoch(); + } else { + this.setMemory(cloneRaw(observed), observed); + } + return this.readMemory(); + } + + readMemory(): NativeMainAuthMemoryRead { + const graceActive = this.observationLedger.notCompleteSince !== null && !this.observationLedger.deletionConfirmed; + const status = this.stopped ? "stopped" + : this.memory ? "ok" : this.fileStatus === "valid" ? "unreadable" : this.fileStatus; + const snapshot = snapshotCopy(this.memory ?? undefined); + return { + status, + fileStatus: this.fileStatus, + graceActive, + canRefreshOrWrite: this.canRefreshOrWrite(), + canWriteFile: this.fileStatus === "valid" + && !!this.disk + && this.leafProvenance === "regular" + && this.disk.leafProvenance === "regular" + && !this.observationLedger.deletionConfirmed + && !this.stopped, + isSymlink: this.isSymlink, + leafProvenance: this.leafProvenance, + deletionConfirmed: this.observationLedger.deletionConfirmed, + observationGeneration: this.observationLedger.observationGeneration, + observationSequence: this.observationLedger.observationSequence, + ...(this.observationError ? { observationError: this.observationError } : {}), + ...(snapshot ? { + snapshot, + raw: Buffer.from(snapshot.raw), + digest: snapshot.digest, + identity: { ...snapshot.identity }, + } : {}), + }; + } + + canRefreshOrWrite(): boolean { + return !this.stopped + && this.fileStatus === "valid" + && !!this.disk + && !!this.memory + && this.leafProvenance === "regular" + && this.memory.leafProvenance === "regular" + && this.disk.leafProvenance === "regular" + && this.memory.digest === this.disk.digest + && nativeMainAuthFenceMatches(this.memory, nativeMainAuthFence(this.disk)) + && !this.observationLedger.deletionConfirmed; + } + + verifyReceipt(receipt: NativeMainAuthWriteReceipt): boolean { + return this.observeReceiptCurrent(receipt); + } + + async persistRefresh(raw: string, options: NativeMainRefreshWriteOptions): Promise { + if (this.stopped) return { status: "stopped" }; + if (!parseChatGptAuth(raw)) throw new Error("Refusing to publish an invalid native-main credential."); + this.observeNow(true); + if (this.observationLedger.deletionConfirmed) { + return { status: "deleted" }; + } + + const memorySnapshot = snapshotCopy(this.memory ?? undefined); + const publicationState = this.classifyPublicationFence(options.expectedSnapshot); + if (publicationState === "changed") { + memorySnapshot?.raw.fill(0); + return this.supersededResult(); + } + + if (publicationState === "blocked") { + if (!nativeMainAuthFenceContentMatches(memorySnapshot, options.expectedSnapshot)) { + memorySnapshot?.raw.fill(0); + return this.supersededResult(); + } + const existingPending = this.pendingRefresh; + if (existingPending?.recoveryRequired) { + memorySnapshot?.raw.fill(0); + return this.recoveryRequiredResult(); + } + if (existingPending && existingPending.digest !== memorySnapshot?.digest) { + memorySnapshot?.raw.fill(0); + return this.supersededResult(); + } + const candidateBase = existingPending?.raw ?? cloneRaw(memorySnapshot!); + const candidateRaw = mergeRefreshIntoDisk(candidateBase, raw); + const expectedBase = existingPending?.expectedSnapshot + ?? { ...memorySnapshot!, observationGeneration: options.expectedSnapshot.observationGeneration }; + const retryFence = existingPending?.retryFence ?? options.expectedSnapshot; + this.retainRefresh( + candidateRaw, + expectedBase, + retryFence, + existingPending?.retryPhysicalSignature ?? expectedBase.physicalSignature, + ); + memorySnapshot?.raw.fill(0); + this.scheduleRetry(NATIVE_MAIN_AUTH_RETRY_INITIAL_MS); + const retained = snapshotCopy(this.memory ?? undefined); + return retained ? { status: "retained", snapshot: retained } : { status: "deleted" }; + } + + const candidateRaw = mergeRefreshIntoDisk(cloneRaw(this.disk!), raw); + this.retainRefresh(candidateRaw, this.disk!); + memorySnapshot?.raw.fill(0); + + for (let attempt = 0; attempt < 3; attempt++) { + const result = this.tryPublishPendingRefresh(options.beforeRename); + if (result) { + if (result.status === "retained") this.scheduleRetry(NATIVE_MAIN_AUTH_RETRY_INITIAL_MS); + return result; + } + if (!this.pendingRefresh) { + return this.supersededResult(); + } + } + this.scheduleRetry(NATIVE_MAIN_AUTH_RETRY_INITIAL_MS); + const retained = snapshotCopy(this.memory ?? undefined); + return retained ? { status: "retained", snapshot: retained } : { status: "deleted" }; + } + + writeAuthFileSync(raw: string, options: NativeMainAuthWriteOptions): NativeMainAuthWriteReceipt { + return this.writeAuthFile(raw, options, false); + } + + async writeAuthFileAsync( + raw: string, + options: NativeMainAuthWriteOptions, + writeOverride?: NativeMainAuthWriteOverride, + ): Promise { + return await this.writeAuthFile(raw, options, true, writeOverride); + } + + private supersededResult(): NativeMainRefreshWriteResult { + if (this.observationLedger.deletionConfirmed) return { status: "deleted" }; + const current = snapshotCopy(this.memory ?? undefined); + return current ? { status: "superseded", snapshot: current } : { status: "superseded" }; + } + + private ensureWatcher(): void { + if (this.watcher || this.stopped) return; + try { + const watcher = this.fileSystem.watchDirectory( + dirname(this.authPath), + () => this.observeInBackground(true), + () => { + try { this.watcher?.close(); } catch { /* the timer reopens it */ } + this.watcher = null; + }, + ); + watcher.unref?.(); + this.watcher = watcher; + } catch { + // The short timer remains the fallback and retries watcher registration. + } + } + + private observeInBackground(forceRead: boolean): void { + try { + this.observeNow(forceRead); + } catch { /* The classifier returns an unreadable record for filesystem failures. */ } + this.retryPendingRefresh(); + } + + private setMemory(raw: string, basedOn?: NativeMainAuthSnapshot): void { + const previous = this.memory; + const next: NativeMainAuthSnapshot = basedOn && raw === cloneRaw(basedOn) + ? snapshotCopy(basedOn)! + : { + path: this.authPath, + raw: Buffer.from(raw), + digest: digest(raw), + identity: basedOn ? { ...basedOn.identity } : { dev: 0, ino: 0 }, + observationGeneration: basedOn?.observationGeneration ?? this.observationLedger.observationGeneration, + observationSequence: basedOn?.observationSequence ?? this.observationLedger.observationSequence, + isSymlink: basedOn?.isSymlink ?? this.isSymlink, + leafProvenance: basedOn?.leafProvenance ?? this.leafProvenance, + statSignature: basedOn?.statSignature ?? Object.freeze({ + dev: 0, ino: 0, size: Buffer.byteLength(raw), mode: 0, mtimeMs: this.now(), ctimeMs: this.now(), + }), + physicalSignature: basedOn?.physicalSignature ?? "unknown", + size: Buffer.byteLength(raw), + mtimeMs: basedOn?.mtimeMs ?? this.now(), + ctimeMs: basedOn?.ctimeMs ?? this.now(), + }; + this.memory = next; + if (previous && previous !== this.disk) previous.raw.fill(0); + } + + private retainRefresh( + raw: string, + expectedSnapshot: NativeMainAuthSnapshot, + retryFence: NativeMainAuthWriteFence = nativeMainAuthFence(expectedSnapshot), + retryPhysicalSignature = expectedSnapshot.physicalSignature, + ): void { + const memoryBefore = snapshotCopy(this.memory ?? undefined); + this.pendingRefresh = { + raw, + digest: digest(raw), + expectedSnapshot: snapshotCopy(expectedSnapshot)!, + retryFence: { ...retryFence, identity: { ...retryFence.identity } }, + retryPhysicalSignature, + generation: ++this.nextRefreshGeneration, + retryDelayMs: NATIVE_MAIN_AUTH_RETRY_INITIAL_MS, + recoveryRequired: false, + }; + this.setMemory(raw, expectedSnapshot); + memoryBefore?.raw.fill(0); + } + + private tryPublishPendingRefresh(beforeRename?: () => void): NativeMainRefreshWriteResult | null { + const pending = this.pendingRefresh; + if (!pending) return null; + if (this.stopped) return { status: "stopped" }; + this.observeNow(true); + if (this.observationLedger.deletionConfirmed) { + this.pendingRefresh = null; + this.clearRetryTimer(); + return { status: "deleted" }; + } + if (this.pendingRefresh !== pending) return this.supersededResult(); + const publicationState = this.classifyPendingRefreshFence(pending); + if (publicationState === "blocked") { + if (this.fileStatus === "valid" && this.disk) { + pending.recoveryRequired = true; + this.clearRetryTimer(); + return this.recoveryRequiredResult(); + } + return null; + } + if (publicationState === "changed") { + this.pendingRefresh = null; + this.clearRetryTimer(); + return this.supersededResult(); + } + + const stagedRaw = pending.raw; + const stagedDigest = pending.digest; + const stagedGeneration = pending.generation; + let receipt: NativeMainAuthWriteReceipt | undefined; + let publicationAuthority: PublicationAuthority | undefined; + + try { + atomicWriteFile(this.authPath, stagedRaw, this.writeSyncIO, { + beforeRename, + validateBeforeRename: (_targetPath, tempIdentity) => { + const current = this.observeNow(true); + if (!current.canWriteFile + || !this.disk + || !tempIdentity + || this.pendingRefresh !== pending + || pending.generation !== stagedGeneration + || pending.digest !== stagedDigest + || this.classifyPendingRefreshFence(pending) !== "current") throw new NativeMainAuthChangedError(); + publicationAuthority = this.capturePublicationAuthority(stagedRaw, stagedDigest, tempIdentity); + }, + afterRenameEvidence: (_targetPath, evidence) => { + if (!publicationAuthority) throw new NativeMainAuthChangedError(); + receipt = this.recordOwnRename(stagedDigest, evidence, publicationAuthority); + }, + }); + } catch (error) { + if (error instanceof NativeMainAuthChangedError) { + this.observeNow(true); + if (this.pendingRefresh !== pending) return this.supersededResult(); + return null; + } + const retained = snapshotCopy(this.memory ?? undefined); + return retained ? { status: "retained", snapshot: retained } : { status: "deleted" }; + } + + const receiptCurrent = !!receipt && this.observeReceiptCurrent(receipt); + if (!receiptCurrent || !this.disk || !receipt || !nativeMainAuthReceiptMatches(this.disk, receipt)) { + if (this.pendingRefresh !== pending) return this.supersededResult(); + if (this.fileStatus === "valid" && this.disk) { + pending.recoveryRequired = true; + return this.recoveryRequiredResult(); + } + const retained = snapshotCopy(this.memory ?? undefined); + return retained ? { status: "retained", snapshot: retained } : { status: "deleted" }; + } + if (this.pendingRefresh === pending) this.pendingRefresh = null; + this.clearRetryTimer(); + const written = snapshotCopy(this.memory ?? undefined); + return written && receipt ? { status: "written", snapshot: written, receipt } : { status: "deleted" }; + } + + private classifyPendingRefreshFence(pending: PendingRefresh): "current" | "changed" | "blocked" { + const state = this.classifyPublicationFence(pending.retryFence); + if (state !== "blocked") return state; + return this.canContinueRetryAfterHiccup(pending) ? "current" : "blocked"; + } + + private recoveryRequiredResult(): NativeMainRefreshWriteResult { + const retained = snapshotCopy(this.memory ?? undefined); + return retained ? { status: "recovery-required", snapshot: retained } : { status: "recovery-required" }; + } + + private recordOwnRename( + candidateDigest: string, + evidence: AtomicWritePublicationEvidence, + authority: PublicationAuthority, + ): NativeMainAuthWriteReceipt { + if (this.stopped) throw new NativeMainAuthStoppedError(); + const targetIdentity = evidence.identity; + if (this.observationLedger.observationSequence !== authority.observationSequence + || this.observationLedger.unownedObservationSequence !== authority.unownedObservationSequence + || targetIdentity.dev !== authority.identity.dev + || targetIdentity.ino !== authority.identity.ino + || evidence.digest !== candidateDigest) { + this.observeNow(true); + throw new NativeMainAuthChangedError(); + } + this.lastForcedReadAt = this.now(); + const record = this.classifyPhysicalObservation(true, { + kind: "publication", + publicationId: authority.publicationId, + digest: candidateDigest, + identity: authority.identity, + statSignature: publicationPhysicalSignatureKey(targetIdentity), + }); + this.applyObservationTransition(record); + if (record.source !== "publication" || !this.disk + || this.disk.observationGeneration !== authority.observationGeneration + || this.disk.digest !== authority.digest + || this.disk.identity.dev !== authority.identity.dev + || this.disk.identity.ino !== authority.identity.ino) { + throw new NativeMainAuthChangedError(); + } + const receipt = immutableWriteReceipt( + this.authPath, + candidateDigest, + targetIdentity, + authority.observationGeneration, + authority.observationSequence, + authority.publicationId, + this.disk.physicalSignature, + ); + this.activeReceipts.set(authority.publicationId, { receipt, invalidated: false }); + return receipt; + } + + private capturePublicationAuthority( + raw: string, + candidateDigest: string, + targetIdentity: AtomicWriteTargetIdentity, + ): PublicationAuthority { + if (this.stopped || this.fileStatus !== "valid" || !this.disk + || this.leafProvenance !== "regular" || this.disk.leafProvenance !== "regular") { + throw new NativeMainAuthChangedError(); + } + return Object.freeze({ + publicationId: ++this.nextReceiptPublicationId, + observationSequence: this.observationLedger.observationSequence, + unownedObservationSequence: this.observationLedger.unownedObservationSequence, + observationGeneration: this.observationLedger.observationGeneration + 1, + identity: freezeIdentity(targetIdentity), + digest: candidateDigest, + raw, + }); + } + + private observeReceiptCurrent(receipt: NativeMainAuthWriteReceipt): boolean { + if (this.stopped) return false; + const active = this.activeReceipts.get(receipt.publicationId); + if (!active || active.invalidated || active.receipt !== receipt) return false; + this.lastForcedReadAt = this.now(); + const record = this.classifyPhysicalObservation(true, { + kind: "receipt-check", + publicationId: receipt.publicationId, + digest: receipt.digest, + identity: receipt.identity, + statSignature: receipt.statSignature, + }); + this.applyObservationTransition(record); + return record.source === "receipt-check" + && !active.invalidated + && this.fileStatus === "valid" + && !!this.disk + && this.disk.leafProvenance === "regular" + && this.memory?.leafProvenance === "regular" + && nativeMainAuthReceiptMatches(this.disk, receipt); + } + + private classifyPublicationFence(expected: NativeMainAuthWriteFence): "current" | "changed" | "blocked" { + if (this.fileStatus !== "valid" || !this.disk || this.observationLedger.deletionConfirmed) return "blocked"; + if (nativeMainAuthFenceMatches(this.disk, expected)) return "current"; + if (this.observationLedger.lastCompleteChangeGeneration > expected.observationGeneration) return "changed"; + if (this.observationLedger.lastHiccupGeneration > expected.observationGeneration) return "blocked"; + return "changed"; + } + + private canContinueRetryAfterHiccup(pending: PendingRefresh): boolean { + const expected = pending.retryFence; + if (this.observationLedger.observationHistoryFloor > expected.observationGeneration + || this.fileStatus !== "valid" + || !this.disk + || this.observationLedger.deletionConfirmed + || !nativeMainAuthFenceContentMatches(this.disk, expected)) return false; + let hiccupObserved = false; + let baseRecoveredAfterHiccup = false; + for (const entry of this.observationLedger.observationHistory) { + if (entry.generation <= expected.observationGeneration) continue; + if (entry.kind !== "complete") { + hiccupObserved = true; + baseRecoveredAfterHiccup = false; + continue; + } + if (entry.digest !== expected.digest + || entry.identity?.dev !== expected.identity.dev + || entry.identity?.ino !== expected.identity.ino + || entry.physicalSignature !== pending.retryPhysicalSignature) return false; + if (hiccupObserved) baseRecoveredAfterHiccup = true; + } + return hiccupObserved && baseRecoveredAfterHiccup; + } + + private writeAuthFile( + raw: string, + options: NativeMainAuthWriteOptions, + asynchronous: false, + ): NativeMainAuthWriteReceipt; + private writeAuthFile( + raw: string, + options: NativeMainAuthWriteOptions, + asynchronous: true, + writeOverride?: NativeMainAuthWriteOverride, + ): Promise; + private writeAuthFile( + raw: string, + options: NativeMainAuthWriteOptions, + asynchronous: boolean, + writeOverride?: NativeMainAuthWriteOverride, + ): NativeMainAuthWriteReceipt | Promise { + if (this.stopped) throw new NativeMainAuthStoppedError(); + if (!parseChatGptAuth(raw)) throw new Error("Refusing to publish an invalid native-main credential."); + this.observeNow(true); + if (this.classifyPublicationFence(options.expectedSnapshot) !== "current") throw new NativeMainAuthChangedError(); + const candidateDigest = digest(raw); + const candidateGeneration = ++this.nextPublicationGeneration; + this.publishingDigest = candidateDigest; + this.publishingGeneration = candidateGeneration; + let receipt: NativeMainAuthWriteReceipt | undefined; + let publicationAuthority: PublicationAuthority | undefined; + const matchesExpected = (): boolean => this.classifyPublicationFence(options.expectedSnapshot) === "current"; + const validateBeforeRename = (_targetPath: string, tempIdentity?: AtomicWriteTargetIdentity): void => { + if (this.stopped) throw new NativeMainAuthStoppedError(); + this.observeNow(true); + if (this.publishingGeneration !== candidateGeneration || !matchesExpected() || !tempIdentity) { + throw new NativeMainAuthChangedError(); + } + publicationAuthority = this.capturePublicationAuthority(raw, candidateDigest, tempIdentity); + }; + const hooks: AtomicWriteHooks = { + beforeRename: options.beforeRename, + validateBeforeRename, + afterRenameEvidence: (_targetPath, evidence) => { + if (!publicationAuthority) throw new NativeMainAuthChangedError(); + receipt = this.recordOwnRename(candidateDigest, evidence, publicationAuthority); + }, + }; + const asyncHooks: AtomicWriteAsyncHooks = { + beforeRename: options.beforeRename, + validateBeforeRename, + afterRenameEvidence: (_targetPath, evidence) => { + if (!publicationAuthority) throw new NativeMainAuthChangedError(); + receipt = this.recordOwnRename(candidateDigest, evidence, publicationAuthority); + }, + }; + const verify = (): NativeMainAuthWriteReceipt => { + if (this.stopped) throw new NativeMainAuthStoppedError(); + const receiptCurrent = !!receipt && this.observeReceiptCurrent(receipt); + if (this.publishingGeneration !== candidateGeneration + || !receiptCurrent + || this.fileStatus !== "valid" + || this.leafProvenance !== "regular" + || !this.disk + || !receipt + || !nativeMainAuthReceiptMatches(this.disk, receipt)) { + throw new NativeMainAuthChangedError(); + } + return receipt; + }; + + if (asynchronous) { + const write = atomicWriteFileAsync(this.authPath, raw, writeOverride ?? this.writeAsyncIO, undefined, asyncHooks); + return write + .then(verify) + .catch(error => { + if (error instanceof NativeMainAuthChangedError || error instanceof NativeMainAuthStoppedError) throw error; + const observed = this.observeNow(true); + if (observed.fileStatus !== "valid" || !observed.canWriteFile) throw new NativeMainAuthChangedError(); + throw error; + }) + .finally(() => { + if (this.publishingGeneration === candidateGeneration) { + this.publishingGeneration = null; + this.publishingDigest = null; + } + }); + } + try { + atomicWriteFile(this.authPath, raw, this.writeSyncIO, hooks); + return verify(); + } finally { + if (this.publishingGeneration === candidateGeneration) { + this.publishingGeneration = null; + this.publishingDigest = null; + } + } + } + + private retryPendingRefresh(): void { + if (!this.pendingRefresh || this.pendingRefresh.recoveryRequired + || this.retryTimer || this.retryInFlight || this.stopped) return; + this.scheduleRetry(this.pendingRefresh.retryDelayMs); + } + + private scheduleRetry(delayMs: number): void { + if (this.retryTimer || this.retryInFlight || this.stopped + || !this.pendingRefresh || this.pendingRefresh.recoveryRequired) return; + const delay = Math.max(NATIVE_MAIN_AUTH_RETRY_INITIAL_MS, delayMs); + this.retryTimer = this.fileSystem.setTimeout(() => { + this.retryTimer = null; + if (this.stopped) return; + void this.retryPendingRefreshUnderClaim(); + }, delay); + this.retryTimer.unref?.(); + } + + private async retryPendingRefreshUnderClaim(): Promise { + if (!this.pendingRefresh || this.pendingRefresh.recoveryRequired || this.stopped || this.retryInFlight) return; + const pending = this.pendingRefresh; + const controller = new AbortController(); + this.retryInFlight = true; + this.retryAbortController = controller; + try { + const context = resolveNativeProfileContext({ codexHome: dirname(this.authPath) }); + await this.claimExclusive(context, async () => { + if (this.stopped || controller.signal.aborted || this.pendingRefresh !== pending) return; + this.tryPublishPendingRefresh(); + }, { waitMs: 30_000, signal: controller.signal }); + } catch { + // A later observer tick retries claim or publication failures. + } finally { + this.retryInFlight = false; + if (this.retryAbortController === controller) this.retryAbortController = null; + if (this.stopped || !this.pendingRefresh) return; + if (this.pendingRefresh.recoveryRequired) return; + if (this.pendingRefresh === pending) { + pending.retryDelayMs = Math.min( + NATIVE_MAIN_AUTH_RETRY_MAX_MS, + pending.retryDelayMs * 2, + ); + } + this.scheduleRetry(this.pendingRefresh.retryDelayMs); + } + } + + private clearRetryTimer(): void { + if (!this.retryTimer) return; + this.fileSystem.clearTimeout(this.retryTimer); + this.retryTimer = null; + } + + private clearMemory(): void { + this.memory?.raw.fill(0); + this.disk?.raw.fill(0); + this.memory = null; + this.disk = null; + } +} + +const stores = new Map(); +let detachShutdownHook: (() => void) | null = null; + +function storeFor(path: string): NativeMainCredentialStore { + const target = resolve(path); + let store = stores.get(target); + if (!store) { + store = new NativeMainCredentialStore({ authPath: target, graceMs: resolveNativeMainAuthGraceMs(loadConfig()) }); + stores.set(target, store); + if (didRunOptionalShutdownHooks()) { + store.stop(); + } else { + store.start(); + if (!detachShutdownHook) { + detachShutdownHook = registerOptionalShutdownHook("native-main-auth", stopNativeMainCredentialWatchers); + } + } + } + return store; +} + +export function getNativeMainAuthMemory(path: string): NativeMainAuthMemoryRead { + return storeFor(path).readMemory(); +} + +export function observeNativeMainAuthFile(path: string): NativeMainAuthMemoryRead { + return storeFor(path).observeNow(); +} + +export function nativeMainAuthCanRefreshOrWrite(path: string): boolean { + return storeFor(path).canRefreshOrWrite(); +} + +export function persistRefreshedNativeMainAuthFile( + path: string, + raw: string, + options: NativeMainRefreshWriteOptions, +): Promise { + return storeFor(path).persistRefresh(raw, options); +} + +export function writeNativeMainAuthFileSyncClaimed( + path: string, + raw: string, + options: NativeMainAuthWriteOptions, +): NativeMainAuthWriteReceipt { + return storeFor(path).writeAuthFileSync(raw, options); +} + +export async function writeNativeMainAuthFileAsyncClaimed( + path: string, + raw: string, + options: NativeMainAuthWriteOptions, + writeOverride?: NativeMainAuthWriteOverride, +): Promise { + return await storeFor(path).writeAuthFileAsync(raw, options, writeOverride); +} + +export function verifyNativeMainAuthWriteReceipt(receipt: NativeMainAuthWriteReceipt): boolean { + return storeFor(receipt.path).verifyReceipt(receipt); +} + +export function stopNativeMainCredentialWatchers(): void { + for (const store of stores.values()) store.stop(); + stores.clear(); + detachShutdownHook?.(); + detachShutdownHook = null; +} diff --git a/src/codex/native-profile-manager.ts b/src/codex/native-profile-manager.ts index e65c225340a..8f739e78801 100644 --- a/src/codex/native-profile-manager.ts +++ b/src/codex/native-profile-manager.ts @@ -21,6 +21,7 @@ import { } from "node:fs"; import { dirname, join, relative, resolve, sep } from "node:path"; import { atomicWriteFileAsync } from "../config"; +import type { AtomicWriteAsyncHooks, AtomicWriteAsyncIO } from "../config/atomic-write"; import { hardenSecretDirAsync, hardenSecretPathAsync } from "../lib/windows-secret-acl"; import { applyConfirmedMainCodexAccountTransition } from "./account-lifecycle"; import { @@ -45,8 +46,8 @@ import { nativeProfileSelectorKey, OsNativeProfileKeyProvider, publicNativeProfile, + parseNativeEnvelopeBytes, readNativeEnvelope, - readNativeEnvelopeResult, readNativeProfileVault, probeNativeProfileRecoveryState, requireFileCredentialStore, @@ -55,6 +56,7 @@ import { serializeNativeProfileJournal, serializeNativeProfileMetadata, validateNativeProfileLabel, + type NativeEnvelopeReadResult, type NativeEnvelopeSnapshot, type NativeProfileContext, } from "./native-profile-store"; @@ -74,6 +76,19 @@ import { type NativeStageProof, type NativeStageTerminalOutcome, } from "./native-profile-stage-store"; +import { + getNativeMainAuthMemory, + NativeMainAuthChangedError, + nativeMainAuthFence, + nativeMainAuthFenceMatches, + nativeMainAuthReceiptFence, + nativeMainAuthStrictNoFollowAllowed, + observeNativeMainAuthFile, + verifyNativeMainAuthWriteReceipt, + writeNativeMainAuthFileAsyncClaimed, + type NativeMainAuthWriteFence, + type NativeMainAuthWriteReceipt, +} from "./native-main-credentials"; const LOCK_WAIT_MS = 5_000; const LEGACY_STAGE_MAX_AGE_MS = 30 * 60_000; @@ -84,13 +99,73 @@ interface PathIdentity { ino: bigint; } -type AtomicWriter = (path: string, content: string) => Promise; +type AtomicWriter = (path: string, content: string, hooks?: AtomicWriteAsyncHooks) => Promise; +type NativeAuthWriter = ( + path: string, + content: string, + expectedSnapshot: NativeMainAuthWriteFence, +) => Promise; type TransitionApplier = (fromAccountId: string, toAccountId: string) => void; type EnvelopeReader = (path: string) => NativeEnvelopeSnapshot; type VaultReader = () => NativeMainProfileVaultV1 | null; -type EnvelopeResultReader = (path: string) => ReturnType; +type EnvelopeResultReader = (path: string) => NativeEnvelopeReadResult; type StableLockOpener = (path: string) => StableLockFile; type StableLockAsserter = (path: string, file: StableLockFile) => void; + +function readCurrentNativeEnvelopeResultFromMemory(path: string): NativeEnvelopeReadResult { + const observation = getNativeMainAuthMemory(path); + if (observation.status !== "ok" || !observation.snapshot) { + const status = observation.status === "missing" || observation.status === "invalid" + ? observation.status + : "unreadable"; + observation.raw?.fill(0); + observation.snapshot?.raw.fill(0); + return { status }; + } + if (!nativeMainAuthStrictNoFollowAllowed(observation)) { + observation.raw?.fill(0); + observation.snapshot.raw.fill(0); + return { status: "unreadable" }; + } + const raw = observation.snapshot.raw; + observation.raw?.fill(0); + try { + const envelope = parseNativeEnvelopeBytes(raw); + envelope.nativeMainAuthFence = nativeMainAuthFence(observation.snapshot); + envelope.isSymlink = observation.snapshot.isSymlink; + return { status: "ok", envelope }; + } catch { + raw.fill(0); + return { status: "invalid" }; + } +} + +function assertNativeMainAuthWritable(path: string): NativeMainAuthWriteFence { + const observation = observeNativeMainAuthFile(path); + const status = observation.fileStatus === "valid" ? "unreadable" : observation.fileStatus; + const unsafeProvenance = !nativeMainAuthStrictNoFollowAllowed(observation); + const fence = observation.snapshot ? nativeMainAuthFence(observation.snapshot) : null; + observation.raw?.fill(0); + observation.snapshot?.raw.fill(0); + if (observation.fileStatus !== "valid" || !observation.canWriteFile + || !observation.canRefreshOrWrite || unsafeProvenance || !fence) { + const code = status === "missing" ? "AUTH_MISSING" + : status === "invalid" ? "AUTH_INVALID" + : "AUTH_UNREADABLE"; + throw new NativeProfileError(code, "The native Codex credential envelope is " + status + ".", 409); + } + return fence; +} + +function readCurrentNativeEnvelopeFromMemory(path: string): NativeEnvelopeSnapshot { + const result = readCurrentNativeEnvelopeResultFromMemory(path); + if (result.status === "ok") return result.envelope; + const code = result.status === "missing" ? "AUTH_MISSING" + : result.status === "invalid" ? "AUTH_INVALID" + : "AUTH_UNREADABLE"; + throw new NativeProfileError(code, "The native Codex credential envelope is " + result.status + ".", 409); +} + export type NativeProfileSwitchBoundary = | "journal-prepared" | "auth-replaced" @@ -103,6 +178,7 @@ export interface NativeProfileManagerOptions { configDir?: string; keyProvider?: NativeProfileKeyProvider; atomicWrite?: AtomicWriter; + nativeAuthWriteIO?: AtomicWriteAsyncIO; hardenPath?: (path: string) => Promise; processProbe?: () => Promise; applyTransition?: TransitionApplier; @@ -197,6 +273,7 @@ export class NativeProfileManager { readonly context: NativeProfileContext; private readonly keyProvider: NativeProfileKeyProvider; private readonly atomicWrite: AtomicWriter; + private readonly writeNativeAuth: NativeAuthWriter; private readonly hardenPath: (path: string) => Promise; private readonly processProbe: () => Promise; private readonly applyTransition: TransitionApplier; @@ -219,7 +296,14 @@ export class NativeProfileManager { constructor(options: NativeProfileManagerOptions = {}) { this.context = resolveNativeProfileContext(options); this.keyProvider = options.keyProvider ?? new OsNativeProfileKeyProvider(); - this.atomicWrite = options.atomicWrite ?? atomicWriteFileAsync; + this.atomicWrite = options.atomicWrite ?? ((path, content, hooks) => + atomicWriteFileAsync(path, content, undefined, undefined, hooks)); + this.writeNativeAuth = (path, content, expectedSnapshot) => writeNativeMainAuthFileAsyncClaimed( + path, + content, + { expectedSnapshot }, + options.nativeAuthWriteIO, + ); this.hardenPath = options.hardenPath ?? hardenNativeProfilePath; this.processProbe = options.processProbe ?? probeNativeCodexProcesses; this.applyTransition = options.applyTransition ?? ((from, to) => { applyConfirmedMainCodexAccountTransition(from, to); }); @@ -230,9 +314,9 @@ export class NativeProfileManager { this.onLockAcquired = options.onLockAcquired ?? (() => {}); this.removeStageTree = options.removeStageTree ?? (path => rmSync(path, { recursive: true, force: false })); this.onSwitchBoundary = options.onSwitchBoundary ?? (() => {}); - this.readEnvelope = options.readEnvelope ?? readNativeEnvelope; + this.readEnvelope = options.readEnvelope ?? (() => readCurrentNativeEnvelopeFromMemory(this.context.authPath)); this.readVault = options.readVault ?? (() => readNativeProfileVault(this.context)); - this.readEnvelopeResult = options.readEnvelopeResult ?? readNativeEnvelopeResult; + this.readEnvelopeResult = options.readEnvelopeResult ?? (() => readCurrentNativeEnvelopeResultFromMemory(this.context.authPath)); this.stableLockOpen = options.stableLockOpen ?? openStableLockFile; this.stableLockAssert = options.stableLockAssert ?? assertStableLockFile; this.stageStore = new NativeProfileStageStore({ @@ -444,6 +528,7 @@ export class NativeProfileManager { try { const vault = this.readVault(); if (!vault) return false; + assertNativeMainAuthWritable(this.context.authPath); key = await this.keyForVault(vault); current = this.readEnvelope(this.context.authPath); this.assertCurrentIdentity(vault, current, key); @@ -712,15 +797,15 @@ export class NativeProfileManager { private deleteStage(path: string, identity?: PathIdentity): void { if (identity) assertPathIdentity(path, identity, "The native-login staging directory"); - const authPath = join(path, "auth.json"); + const stagedAuthPath = join(path, "auth.json"); let authFd: number | undefined; try { const flags = process.platform === "win32" ? fsConstants.O_RDWR : fsConstants.O_RDWR | fsConstants.O_NOFOLLOW; - authFd = openSync(authPath, flags); + authFd = openSync(stagedAuthPath, flags); const opened = fstatSync(authFd, { bigint: true }); - const pathEntry = lstatSync(authPath, { bigint: true }); + const pathEntry = lstatSync(stagedAuthPath, { bigint: true }); if ( !opened.isFile() || !pathEntry.isFile() @@ -736,7 +821,7 @@ export class NativeProfileManager { true, true, ); - unlinkSync(authPath); + unlinkSync(stagedAuthPath); const unlinked = fstatSync(authFd, { bigint: true }); if (unlinked.dev !== opened.dev || unlinked.ino !== opened.ino || unlinked.nlink !== 0n) { throw new NativeProfileError( @@ -1086,7 +1171,7 @@ export class NativeProfileManager { const label = validateNativeProfileLabel(labelInput); target = readNativeEnvelope(join(ownedStage.path, "auth.json")); assertPathIdentity(ownedStage.path, ownedStage.identity, "The native-login staging directory"); - current = readNativeEnvelope(this.context.authPath); + current = this.readEnvelope(this.context.authPath); const vault = this.requireVault(); if (vault.profiles.length >= MAX_NATIVE_PROFILES) { throw new NativeProfileError("INVALID_REQUEST", "Native profiles are limited to 32 entries.", 400); @@ -1248,6 +1333,22 @@ export class NativeProfileManager { } } + private assertNativeAuthReceiptCurrent(receipt: NativeMainAuthWriteReceipt): void { + if (!verifyNativeMainAuthWriteReceipt(receipt)) throw new NativeMainAuthChangedError(); + } + + private assertNativeAuthFenceCurrent(fence: NativeMainAuthWriteFence): void { + const observation = observeNativeMainAuthFile(this.context.authPath); + const matches = observation.fileStatus === "valid" + && observation.canWriteFile + && observation.leafProvenance === "regular" + && observation.snapshot?.leafProvenance === "regular" + && nativeMainAuthFenceMatches(observation.snapshot, fence); + observation.raw?.fill(0); + observation.snapshot?.raw.fill(0); + if (!matches) throw new NativeMainAuthChangedError(); + } + async switch(targetSelector: string, confirmedStopped = false): Promise> { return this.withLock(async () => { await this.sweepStagesLocked(); @@ -1260,9 +1361,21 @@ export class NativeProfileManager { let target: NativeEnvelopeSnapshot | null = null; let journalPrepared = false; let committed = false; + let sourceFence: NativeMainAuthWriteFence | null = null; + let targetReceipt: NativeMainAuthWriteReceipt | null = null; try { + sourceFence = assertNativeMainAuthWritable(this.context.authPath); key = await this.keyForVault(beforeVault); + const sourceObservation = observeNativeMainAuthFile(this.context.authPath); + const sourceStillMatches = sourceObservation.canWriteFile + && sourceObservation.leafProvenance === "regular" + && sourceObservation.snapshot?.leafProvenance === "regular" + && nativeMainAuthFenceMatches(sourceObservation.snapshot, sourceFence); + sourceObservation.raw?.fill(0); + sourceObservation.snapshot?.raw.fill(0); + if (!sourceStillMatches) throw new NativeMainAuthChangedError(); source = this.readEnvelope(this.context.authPath); + if (source.digest !== sourceFence.digest) throw new NativeMainAuthChangedError(); const sourceProfile = this.assertCurrentIdentity(beforeVault, source, key); const targetProfile = this.resolveTarget(beforeVault, targetSelector); target = decryptNativeEnvelope(this.context, targetProfile.id, targetProfile.identityHash, targetProfile.payload!, key); @@ -1305,20 +1418,28 @@ export class NativeProfileManager { journalPrepared = true; await this.onSwitchBoundary("journal-prepared"); this.assertOperationStorageStable(); - await this.atomicWrite(this.context.authPath, target.text); + targetReceipt = await this.writeNativeAuth(this.context.authPath, target.text, sourceFence); const observedTarget = this.verifyWrittenEnvelope(target.digest, targetProfile.identityHash, key); observedTarget.raw.fill(0); advanceCodexCredentialMutationEpoch(); + this.assertNativeAuthReceiptCurrent(targetReceipt); await this.onSwitchBoundary("auth-replaced"); + this.assertNativeAuthReceiptCurrent(targetReceipt); journal.phase = "auth-replaced"; await this.writeJournal(journal); + this.assertNativeAuthReceiptCurrent(targetReceipt); await this.writeVault(afterVault); committed = true; + this.assertNativeAuthReceiptCurrent(targetReceipt); await this.onSwitchBoundary("vault-committed"); + this.assertNativeAuthReceiptCurrent(targetReceipt); journal.phase = "vault-committed"; await this.writeJournal(journal); + this.assertNativeAuthReceiptCurrent(targetReceipt); this.applyTransition(source.accountId, target.accountId); + this.assertNativeAuthReceiptCurrent(targetReceipt); await this.onSwitchBoundary("runtime-transition-published"); + this.assertNativeAuthReceiptCurrent(targetReceipt); this.removeJournal(); await this.onSwitchBoundary("journal-deleted"); return { @@ -1332,15 +1453,44 @@ export class NativeProfileManager { if (committed) { throw new NativeProfileError("RECOVERY_REQUIRED", "The login changed, but final transaction cleanup requires recovery.", 409); } + if (!targetReceipt) { + try { + if (!sourceFence) throw new NativeMainAuthChangedError(); + this.assertNativeAuthFenceCurrent(sourceFence); + this.removeJournal(); + } catch { + throw new NativeProfileError( + "RECOVERY_REQUIRED", + "The current auth.json changed before publication; the encrypted recovery journal was retained.", + 409, + ); + } + throw new NativeProfileError("SWITCH_ROLLED_BACK", "The native-login switch failed before publication; the original login remains current.", 409); + } try { this.assertOperationStorageStable(); - await this.atomicWrite(this.context.authPath, source!.text); + this.assertNativeAuthReceiptCurrent(targetReceipt); + const rollbackReceipt = await this.writeNativeAuth( + this.context.authPath, + source!.text, + nativeMainAuthReceiptFence(targetReceipt), + ); + this.assertNativeAuthReceiptCurrent(rollbackReceipt); const restored = this.verifyWrittenEnvelope(source!.digest, nativeIdentityHash(key!.key, source!.accountId), key!); restored.raw.fill(0); + this.assertNativeAuthReceiptCurrent(rollbackReceipt); advanceCodexCredentialMutationEpoch(); await this.writeVault(beforeVault); + this.assertNativeAuthReceiptCurrent(rollbackReceipt); this.removeJournal(); - } catch { + } catch (rollbackError) { + if (rollbackError instanceof NativeMainAuthChangedError) { + throw new NativeProfileError( + "RECOVERY_REQUIRED", + "The current auth.json is invalid or changed; the encrypted recovery journal was retained until a valid state is observed.", + 409, + ); + } throw new NativeProfileError( "AUTH_RESTORE_FAILED", "The original native login could not be verified after rollback; the encrypted recovery journal was retained.", @@ -1382,18 +1532,44 @@ export class NativeProfileManager { }; } let key: NativeProfileKey | null = null; - let current: ReturnType | null = null; + let current: NativeEnvelopeReadResult | null = null; let sourceEnvelope: NativeEnvelopeSnapshot | null = null; try { + const initialObservation = observeNativeMainAuthFile(this.context.authPath); + const initialFence = initialObservation.fileStatus === "valid" + && initialObservation.canWriteFile + && initialObservation.leafProvenance === "regular" + && initialObservation.snapshot?.leafProvenance === "regular" + && initialObservation.snapshot + ? nativeMainAuthFence(initialObservation.snapshot) + : null; + initialObservation.raw?.fill(0); + initialObservation.snapshot?.raw.fill(0); key = await this.keyForVault(journal.beforeVault); - current = readNativeEnvelopeResult(this.context.authPath); - if (current.status !== "ok") { + if (!initialFence) { + throw new NativeProfileError("RECOVERY_REQUIRED", "Recovery stopped because auth.json is not a valid physical file; the encrypted journal remains available for retry.", 409); + } + const fileObservation = observeNativeMainAuthFile(this.context.authPath); + const canRecover = fileObservation.fileStatus === "valid" + && fileObservation.canWriteFile + && fileObservation.leafProvenance === "regular" + && fileObservation.snapshot?.leafProvenance === "regular" + && nativeMainAuthFenceMatches(fileObservation.snapshot, initialFence); + fileObservation.raw?.fill(0); + fileObservation.snapshot?.raw.fill(0); + current = this.readEnvelopeResult(this.context.authPath); + if (!canRecover || current.status !== "ok") { throw new NativeProfileError("RECOVERY_REQUIRED", "Recovery stopped because the current native login is not readable and confirmed.", 409); } + if (current.envelope.digest !== initialFence.digest) { + throw new NativeProfileError("RECOVERY_REQUIRED", "Recovery stopped because auth.json changed after recovery began; the encrypted journal remains available for retry.", 409); + } const currentHash = nativeIdentityHash(key.key, current.envelope.accountId); if (rollback) { if (currentHash === journal.sourceIdentityHash) { + this.assertNativeAuthFenceCurrent(initialFence!); await this.writeVault(journal.beforeVault); + this.assertNativeAuthFenceCurrent(initialFence!); this.removeJournal(); return { ok: true, @@ -1444,17 +1620,35 @@ export class NativeProfileManager { const preservedJournal = structuredClone(journal); preservedJournal.targetPayload = refreshedTargetPayload; preservedJournal.beforeVault = structuredClone(rollbackVault); + this.assertNativeAuthFenceCurrent(initialFence!); await this.writeJournal(preservedJournal); + this.assertNativeAuthFenceCurrent(initialFence!); await this.writeVault(rollbackVault); + this.assertNativeAuthFenceCurrent(initialFence!); rollbackVaultPublished = true; } this.assertOperationStorageStable(); - await this.atomicWrite(this.context.authPath, sourceEnvelope.text); + this.assertNativeAuthFenceCurrent(initialFence!); + let rollbackReceipt: NativeMainAuthWriteReceipt; + try { + rollbackReceipt = await this.writeNativeAuth(this.context.authPath, sourceEnvelope.text, initialFence!); + } catch (error) { + if (error instanceof NativeMainAuthChangedError) { + throw new NativeProfileError("RECOVERY_REQUIRED", "Recovery stopped because auth.json changed or became invalid; the encrypted journal remains available for retry.", 409); + } + throw error; + } + this.assertNativeAuthReceiptCurrent(rollbackReceipt); const restored = this.verifyWrittenEnvelope(sourceEnvelope.digest, journal.sourceIdentityHash, key); restored.raw.fill(0); + this.assertNativeAuthReceiptCurrent(rollbackReceipt); advanceCodexCredentialMutationEpoch(); - if (!rollbackVaultPublished) await this.writeVault(rollbackVault); + if (!rollbackVaultPublished) { + await this.writeVault(rollbackVault); + this.assertNativeAuthReceiptCurrent(rollbackReceipt); + } this.applyTransition(current.envelope.accountId, sourceEnvelope.accountId); + this.assertNativeAuthReceiptCurrent(rollbackReceipt); this.removeJournal(); return { ok: true, @@ -1475,7 +1669,9 @@ export class NativeProfileManager { throw new NativeProfileError("RECOVERY_REQUIRED", "Recovery found a third or unknown native identity and made no credential write.", 409); } if (decision.action === "rollback-source") { + this.assertNativeAuthFenceCurrent(initialFence!); await this.writeVault(journal.beforeVault); + this.assertNativeAuthFenceCurrent(initialFence!); this.removeJournal(); return { ok: true, @@ -1493,8 +1689,11 @@ export class NativeProfileManager { journal.sourcePayload, key, ); + this.assertNativeAuthFenceCurrent(initialFence!); await this.writeVault(journal.afterVault); + this.assertNativeAuthFenceCurrent(initialFence!); this.applyTransition(sourceEnvelope.accountId, current.envelope.accountId); + this.assertNativeAuthFenceCurrent(initialFence!); this.removeJournal(); return { ok: true, diff --git a/src/codex/native-profile-startup.ts b/src/codex/native-profile-startup.ts index ade3d036480..2b84aa73618 100644 --- a/src/codex/native-profile-startup.ts +++ b/src/codex/native-profile-startup.ts @@ -141,7 +141,11 @@ export function initializeNativeMainStartupGate( settled = (async () => { try { await deps.beforeRecovery?.(); - await manager.recover(false); + await withNativeMainExclusiveClaim( + manager.context, + () => manager.recover(false), + { waitMs: 10_000 }, + ); if (epoch === currentEpoch && probe(manager.context) === "none") { clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); snapshot = ready(homeId); diff --git a/src/codex/native-profile-store.ts b/src/codex/native-profile-store.ts index 7ab797761d6..02f635c53ff 100644 --- a/src/codex/native-profile-store.ts +++ b/src/codex/native-profile-store.ts @@ -83,6 +83,12 @@ export interface NativeEnvelopeSnapshot { text: string; digest: string; accountId: string; + nativeMainAuthFence?: { + digest: string; + identity: { dev: number; ino: number }; + observationGeneration: number; + }; + isSymlink?: boolean; } export type NativeEnvelopeReadResult = @@ -377,7 +383,7 @@ export function resolveNativeProfileContext(options: { codexHome?: string; confi }; } -export function readBounded(path: string, limit: number, testSeam?: BoundedReadTestSeam): Buffer { +function readBounded(path: string, limit: number, testSeam?: BoundedReadTestSeam): Buffer { let fd: number | undefined; let failed = false; try { diff --git a/src/codex/orca-import.ts b/src/codex/orca-import.ts index 918998aae85..702fc4363e4 100644 --- a/src/codex/orca-import.ts +++ b/src/codex/orca-import.ts @@ -1,9 +1,13 @@ import { randomUUID } from "node:crypto"; import { lstatSync, unlinkSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { atomicWriteFile, getConfigDir, getConfigPath, mutatePersistedConfig, readConfigDiagnostics, withConfigMutationLockSync } from "../config"; import { readAlivePid, readRuntimePort } from "../config/process-state"; import { getCodexHome } from "./paths"; +import { + observeNativeMainAuthFile, + nativeMainAuthStrictNoFollowAllowed, +} from "./native-main-credentials"; import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch"; import { assertPlainLocalPath, ORCA_ACCOUNT_DIRECTORY, parseOrcaAuth, readBoundedLocalFile, readOrcaAuthSource, sameLocalPath } from "./orca-auth-source"; @@ -56,6 +60,43 @@ function assertStopped(): void { if (running) throw new OrcaImportError("Stop the opencodex proxy before applying an Orca import."); } +export function observeOrcaMainAuthMemory(authPath: string) { + try { + assertPlainLocalPathThroughExistingPrefix(authPath); + } catch { + throw new OrcaImportError("Main Codex identity is unknown; import refused."); + } + const observation = observeNativeMainAuthFile(authPath); + let pathIsPlain = true; + try { + assertPlainLocalPathThroughExistingPrefix(authPath); + } catch { + pathIsPlain = false; + } + if (!pathIsPlain || observation.status === "stopped" + || observation.status === "ok" && !nativeMainAuthStrictNoFollowAllowed(observation)) { + observation.snapshot?.raw.fill(0); + observation.raw?.fill(0); + throw new OrcaImportError("Main Codex identity is unknown; import refused."); + } + return observation; +} + +function assertPlainLocalPathThroughExistingPrefix(path: string): void { + let candidate = resolve(path); + while (true) { + try { + assertPlainLocalPath(candidate); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + const parent = dirname(candidate); + if (parent === candidate) throw error; + candidate = parent; + } + } +} + function prepare(sourceDir: string, registryPath: string) { const configRaw = optionalFile(getConfigPath()); if (!configRaw || readConfigDiagnostics().source !== "file") { @@ -101,7 +142,25 @@ function prepare(sourceDir: string, registryPath: string) { recoverable.set(credential.chatgptAccountId, { id, credential }); } } - const mainRaw = optionalFile(join(getCodexHome(), "auth.json")); + const mainObservation = observeOrcaMainAuthMemory(join(getCodexHome(), "auth.json")); + if (mainObservation.status === "stopped") { + throw new OrcaImportError("Main Codex identity is unknown; import refused."); + } + if (mainObservation.status === "unreadable") { + throw new OrcaImportError("A target or main credential file is unreadable; import refused."); + } + if (mainObservation.status === "invalid" || mainObservation.fileStatus === "invalid") { + throw new OrcaImportError("Main Codex identity is unknown; import refused."); + } + // "missing" means no login is held in memory, so there is no main identity to protect. A login + // kept through the deletion grace reports "ok" with a missing file and stays refused. + if ((mainObservation.status !== "ok" || mainObservation.fileStatus !== "valid") + && mainObservation.status !== "missing") { + throw new OrcaImportError("Main Codex identity is not confirmed; import refused."); + } + const mainRaw = mainObservation.snapshot?.raw.toString("utf-8"); + mainObservation.snapshot?.raw.fill(0); + mainObservation.raw?.fill(0); if (mainRaw !== undefined) { const main = object(JSON.parse(mainRaw)); if (main.tokens !== undefined) { diff --git a/src/config/atomic-write.ts b/src/config/atomic-write.ts index 9e0452741bb..859dfb9613d 100644 --- a/src/config/atomic-write.ts +++ b/src/config/atomic-write.ts @@ -6,11 +6,13 @@ import { fstatSync, lstatSync, openSync, + readFileSync, realpathSync, truncateSync, unlinkSync, writeFileSync, } from "node:fs"; +import { createHash } from "node:crypto"; import { basename, dirname, join } from "node:path"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { assertNotRealCodexHomeUnderTest, assertNotRealHomeUnderTest } from "../lib/test-home-guard"; @@ -72,9 +74,24 @@ export interface AtomicWriteIO { export interface AtomicWriteHooks { afterTempWrite?: (tempPath: string, targetPath: string) => void; beforeRename?: (tempPath: string, targetPath: string) => void; - validateBeforeRename?: (targetPath: string) => void; + validateBeforeRename?: (targetPath: string, replacementIdentity?: AtomicWriteTargetIdentity) => void; /** Publication receipt; runs before any post-rename cleanup can fail. */ - afterRename?: (targetPath: string) => void; + afterRename?: (targetPath: string, identity?: AtomicWriteTargetIdentity) => void; + afterRenameEvidence?: (targetPath: string, evidence: AtomicWritePublicationEvidence) => void; +} + +export interface AtomicWriteTargetIdentity { + dev: number; + ino: number; + size: number; + mode: number; + mtimeMs: number; + ctimeMs: number; +} + +export interface AtomicWritePublicationEvidence { + identity: AtomicWriteTargetIdentity; + digest: string; } export class AtomicWriteResidualTempError extends Error { @@ -291,6 +308,18 @@ function atomicWriteFileToTarget( const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; let hardened = false; let ownsTemp = false; + let replacementIdentity: AtomicWriteTargetIdentity | undefined; + let publicationDescriptor: number | undefined; + const validateRenameAttempt = (destination: string, source: string): void => { + let currentIdentity: AtomicWriteTargetIdentity | undefined; + if (replacementIdentity) { + currentIdentity = atomicWriteTargetIdentity(source); + if (currentIdentity.dev !== replacementIdentity.dev || currentIdentity.ino !== replacementIdentity.ino) { + throw new Error("atomic replacement temporary file identity changed before rename"); + } + } + hooks.validateBeforeRename?.(destination, currentIdentity); + }; const effective: AtomicWriteIO = io ?? { write: (tempPath, value) => writePrivateTempFile(tempPath, value, path, () => { ownsTemp = true; }), harden: tempPath => { @@ -304,7 +333,9 @@ function atomicWriteFileToTarget( } try { chmodSync(tempPath, 0o600); } catch { /* platform may ignore chmod */ } }, - rename: (source, destination) => renameAtomicFile(source, destination, undefined, "config", hooks), + rename: (source, destination) => renameAtomicFile(source, destination, undefined, "config", { + validateBeforeRenameAttempt: validateRenameAttempt, + }), truncate: tempPath => truncateSync(tempPath, 0), unlink: unlinkSync, }; @@ -319,9 +350,25 @@ function atomicWriteFileToTarget( effective.harden(tmp); hardened = true; hooks.beforeRename?.(tmp, target); - hooks.validateBeforeRename?.(target); - effective.rename(tmp, target); - hooks.afterRename?.(target); + replacementIdentity = hooks.afterRename || hooks.afterRenameEvidence ? atomicWriteTargetIdentity(tmp) : undefined; + hooks.validateBeforeRename?.(target, replacementIdentity); + if (hooks.afterRenameEvidence) { + publicationDescriptor = openSync(tmp, "r"); + const openedIdentity = atomicWriteDescriptorIdentity(publicationDescriptor); + if (!sameAtomicWriteTargetIdentity(openedIdentity, replacementIdentity)) { + throw new Error("atomic publication descriptor changed before rename"); + } + } + const renameResult: unknown = effective.rename(tmp, target); + if (hooks.afterRenameEvidence && renameResult !== undefined + && typeof (renameResult as { then?: unknown }).then === "function") { + void Promise.resolve(renameResult).catch(() => {}); + throw new Error("injected atomic rename did not capture publication evidence synchronously"); + } + const publicationEvidence = publicationDescriptor !== undefined && hooks.afterRenameEvidence + ? atomicWritePublicationEvidence(publicationDescriptor) : undefined; + if (replacementIdentity) hooks.afterRename?.(target, replacementIdentity); + if (publicationEvidence) hooks.afterRenameEvidence?.(target, publicationEvidence); // The rename is only as durable as the directory entry recording it. Fsyncing the temp's // CONTENT and then losing the entry in a power cut leaves the old file in place, or the // directory in an indeterminate state, while the caller was told the replacement landed. @@ -362,6 +409,8 @@ function atomicWriteFileToTarget( if (removed) forgetEphemeralSecretPath(tmp); if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause }); throw cause; + } finally { + if (publicationDescriptor !== undefined) closeSync(publicationDescriptor); } } @@ -421,7 +470,7 @@ export function atomicWriteFileNoFollowUnclaimed(path: string, content: string): export interface AtomicWriteAsyncIO { write: (path: string, content: string) => void | Promise; harden: (path: string) => void | Promise; - rename: (source: string, destination: string) => void | Promise; + rename: (source: string, destination: string, afterReplace?: () => void) => void | Promise; truncate: (path: string) => void | Promise; unlink: (path: string) => void | Promise; } @@ -430,13 +479,39 @@ export interface AtomicWriteAsyncTestSeam { afterTempWrite?: (tempPath: string) => void | Promise; } +export interface AtomicWriteAsyncHooks { + beforeRename?: (targetPath: string) => void | Promise; + validateBeforeRename?: (targetPath: string, replacementIdentity?: AtomicWriteTargetIdentity) => void; + afterRename?: (targetPath: string, identity?: AtomicWriteTargetIdentity) => void | Promise; + afterRenameEvidence?: (targetPath: string, evidence: AtomicWritePublicationEvidence) => void | Promise; +} + export async function atomicWriteFileAsync( path: string, content: string, io?: AtomicWriteAsyncIO, testSeam?: AtomicWriteAsyncTestSeam, + hooks: AtomicWriteAsyncHooks = {}, ): Promise { let ownsTemp = false; + let replacementIdentity: AtomicWriteTargetIdentity | undefined; + let publicationDescriptor: number | undefined; + let publicationEvidence: AtomicWritePublicationEvidence | undefined; + const capturePublicationEvidence = (): void => { + if (publicationEvidence === undefined && publicationDescriptor !== undefined && hooks.afterRenameEvidence) { + publicationEvidence = atomicWritePublicationEvidence(publicationDescriptor); + } + }; + const validateRenameAttempt = (destination: string, source: string): void => { + let currentIdentity: AtomicWriteTargetIdentity | undefined; + if (replacementIdentity) { + currentIdentity = atomicWriteTargetIdentity(source); + if (currentIdentity.dev !== replacementIdentity.dev || currentIdentity.ino !== replacementIdentity.ino) { + throw new Error("atomic replacement temporary file identity changed before rename"); + } + } + hooks.validateBeforeRename?.(destination, currentIdentity); + }; const effective: AtomicWriteAsyncIO = io ?? { write: (tempPath, value) => writePrivateTempFileAsync(tempPath, value, path, () => { ownsTemp = true; }), harden: async tempPath => { @@ -447,7 +522,13 @@ export async function atomicWriteFileAsync( } try { chmodSync(tempPath, 0o600); } catch { /* platform may ignore chmod */ } }, - rename: renameAtomicFileAsync, + rename: (source, destination) => renameAtomicFileAsync(source, destination, "config", { + validateBeforeRenameAttempt: validateRenameAttempt, + // renameAtomicFileAsync calls this synchronously in the same stack as renameSync. + // Keep publication evidence capture here so its first fstat is the first operation + // after rename returns; do not move it behind the async rename's promise continuation. + afterReplace: capturePublicationEvidence, + }), truncate: target => truncateSync(target, 0), unlink: unlinkSync, }; @@ -463,7 +544,51 @@ export async function atomicWriteFileAsync( await testSeam?.afterTempWrite?.(tmp); await effective.harden(tmp); hardened = true; - await effective.rename(tmp, target); + await hooks.beforeRename?.(target); + replacementIdentity = hooks.afterRename || hooks.afterRenameEvidence ? atomicWriteTargetIdentity(tmp) : undefined; + await hooks.validateBeforeRename?.(target, replacementIdentity); + if (hooks.afterRenameEvidence) { + publicationDescriptor = openSync(tmp, "r"); + const openedIdentity = atomicWriteDescriptorIdentity(publicationDescriptor); + if (!sameAtomicWriteTargetIdentity(openedIdentity, replacementIdentity)) { + throw new Error("atomic publication descriptor changed before rename"); + } + } + if (io) { + let renameCallActive = true; + let evidenceFailure: unknown; + let renameResult: void | Promise; + try { + renameResult = io.rename(tmp, target, () => { + if (!renameCallActive) return; + try { + if (publicationDescriptor === undefined || !hooks.afterRenameEvidence) return; + const evidence = atomicWritePublicationEvidence(publicationDescriptor); + const publishedIdentity = atomicWriteTargetIdentity(target); + if (!sameAtomicWriteTargetIdentity(evidence.identity, publishedIdentity)) { + throw new Error("injected atomic rename evidence preceded publication"); + } + publicationEvidence = evidence; + } catch (error) { + evidenceFailure = error; + } + }); + } finally { + renameCallActive = false; + } + await renameResult; + if (evidenceFailure) throw evidenceFailure; + if (hooks.afterRenameEvidence && publicationEvidence === undefined) { + throw new Error("injected atomic rename did not capture publication evidence synchronously"); + } + } else { + await effective.rename(tmp, target); + if (hooks.afterRenameEvidence && publicationEvidence === undefined) { + throw new Error("atomic rename did not capture publication evidence synchronously"); + } + } + if (replacementIdentity) await hooks.afterRename?.(target, replacementIdentity); + if (publicationEvidence) await hooks.afterRenameEvidence?.(target, publicationEvidence); forgetEphemeralSecretPath(tmp); } catch (cause) { if (!ownsTemp) throw cause; @@ -502,5 +627,55 @@ export async function atomicWriteFileAsync( if (removed) forgetEphemeralSecretPath(tmp); if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause }); throw cause; + } finally { + if (publicationDescriptor !== undefined) closeSync(publicationDescriptor); + } +} + +function atomicWriteTargetIdentity(path: string): AtomicWriteTargetIdentity { + const stat = lstatSync(path); + if (!stat.isFile()) throw new Error("atomic replacement temporary file is not regular"); + return atomicWriteIdentityFromStat(stat); +} + +function atomicWriteDescriptorIdentity(descriptor: number): AtomicWriteTargetIdentity { + const stat = fstatSync(descriptor); + if (!stat.isFile()) throw new Error("atomic publication descriptor is not regular"); + return atomicWriteIdentityFromStat(stat); +} + +function atomicWriteIdentityFromStat(stat: ReturnType): AtomicWriteTargetIdentity { + return { + dev: Number(stat.dev), + ino: Number(stat.ino), + size: Number(stat.size), + mode: Number(stat.mode), + mtimeMs: Number(stat.mtimeMs), + ctimeMs: Number(stat.ctimeMs), + }; +} + +function sameAtomicWriteTargetIdentity( + left: AtomicWriteTargetIdentity, + right: AtomicWriteTargetIdentity | undefined, +): boolean { + return !!right + && left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mode === right.mode + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs; +} + +function atomicWritePublicationEvidence(descriptor: number): AtomicWritePublicationEvidence { + const before = atomicWriteDescriptorIdentity(descriptor); + const bytes = readFileSync(descriptor); + const digest = createHash("sha256").update(bytes).digest("hex"); + const after = atomicWriteDescriptorIdentity(descriptor); + bytes.fill(0); + if (!sameAtomicWriteTargetIdentity(before, after) || after.size !== bytes.byteLength) { + throw new Error("atomic publication changed while evidence was captured"); } + return { identity: after, digest }; } diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index eedb61adf91..4025d96ffa9 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -8,6 +8,7 @@ import { join } from "node:path"; import * as z from "zod/v4"; import { compactionRecoveryConfigError } from "./schema/compaction-recovery"; import { nativeReasoningRetentionConfigError } from "./schema/native-reasoning-retention"; +import { nativeMainAuthGraceSecondsConfigError } from "./schema/native-main-auth-grace"; import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility"; import { blockedModelRedirectsError } from "./schema/blocked-model-redirects"; import { mainAccountHardLockFallbackError } from "./schema/main-account-hard-lock-fallback"; @@ -731,6 +732,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? nativeReasoningRetentionConfigError(value) ?? mainAccountHardLockFallbackError(value) ?? compactionRecoveryConfigError(value) ?? desktopCompatibilityConfigError(value) ?? configReasoningPinsConfigError(value) + ?? nativeMainAuthGraceSecondsConfigError(value) ?? blankHostnameError(value) ?? (rawSubagentModelForce(value) !== undefined && !isSubagentModelEntry(rawSubagentModelForce(value)) ? "schema_invalid: claudeCode.subagentModelForce: expected a safe roster-style model id" : null) ?? claudeSubagentEffortError(value) diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index 0dd593bf863..4693a893922 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -4,6 +4,7 @@ import { chmodSync, existsSync } from "node:fs"; import { join } from "node:path"; import { compactionRecoveryConfigError } from "./schema/compaction-recovery"; import { nativeReasoningRetentionConfigError } from "./schema/native-reasoning-retention"; +import { nativeMainAuthGraceSecondsConfigError } from "./schema/native-main-auth-grace"; import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility"; import { blockedModelRedirectsError } from "./schema/blocked-model-redirects"; import { mainAccountHardLockFallbackError } from "./schema/main-account-hard-lock-fallback"; @@ -139,6 +140,9 @@ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxCon const retentionWarning = degradedReasoningRetentionWarning(rawParsed, validated); if (retentionWarning) console.warn(retentionWarning); if (compactionRecoveryConfigError(rawParsed)) console.warn("⚠️ invalid compactionRecovery disabled; the original compaction failure is preserved"); + if (nativeMainAuthGraceSecondsConfigError(rawParsed)) { + console.warn("⚠️ invalid nativeMainAuthGraceSeconds ignored; the native main login grace stays 60 seconds"); + } if (desktopCompatibilityConfigError(rawParsed)) console.warn("Invalid desktopCompatibility startup preference ignored; no desktop compatibility service will start automatically."); if (blockedModelRedirectsError(rawParsed)) console.warn("⚠️ invalid blockedModelRedirects ignored; provider routing remains available"); if (mainAccountHardLockFallbackError(rawParsed)) console.warn("⚠️ invalid codexMainAccountHardLockFallback ignored; the main-account hard lock still refuses while blocked"); diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index ddf640a8a15..7693686fec0 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -5,6 +5,7 @@ import { accountPauseStateSchema } from "./account-auto-pause"; import { anthropicAccountPoolSchema } from "./anthropic-account-pool"; import { compactionRecoverySchema } from "./compaction-recovery"; import { nativeReasoningRetentionSchema } from "./native-reasoning-retention"; +import { nativeMainAuthGraceSecondsSchema } from "./native-main-auth-grace"; import { desktopCompatibilitySchema } from "./desktop-compatibility"; import { blockedModelRedirectsSchema } from "./blocked-model-redirects"; import { mainAccountHardLockFallbackSchema } from "./main-account-hard-lock-fallback"; @@ -243,6 +244,8 @@ export const configSchema = z.object({ codexMainAccountHardLockFallback: mainAccountHardLockFallbackSchema.optional().catch(undefined), // #2999: a malformed hand edit falls back to auto detection rather than rejecting the config. nativeMainRefresh: z.enum(["codex", "opencodex"]).optional().catch(undefined), + // Malformed hand edits fall back to the 60-second default; candidate writes reject them. + nativeMainAuthGraceSeconds: nativeMainAuthGraceSecondsSchema.optional().catch(undefined), // Future versions remain opaque through passthrough-compatible whole-config saves. // Only version 1 grants deletion authority in the rebase path. configRebaseProvenance: z.unknown().optional(), diff --git a/src/config/schema/native-main-auth-grace.ts b/src/config/schema/native-main-auth-grace.ts new file mode 100644 index 00000000000..0a02218ae13 --- /dev/null +++ b/src/config/schema/native-main-auth-grace.ts @@ -0,0 +1,19 @@ +import * as z from "zod/v4"; +import type { OcxConfig } from "../../types"; + +export const DEFAULT_NATIVE_MAIN_AUTH_GRACE_SECONDS = 60; +export const nativeMainAuthGraceSecondsSchema = z.number().int().min(1).max(86_400); + +export function nativeMainAuthGraceSecondsConfigError(value: unknown): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const grace = (value as Record).nativeMainAuthGraceSeconds; + return grace === undefined || nativeMainAuthGraceSecondsSchema.safeParse(grace).success + ? null : "schema_invalid: nativeMainAuthGraceSeconds: must be an integer from 1 to 86400, or omitted"; +} + +export function resolveNativeMainAuthGraceMs( + config: Pick | null | undefined, +): number { + const parsed = nativeMainAuthGraceSecondsSchema.safeParse(config?.nativeMainAuthGraceSeconds); + return parsed.success ? parsed.data * 1_000 : DEFAULT_NATIVE_MAIN_AUTH_GRACE_SECONDS * 1_000; +} diff --git a/src/lib/windows-atomic-replace.ts b/src/lib/windows-atomic-replace.ts index 9ac9ab06e61..a77acd33279 100644 --- a/src/lib/windows-atomic-replace.ts +++ b/src/lib/windows-atomic-replace.ts @@ -38,7 +38,11 @@ export type ReplacePublisher = | "tray"; /** Called immediately before every rename attempt, including each Windows retry. */ -export interface RenameValidationHooks { validateBeforeRename?: (destination: string) => void } +export interface RenameValidationHooks { + validateBeforeRename?: (destination: string) => void; + validateBeforeRenameAttempt?: (destination: string, source: string) => void; + afterReplace?: () => void; +} /** The Windows error codes this module treats as a momentary hold. */ export type ReplaceRetryCode = "EBUSY" | "EPERM" | "EACCES"; @@ -149,7 +153,10 @@ export function renameAtomicFile( retryWindowsFileOperation({ run: () => io.rename(source, destination), onRetry: (code, exhausted) => bump(publisher, code, exhausted ? "exhausted" : "retried"), - beforeAttempt: () => hooks.validateBeforeRename?.(destination), + beforeAttempt: () => { + hooks.validateBeforeRename?.(destination); + hooks.validateBeforeRenameAttempt?.(destination, source); + }, }, io); } @@ -161,9 +168,9 @@ export async function renameAtomicFileAsync( ): Promise { for (let attempt = 0; ; attempt += 1) { hooks.validateBeforeRename?.(destination); + hooks.validateBeforeRenameAttempt?.(destination, source); try { renameSync(source, destination); - return; } catch (error) { const code = transientWindowsReplaceCode(process.platform, error); if (!code) throw error; @@ -173,6 +180,9 @@ export async function renameAtomicFileAsync( } bump(publisher, code, "retried"); await Bun.sleep(25 * (attempt + 1)); + continue; } + hooks.afterReplace?.(); + return; } } diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 56557344e91..d7f667b82d4 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -1499,7 +1499,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { addFinalRequestLog(requestId, start, logCtx, status, meta); }; const sessionReq = withCallerSessionIdentity(withGrokSessionIdentity(req), admission); - return runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => { + const admittedResponse = await runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => { let response: Response; try { response = await handleResponses(sessionReq, config, logCtx, { @@ -1533,6 +1533,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { requestId, ); }, { requestId, start, logCtx }); + return admittedResponse; } // Anthropic Messages inbound (Claude Code). count_tokens FIRST (longer path). diff --git a/src/types/config.ts b/src/types/config.ts index 955522cf1fe..42be37b9aba 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -640,6 +640,8 @@ export interface OcxConfig { * is found, otherwise "opencodex". "codex" also falls back when no such binary exists. */ nativeMainRefresh?: "codex" | "opencodex"; + /** Whole seconds to retain the last good native main login while auth.json is not complete; integer 1-86400, default 60. */ + nativeMainAuthGraceSeconds?: number; /** Use included quota to 100% for these accounts; preserves thresholds and credit-spending policy. */ codexUseRemainingQuotaAccountIds?: string[]; /** Explicit top-level deletion intent used by stale whole-config rebases. */ diff --git a/structure/INDEX.md b/structure/INDEX.md index 3677e16c754..04058162622 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -27,6 +27,7 @@ Persisted config, the Codex home it writes into, and the model catalog it publis | [`config.md`](config.md) | Persisted config schema, both injection forms, provider validation, and restore. | | [`codex-provider-routing.md`](codex-provider-routing.md) | Explicit provider-ID routing consent, URL-only publication and recovery. | | [`codex-home.md`](codex-home.md) | CODEX_HOME resolution, the files opencodex manages there, and Codex-home diagnostics. | +| [`native-main-credentials.md`](native-main-credentials.md) | In-memory native-main auth observation, publication, and confirmed deletion. | | [`codex-prompts.md`](codex-prompts.md) | Prompt-layer repair, base import previews and bounded text-probe sources. | | [`codex-cli-update.md`](codex-cli-update.md) | Read-only update planning, isolated npm environment, and complete process observation. | | [`catalog.md`](catalog.md) | Shared Codex catalog assembly, account namespaces, pool rotation, and effort ladders. | @@ -179,7 +180,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/cli/models-runtime.ts` | [`pricing.md`](pricing.md) | | `src/client/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/clients/` | [`clients/integrations.md`](clients/integrations.md)
[`clients/qoder.md`](clients/qoder.md) | -| `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`subagent-capacity.md`](subagent-capacity.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`native-main-credentials.md`](native-main-credentials.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`subagent-capacity.md`](subagent-capacity.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/codex/account-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/codex/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | | `src/codex/app-server-processes.ts` | [`codex-cli-update.md`](codex-cli-update.md) | diff --git a/structure/codex-home.md b/structure/codex-home.md index d541ba24ab5..2d1ab2ad3e4 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -234,14 +234,14 @@ reopened. `ocx status` reports the same drift, also while the loop is gated off, It says nothing in a sibling's home, whose routing names the owner beside it, and it promises only `ocx sync`, because it cannot see whether a healer runs, is gated, or is paused. -Native-main refresh follows the [rotation contract](providers/openai-accounts.md#native-main-refresh-cancellation). +The [native-main credential contract](native-main-credentials.md) owns effective `$CODEX_HOME/auth.json` observation, memory, publication, deletion and the filesystem-import guard. Native-main refresh follows the [rotation contract](providers/openai-accounts.md#native-main-refresh-cancellation). Same-identity device reauth (#3898) instead obtains a new login grant through `/api/codex-auth/main/reauth-device` (start/status/cancel) plus `ocx account main reauth`. It uses the OpenAI deviceauth grant already used for pool accounts, but nothing routes through the pool login surface — `/api/codex-auth/login` keeps rejecting `__main__` — and the commit is a sibling of the refresh write: same-identity check against the snapshot captured at start, owner-independent exclusive claim (a headless hub runs no -owner lifecycle), path/hash/inode re-verification inside the claim, and one +owner lifecycle), file-identity/digest/observation-generation re-verification inside the claim, and one atomic write of access/refresh/id token + account_id. The old identity token is never retained beside the new grant. No claim is held while the human completes the device page, and no DTO, log, or error carries tokens, emails, diff --git a/structure/config.md b/structure/config.md index 4fd3bbe397d..c28e4b25d3f 100644 --- a/structure/config.md +++ b/structure/config.md @@ -71,9 +71,9 @@ owner-only state and are validated before a record is returned. `src/config.ts` symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. Replacing config and process-state writes use `src/config/atomic-write.ts`. The leaf preserves the shared -process-wide temp sequence, optional synchronous caller destination validation before every rename attempt, symlink target resolution, no-follow directory-entry replacement for +process-wide temp sequence, optional synchronous caller destination validation before every rename attempt, plus asynchronous before-rename and final-validation hooks immediately before rename, symlink target resolution, no-follow directory-entry replacement for externally writable integration directories, real-home test guard, owner manifest, Windows ACL hardening, -scrub-before-unlink failure path, and explicit residual-temp errors. A caller must not replace it with a local temp-and-rename shortcut. Publication failures in +scrub-before-unlink failure path, explicit residual-temp errors, and an after-rename target identity hook that native-main auth uses for immutable publication receipts. Native-main captures the temporary file's device, inode, size, mode and mtime at final validation before rename, then checks that evidence against the renamed target before issuing its receipt. The captured ctime is not compared because rename may change it. A caller must not replace it with a local temp-and-rename shortcut. Publication failures in `src/config/persist-unlocked.ts` and `src/config/live-reconcile.ts` follow the [publication-aware rollback contract](gui-and-management-api.md#durable-provider-patch). `src/config/live-reconcile.ts` adopts committed model discovery and disabled selectors together with their scoped live merge baselines. A later manual enable therefore removes the automatic disable instead of having a stale three-way merge restore it. Unrelated live config baselines are not advanced by that adoption. Owner-registry publication first refuses a directory symlink, applies POSIX mode `0700`, and requires `hardenSecretDir` for the locator directory. On Windows the default secret-directory ACL grants the current owner full control before removing inherited and broad-user access, without elevated-stage reader grants. A hardening failure skips this best-effort publication before any pointer is written. @@ -105,7 +105,7 @@ A schema-invalid top-level JSON value is repairable only when it is a non-array | Transport | stream mode, timeouts, proxy settings, `websockets`, `emptyCompletionRetry` | `streamMode` persists in config.json; Windows services need a persisted input, and macOS uses it for explicit eager-relay opt-in. Empty-completion replay is an explicit top-level opt-in because its second upstream request may be billable. | | Canonical ChatGPT upstream transport and Lite history | `providers.openai.upstreamWebsocket`, per-provider `projectResponsesLiteHostedSearchHistory` | Omitted `upstreamWebsocket` uses upstream WebSocket when eligible; explicit `false` selects HTTP/SSE without changing the canonical provider identity. `true` is rejected on the canonical row. This is independent of the client-facing `websockets` setting. `src/config/schema/leaf-validators.ts` validates `projectResponsesLiteHostedSearchHistory` as an optional strict boolean, default off; only runtime `=== true` enables the ordinary lossy policy, still limited by the exact destination and explicit Lite [hosted-search history boundary](providers-and-adapters.md#hosted-search-continuation-binding). `src/providers/model-rename-fields.ts` classifies it as provider-wide policy with no model-ID rewrite role. | | Provider egress | `providers..proxy`, `providers..noProxy` | An absent `proxy` inherits global egress; `"direct"` or `null` forces direct egress; HTTP(S) and SOCKS5(H) URLs select a provider-owned proxy. `noProxy` uses NO_PROXY syntax and sends a matching destination direct across either a provider-owned or inherited global proxy. `src/lib/provider-egress.ts` owns parsing and request-local resolution. `forwardClientHeaders` opts into `originator`, `x-client-request-id`, `x-codex-app-version`, or `user-agent` on Responses inference only; provider headers win and all other names are rejected on load/write and filtered at runtime. | -| Credentials | `apiKeys` | Data-plane only; never admitted to `/api/*`. | +| Credentials | `apiKeys`, `nativeMainAuthGraceSeconds` | `apiKeys`: data-plane only; never admitted to `/api/*`. `nativeMainAuthGraceSeconds` (integer 1-86400, default 60): how long the last good native main login stays in memory while `auth.json` is not complete; see [`native-main-credentials.md`](native-main-credentials.md). | | Lifecycle | `codexAutoStart`, shim/start behavior, resume-history sync, storage cleanup | Startup safety reads these; see [`gui-and-management-api.md`](gui-and-management-api.md). | Env values are resolved through `src/config/proxy-env.ts`, so a config value naming an env var never persists the secret itself. diff --git a/structure/manifest.json b/structure/manifest.json index ec894791ee1..d983c238e2f 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -142,6 +142,15 @@ "src/codex/" ] }, + { + "path": "native-main-credentials.md", + "tier": 2, + "title": "Native Main Credentials", + "scope": "In-memory native-main auth observation, publication, and confirmed deletion.", + "documents": [ + "src/codex/" + ] + }, { "path": "codex-prompts.md", "tier": 2, diff --git a/structure/native-main-credentials.md b/structure/native-main-credentials.md new file mode 100644 index 00000000000..d688c696301 --- /dev/null +++ b/structure/native-main-credentials.md @@ -0,0 +1,97 @@ +# Native Main Credentials + +`src/codex/native-main-credentials.ts` owns content reads and writes for effective +`$CODEX_HOME/auth.json`. Request, account, lifecycle, profile, and Orca-import checks use its +in-memory snapshot. The home resolver and doctor may check whether the path exists; staged profile +files and explicitly managed Orca homes remain separate inputs. + +Ordinary credential reads follow a symlinked `auth.json` to preserve Codex-home compatibility. The +snapshot keeps that link provenance. Bounded token reads, native-profile reads and writability +checks, and Orca main-identity reads reject a symlink. Orca asks the store for a current observation +and proceeds only with a valid current file or no main login at all (status `missing`); stopped, +invalid, and unreadable states are refused. A login retained through the deletion grace (status `ok`, +file `missing`) is refused too. + +Credential reads use a descriptor opened with no-follow where the platform supports it. The reader +starts with the observed file size, grows its buffer only as needed up to the 4 MiB limit plus one +byte, and checks the descriptor size again after EOF. Oversized or concurrently growing files are +refused; only allocated scratch buffers are cleared. + +The store watches the CODEX_HOME directory and runs a 250 ms stat check. A changed device, inode, +size, mode, mtime, or ctime triggers a bounded read and hash. A one-second fixed cadence also forces +that read and hash when the stat signature is unchanged, so dropped events and same-size rewrites +converge. Directory events, the timer, pre-refresh checks, and pre-publication checks all produce +one immutable observation record through the same classifier. One transition applies it to memory, +deletion grace, hiccup recovery, history, and generation. Watcher errors cause registration to be +retried by the timer, and shutdown closes the watcher and clears both timers. + +One classifier decides whether observed content is complete enough to replace memory. A ChatGPT +login needs non-empty access and refresh tokens; an API-key login needs a non-empty key. The profile +parser separately requires its ID-token and account-identity fields to encrypt a profile. Those +fields do not arbitrate between complete logins: every complete external change wins without +expiry, account, or other precedence checks. Missing, empty, partial, malformed, or unreadable +files leave the last good snapshot available, retain any rotated refresh grant, and pause refresh +and every OpenCodex write for a grace period: `nativeMainAuthGraceSeconds`, default 60 seconds. +The period starts at the first observation that is not a complete file. Only a complete file ends +it; a read error or another incomplete observation does not restart it. A complete file replaces +memory as soon as it is observed, at any time. When the period ends without a complete file, the +cache, the pending refresh, and its retry timer are cleared so an old grant cannot recreate it. + +A read failure with a present leaf is an unknown hiccup, so it retains memory and a rotated refresh +grant while blocking writes. Retry authority retains the original complete physical signature, +including mtime and ctime; later recovery from an unknown observation does not excuse a subsequent +observed rewrite. If bounded history no longer proves whether a complete change intervened, the +grant remains in memory and recovery is required rather than adopting the old disk credential. + +Refresh publication runs immediately through the shared temp-file/rename writer; there is no +debounce. Final validation captures receipt authority before rename: the observation sequence and +the temp file's identity. Immediately after rename returns, the sync writer captures publication +evidence synchronously from the retained descriptor. The async writer invokes that capture +synchronously from the rename helper before returning to its promise continuation. Evidence takes +a descriptor `fstat`, hashes bytes read through that descriptor, then takes another `fstat`; it +includes device, inode, size, mode, mtime, and ctime. A receipt is issued only when the descriptor +stays stable, its digest matches the candidate, its identity matches the staged file, and no +unowned observation intervened. Later path observations count as the publication only when their +complete physical signature matches that descriptor evidence. A rewrite after evidence capture is +refused even when it restores mtime and changes only ctime. Any intervening observation, even one +that later sees the old bytes restored, refuses continuation and rollback; the current credential +and encrypted journal remain available for recovery. The receipt binds the accepted target's full +physical signature. +Retries keep their original disk fence when later refresh results are merged into a retained grant. +If an unreadable or invalid observation interrupts a retained refresh grant, retry may continue only +when observation history proves that every intervening observation was a hiccup and the same +complete base identity and digest returned. A complete different observation or truncated history +blocks retry and retains the grant for recovery. Invalid, unreadable, and grace-missing states block +writes including rollback and recovery; profile recovery keeps the encrypted journal until a valid +physical state is observed. Device reauth, profile switch, rollback, and recovery use the same writer. + +Refresh and reauth keep their CODEX_HOME claim, and profile operations retain their outer claim, +journal, boundary hooks, read-back verification, and rollback. A Bun parser-based import guard +rejects every module other than the credential store that combines effective-path authority with a +raw filesystem capability. Static imports, re-exports, dynamic imports, and `require` calls are +parsed; importing `createRequire` from `node:module` is itself treated as acquiring a raw +capability. The generic filesystem facade is removed so it cannot export +path-accepting raw readers, writers, or openers. +Modules that need unrelated filesystem operations import their narrow `node:fs` or +`node:fs/promises` operations directly. The import guard recognizes the profile-store +`readBounded` calls only for `context.vaultPath` and `context.journalPath`, and `privateFileState` +only for `context.journalPath` and `context.recoveryBlockPath`. Doctor may use `existsSync` for the +resolved CODEX_HOME directory; `auth.json` existence goes through the credential store. The +generic `readNativeEnvelope` wrappers count as raw capabilities and are rejected if passed the live +auth path; the profile manager uses them only for its verified owned-stage path. These operation/path +exemptions cannot read or write effective `auth.json`. The credential store is the sole module +allowed to combine live-path authority with raw I/O. + +After optional shutdown hooks run, a late accessor returns the defined `stopped` status and cannot +start a watcher, timer, or retry. + +The grace is a sanity check, not a delay: it keeps the middle states of a Codex login (a missing, +empty, or half-written `auth.json`) from breaking requests that already use the last good login. +A new login or an account switch is never delayed, because a complete file replaces memory as +soon as it is observed. A logout takes effect when the period ends. + +### Accepted remaining risk + +> A Codex write that lands within the same split second as OpenCodex's rename can still be lost. + +Closing this window needs a lock that Codex also honors, which requires an upstream Codex change: [openai/codex#40267](https://github.com/openai/codex/issues/40267#issuecomment-6152924173). diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 809727ad810..49105ba8bf9 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -291,15 +291,15 @@ only the refresh's own 30-second timeout: success rotates and retires the old refresh token, so cancellation cannot discard the only live grant. Both locks await the callback through completion, including publication; aborting their wait signal does not release an acquired lock while the exchange runs. -Successful refresh publishes access and refresh tokens and advances the -credential mutation epoch before clearing grant rejection and applicable -reauth quarantine, then throws the caller's abort reason if cancelled. -Snapshot and identity checks still refuse to overwrite an external auth writer, -including when the caller also cancelled. A save refused after a successful -exchange serves the new access token to that request only: auth.json stays as -the other writer left it, the rotated grant is kept nowhere, and the old grant -is not marked rejected (#2999). Same-identity device reauth obtains a new login -grant rather than rotating the existing grant and keeps its pre-publication fence. +The native-main store watches CODEX_HOME/auth.json, is the sole OpenCodex writer, and serves readers its last +complete in-memory snapshot. Refresh and publication share one fence (file identity, digest, observation generation) +that holds through caller cancellation. Invalid, unreadable, or missing content keeps the last complete credential +and blocks refresh and writes; a missing file is a logout only after ten minutes. A hiccup keeps the rotated grant in +memory. Results: `written` and `retained` keep the rotated grant in file or memory and return its access token; +`recovery-required` and `superseded` return the in-flight access token while memory follows the file; `deleted` and +`stopped` return no token. Neither `recovery-required` nor `superseded` marks the old grant rejected (#2999). Written +and retained results advance the credential mutation epoch before clearing grant rejection and reauth quarantine, +then surface caller cancellation. Same-identity device reauth obtains a new login grant through the central writer. ## Native-main refresh ownership @@ -331,7 +331,7 @@ share the operation; equal emails in different workspaces and different members remain independent. Missing identity evidence never links entries. Main's ID and access tokens must agree on both workspace and member email; a disagreement returns 503. Main discovery, group publication and config persistence hold native-main admission and, when the home exists, the cross-process -shared claim. Main reads use the claim's pinned auth path and bounded regular-file reader. A positively +shared claim. Main reads use the claim's pinned auth path and native-main memory snapshot. A positively absent home or valid API-key-only envelope has no ChatGPT main identity: Pool-only grouping proceeds, and main remains independently addressable by id. An inaccessible existing home, busy claim, malformed credentials, or unreadable/nonregular/oversized auth file returns 503 before any group publication. diff --git a/structure/runtime.md b/structure/runtime.md index fd71a66ad1b..7fc33e5649b 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -114,7 +114,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/server/audio-live.ts`, `src/server/audio-dictation.ts` | External voice/dictation orchestration using the existing bounded socket relay, server-owned credentials, cancellation and opaque call ownership. See [streaming audio](data-planes/inbound-compat.md#streaming-audio). | | `src/config.ts`, `src/config/persisted-mutation.ts` | Persisted `~/.opencodex/config.json` surface: the facade keeps load/save/initialize entry points and re-exports the schema-valid mutation callback, types, and one-shot test seam; the leaf owns its bounded rebase under the shared lock. Schema lives in `src/config/schema/` (`config-schema.ts`, `leaf-validators.ts`), defaults in `src/config/proxy-env.ts`, and replace-path persistence in `src/config/persist-unlocked.ts`. | | `src/config/paths.ts` | Resolves `OPENCODEX_HOME`, `config.json`, and owner-only directory hardening. | -| `src/config/atomic-write.ts` | Shared synchronous/asynchronous temp-harden-rename writer and residual-temp failure contract. Synchronous caller validation runs immediately before each rename attempt, including Windows retries. The temp is ACL-hardened before it holds a byte and again before the rename, both `required: true`; the second call is a memo hit rather than a second icacls sequence because the writer re-asserts descriptor/path identity after the content write and re-attributes the harden through `reattributeHardenedSecretPath`. Windows takes no `chmod` on that path — it sets the read-only attribute, not the DACL, and its ChangeTime bump is what used to retire the memo. | +| `src/config/atomic-write.ts` | Shared synchronous/asynchronous temp-harden-rename writer and residual-temp failure contract. Synchronous caller validation runs immediately before each rename attempt, including Windows retries; asynchronous callers can run before-rename and final-validation hooks immediately before rename. The temp is ACL-hardened before it holds a byte and again before the rename, both `required: true`; the second call is a memo hit rather than a second icacls sequence because the writer re-asserts descriptor/path identity after the content write and re-attributes the harden through `reattributeHardenedSecretPath`. Windows takes no `chmod` on that path — it sets the read-only attribute, not the DACL, and its ChangeTime bump is what used to retire the memo. | | `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. A transient Windows WMIC/PowerShell command-line probe failure gets one bounded retry before a PID reads as unverified. | | `src/config/admitted-identity.ts` | Which configuration a derived artifact was built from. Detaches the resident configuration as plain data so one pass cannot gather under one state and project under another, and records the complete structure beside the configuration file's bytes. Refuses an accessor, a cycle, a value JSON could not produce, an unreadable file and a file the loader would have had to salvage; a callable `providers[name].fetch` is the one non-data field, held and compared by reference, while a written one is ordinary data on both sides, as the outbound transport also reads it. It does NOT require the resident configuration to equal the file: the proxy routes by what it holds, and live reconciliation retains live changes and the active listener binding on purpose. Evidence stays in a module WeakMap, never on the config and never in a response. | | `src/server/ports.ts` | Owns bind availability and ephemeral-port selection. Temporary probes dispose accepted peers and wait for listener close before reporting success. | diff --git a/tests/clients/desktop-compatibility-native-identity.test.ts b/tests/clients/desktop-compatibility-native-identity.test.ts index aecdc2b2da9..f7481bc66de 100644 --- a/tests/clients/desktop-compatibility-native-identity.test.ts +++ b/tests/clients/desktop-compatibility-native-identity.test.ts @@ -12,7 +12,7 @@ function fixture() { const path = join(root, "auth.json"); let revision = 0; const write = (id = "fixture-A", access = "synthetic-access-A") => { const claims = { "https://api.openai.com/auth": { chatgpt_account_id: id, chatgpt_user_id: "fixture-user", chatgpt_plan_type: "pro" } }; - writeFileSync(path, JSON.stringify({ auth_mode: "chatgpt", tokens: { account_id: id, access_token: access, + writeFileSync(path, JSON.stringify({ auth_mode: "chatgpt", tokens: { account_id: id, access_token: access, refresh_token: "synthetic-refresh", id_token: `fixture.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.fixture` } })); const timestamp = new Date(Date.now() + ++revision * 2000); utimesSync(path, timestamp, timestamp); }; diff --git a/tests/codex-integration/bearer-admission-routed-provider.test.ts b/tests/codex-integration/bearer-admission-routed-provider.test.ts index 21d036a0544..0983b5e9d1f 100644 --- a/tests/codex-integration/bearer-admission-routed-provider.test.ts +++ b/tests/codex-integration/bearer-admission-routed-provider.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; import { clearComboTargetCooldowns } from "../../src/combos/failover"; +import { spendLedgerOwnerSnapshot } from "../../src/lib/spend-ledger-owner"; import { startServer } from "../../src/server"; import { noteSubagentModelFailure, resetSubagentModelFallbackStateForTests } from "../../src/codex/subagent-model-fallback"; import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; @@ -52,6 +53,9 @@ let codexHome = ""; let routedAuth: Array = []; let nativeAuth: Array = []; let nativeAccountIds: Array = []; +const caseServers = new Set>(); +const caseServerStops = new WeakMap, Promise>(); +const caseServerCleanups: Array<() => Promise> = []; const ADMISSION_SECRET = "ocx_data_2132secret"; const ROUTED_KEY = "sk-routed-provider-key"; @@ -81,6 +85,7 @@ function mixedConfig(): OcxConfig { authMode: "forward", codexAccountMode: "direct", defaultModel: "gpt-5.5", + upstreamWebsocket: false, }, gateway: { adapter: "openai-chat", @@ -125,7 +130,13 @@ async function withCursorCaptureServer( ): Promise { const capturedAuth: Array = []; const sessions = new Set(); + let stopping: Promise | undefined; const server = http2.createServer(); + const stop = (): Promise => stopping ??= (async () => { + for (const session of sessions) session.destroy(); + await new Promise(resolve => server.close(() => resolve())); + })(); + trackCaseServerCleanup(stop); server.on("session", session => { sessions.add(session); session.once("close", () => sessions.delete(session)); @@ -157,12 +168,12 @@ async function withCursorCaptureServer( try { return await run(`http://127.0.0.1:${address.port}`, capturedAuth); } finally { - for (const session of sessions) session.destroy(); - await new Promise(resolve => server.close(() => resolve())); + await stop(); } } beforeEach(() => { + expect(spendLedgerOwnerSnapshot().ownership).toBe("unheld"); resetVisionDescriptionCache(); clearComboTargetCooldowns(); resetSubagentModelFallbackStateForTests(); @@ -198,7 +209,41 @@ beforeEach(() => { }) as typeof fetch; }); +function startTrackedServer(...args: Parameters): ReturnType { + const server = startServer(...args); + caseServers.add(server); + return server; +} + +function stopTrackedServer(server: ReturnType): Promise { + const existing = caseServerStops.get(server); + if (existing) return existing; + const stopping = Promise.resolve().then(() => server.stop(true)); + caseServerStops.set(server, stopping); + return stopping; +} + +function trackCaseServerCleanup(cleanup: () => Promise): void { + caseServerCleanups.push(cleanup); +} + afterEach(async () => { + let stopError: unknown; + for (const server of caseServers) { + try { + await stopTrackedServer(server); + } catch (error) { + stopError ??= error; + } + } + caseServers.clear(); + for (const cleanup of caseServerCleanups.splice(0).reverse()) { + try { + await cleanup(); + } catch (error) { + stopError ??= error; + } + } resetVisionDescriptionCache(); closeRequestHistoryIndex(); clearComboTargetCooldowns(); @@ -212,11 +257,13 @@ afterEach(async () => { else process.env.CODEX_HOME = previousCodexHome; if (previousDataToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; else process.env.OPENCODEX_API_AUTH_TOKEN = previousDataToken; + expect(spendLedgerOwnerSnapshot().ownership).toBe("unheld"); // Startup's config-dir ACL hardening may still hold the homes on Windows; settle it first. if (ocxHome) await removeTreeAfterBackgroundWork(ocxHome); if (codexHome) await removeTreeAfterBackgroundWork(codexHome); ocxHome = ""; codexHome = ""; + if (stopError) throw stopError; }); async function postResponses( @@ -268,7 +315,7 @@ async function postClaudeMessages( } async function startOwnedServer(): Promise> { - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); await waitForNativeMainStartupGate(); return server; } @@ -279,7 +326,7 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route // The reported install: no ChatGPT login was ever performed. writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); try { const response = await postResponses(server.url, "gateway/gateway-model"); @@ -291,7 +338,7 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route expect(routedAuth.join("|")).not.toContain(ADMISSION_SECRET); expect(nativeAuth).toHaveLength(0); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); @@ -299,7 +346,7 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route saveConfig(mixedConfig()); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); try { await waitForNativeMainStartupGate(); const response = await postResponses(server.url, "gpt-5.5"); @@ -310,7 +357,7 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route expect(nativeAuth).toHaveLength(0); expect(routedAuth).toHaveLength(0); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); @@ -319,10 +366,10 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route const stored = liveJwt(); writeFileSync( join(codexHome, "auth.json"), - JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + JSON.stringify({ tokens: { access_token: stored, refresh_token: "stored_main_refresh", account_id: "stored_main_acc" } }), ); - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); try { await waitForNativeMainStartupGate(); const response = await postResponses(server.url, "gpt-5.5"); @@ -331,7 +378,7 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route expect(nativeAuth).toEqual([`Bearer ${stored}`]); expect(nativeAuth.join("|")).not.toContain(ADMISSION_SECRET); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -342,14 +389,14 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => saveConfig(cursorForwardConfig(baseUrl)); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); try { const response = await postResponses(server.url, "cursorcustom/auto"); // The runTurn adapter reports its pre-dispatch failure in a Responses terminal. expect(await response.json()).toMatchObject({ status: "failed" }); expect(capturedAuth).toEqual([]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -359,7 +406,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => saveConfig(cursorForwardConfig(baseUrl)); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); try { await postResponses(server.url, "cursorcustom/auto", { "x-opencodex-api-key": ADMISSION_SECRET, @@ -367,7 +414,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => }); expect(capturedAuth).toEqual(["Bearer cursor-upstream-token"]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -377,12 +424,12 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => saveConfig(cursorForwardConfig(baseUrl, "cursor-configured-token")); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); try { await postResponses(server.url, "cursorcustom/auto"); expect(capturedAuth).toEqual(["Bearer cursor-configured-token"]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -392,7 +439,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => saveConfig(cursorForwardConfig(baseUrl)); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); try { const response = await postResponses(server.url, "cursorcustom/auto", { "x-opencodex-api-key": ADMISSION_SECRET, @@ -401,7 +448,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(response.status).toBe(401); expect(capturedAuth).toEqual([]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -411,7 +458,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => saveConfig(cursorForwardConfig(baseUrl, "cursor-configured-token")); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); try { await postResponses(server.url, "cursorcustom/auto", { "x-opencodex-api-key": ADMISSION_SECRET, @@ -419,7 +466,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => }); expect(capturedAuth).toEqual(["Bearer cursor-configured-token"]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -428,7 +475,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => await withCursorCaptureServer(async (baseUrl, capturedAuth) => { saveConfig(cursorForwardConfig(baseUrl)); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + tokens: { access_token: liveJwt(), refresh_token: "stored_main_refresh", account_id: "stored_main_acc" }, })); const server = await startOwnedServer(); @@ -439,7 +486,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => }); expect(capturedAuth).toEqual(["Bearer cursor-upstream-token"]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -460,6 +507,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => config.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", + upstreamWebsocket: false, }; config.visionSidecar = { enabled: true, backend: "openai", model: "gpt-5.6-luna" }; if (scenario === "default-sidecars") delete config.visionSidecar; @@ -477,7 +525,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => if (scenario === "noncanonical-openai") { // Keep the reserved OpenAI row absent; a custom forward provider is valid config // but is not authority to inject stored ChatGPT credentials into a sidecar. - config.providers.mirror = { adapter: "openai-responses", authMode: "forward", baseUrl: "https://mirror.example.com/v1" }; + config.providers.mirror = { adapter: "openai-responses", authMode: "forward", baseUrl: "https://mirror.example.com/v1", upstreamWebsocket: false,}; delete config.providers.openai; } const search = scenario.startsWith("search-"); @@ -486,7 +534,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => saveConfig(config); const stored = fakeChatGptJwt({ chatgpt_account_id: "stored_main_acc", exp: Math.floor(Date.now() / 1000) + 3600 }); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: scenario === "no-stored-main" ? {} : { access_token: stored, account_id: "stored_main_acc" }, + tokens: scenario === "no-stored-main" ? {} : { access_token: stored, refresh_token: "stored_main_refresh", account_id: "stored_main_acc" }, })); const server = await startOwnedServer(); let settled = false; @@ -523,7 +571,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(nativeAuth).toEqual([]); } finally { releaseUpstream(); - try { await (await pending).text(); } finally { await server.stop(true); } + try { await (await pending).text(); } finally { await stopTrackedServer(server); } } expect(getNativeMainProfileRequestCount()).toBe(0); }, async () => { signalStarted(); await upstreamGate; }); @@ -541,12 +589,13 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => config.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", + upstreamWebsocket: false, }; config.webSearchSidecar = { enabled: true, backend: "openai", model: "gpt-5.6-luna" }; saveConfig(config); const stored = fakeChatGptJwt({ chatgpt_account_id: "stored_main_acc", exp: Math.floor(Date.now() / 1000) + 3600 }); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: stored, account_id: "stored_main_acc" }, + tokens: { access_token: stored, refresh_token: "stored_main_refresh", account_id: "stored_main_acc" }, })); const server = await startOwnedServer(); let settled = false; @@ -575,7 +624,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(nativeAuth).toEqual([]); } finally { releaseUpstream(); - try { await (await pending).text(); } finally { await server.stop(true); } + try { await (await pending).text(); } finally { await stopTrackedServer(server); } } expect(getNativeMainProfileRequestCount()).toBe(0); expect(capturedAuth).toEqual(["Bearer cursor-upstream-token"]); @@ -590,6 +639,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => config.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: pool ? "pool" : "direct", + upstreamWebsocket: false, }; // Keep this auth fixture independent of the legacy sidecar model migration. config.visionSidecar = { enabled: true, backend: "openai", model: "gpt-5.6-luna" }; @@ -605,7 +655,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => saveConfig(config); const stored = fakeChatGptJwt({ chatgpt_account_id: "stored_main_acc", exp: Math.floor(Date.now() / 1000) + 3600 }); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: stored, account_id: "stored_main_acc" }, + tokens: { access_token: stored, refresh_token: "stored_main_refresh", account_id: "stored_main_acc" }, })); const sidecar: Array<{ authorization: string | null; account: string | null; claimed: boolean }> = []; globalThis.fetch = (async (input, init) => { @@ -628,7 +678,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => return originalFetch(input, init); }) as typeof fetch; const fenced = ownership.endsWith("fenced"); - const server = !fenced ? await startOwnedServer() : startServer(0, { + const server = !fenced ? await startOwnedServer() : startTrackedServer(0, { inspectNativeCodexOwnership: () => ({ ownership: "foreign", reason: "fixture owned by another service" }), }); try { @@ -650,7 +700,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => account: pool ? "sidecar_pool_acc" : "stored_main_acc", claimed: !pool }]); expect(capturedAuth).toEqual(["Bearer cursor-upstream-token"]); } finally { - await server.stop(true); + await stopTrackedServer(server); } expect(getNativeMainProfileRequestCount()).toBe(0); }); @@ -660,7 +710,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => await withCursorCaptureServer(async (baseUrl, capturedAuth) => { saveConfig(cursorForwardConfig(baseUrl)); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + tokens: { access_token: liveJwt(), refresh_token: "stored_main_refresh", account_id: "stored_main_acc" }, })); const server = await startOwnedServer(); @@ -671,7 +721,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(response.status).not.toBe(200); expect(capturedAuth).toEqual([]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -680,7 +730,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => await withCursorCaptureServer(async (baseUrl, capturedAuth) => { saveConfig(cursorForwardConfig(baseUrl)); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + tokens: { access_token: liveJwt(), refresh_token: "stored_main_refresh", account_id: "stored_main_acc" }, })); const server = await startOwnedServer(); @@ -691,7 +741,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(response.status).not.toBe(200); expect(capturedAuth).toEqual([]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -718,7 +768,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => } expect(capturedAuth).toEqual([]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -764,7 +814,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => } expect(capturedAuth).toEqual([]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); } @@ -794,7 +844,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => await response.text(); expect(capturedAuth).toEqual([`Bearer ${bearer}`]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); } @@ -808,7 +858,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => }; saveConfig(config); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + tokens: { access_token: liveJwt(), refresh_token: "stored_main_refresh", account_id: "stored_main_acc" }, })); const server = await startOwnedServer(); @@ -821,7 +871,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(response.status).not.toBe(200); expect(capturedAuth).toEqual([]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -835,7 +885,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => saveConfig(config); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); try { const response = await postResponses(server.url, "combo/free", { "x-opencodex-api-key": ADMISSION_SECRET, @@ -845,7 +895,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(response.status).not.toBe(200); expect(capturedAuth).toEqual([]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -882,7 +932,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => saveConfig(config); writeFileSync( join(codexHome, "auth.json"), - JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + JSON.stringify({ tokens: { access_token: stored, refresh_token: "stored_main_refresh", account_id: "stored_main_acc" } }), ); const server = await startOwnedServer(); @@ -892,7 +942,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(nativeAuth).toEqual([`Bearer ${stored}`]); expect(nativeAuth.join("|")).not.toContain(ADMISSION_SECRET); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); @@ -930,7 +980,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(nativeAccountIds).toEqual([]); } } finally { - await server.stop(true); + await stopTrackedServer(server); } }, ); @@ -944,6 +994,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => authMode: "forward", codexAccountMode: "direct", defaultModel: "gpt-5.6-luna", + upstreamWebsocket: false, }; config.subagentModelFallback = ["gpt-5.6-luna"]; const stored = liveJwt(); @@ -951,7 +1002,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => noteSubagentModelFailure("cursorcustom/auto", "429", config); writeFileSync( join(codexHome, "auth.json"), - JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + JSON.stringify({ tokens: { access_token: stored, refresh_token: "stored_main_refresh", account_id: "stored_main_acc" } }), ); const server = await startOwnedServer(); @@ -965,7 +1016,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(nativeAuth).toEqual([]); expect(response.status).toBe(401); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -979,6 +1030,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => authMode: "forward", codexAccountMode: "direct", defaultModel: "gpt-5.6-luna", + upstreamWebsocket: false, }; config.subagentModelFallback = ["gpt-5.6-luna"]; const stored = liveJwt(); @@ -986,7 +1038,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => noteSubagentModelFailure("cursorcustom/auto", "429", config); writeFileSync( join(codexHome, "auth.json"), - JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + JSON.stringify({ tokens: { access_token: stored, refresh_token: "stored_main_refresh", account_id: "stored_main_acc" } }), ); const server = await startOwnedServer(); @@ -1000,7 +1052,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(nativeAuth).toEqual([]); expect(response.status).toBe(401); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -1020,7 +1072,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(nativeAuth).toEqual(["Bearer caller-native-token"]); expect(routedAuth).toEqual([]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); @@ -1034,7 +1086,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => saveConfig(config); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); try { const authHeaders = { "x-opencodex-api-key": ADMISSION_SECRET, @@ -1050,7 +1102,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => // failure inside its normal response envelope. The wire observation is authoritative. expect([200, 502]).toContain(status); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -1065,7 +1117,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => saveConfig(config); writeFileSync( join(codexHome, "auth.json"), - JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + JSON.stringify({ tokens: { access_token: stored, refresh_token: "stored_main_refresh", account_id: "stored_main_acc" } }), ); const server = await startOwnedServer(); @@ -1074,7 +1126,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(response.status).toBe(200); expect(nativeAuth).toEqual([`Bearer ${stored}`]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); @@ -1087,7 +1139,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => saveConfig(config); writeFileSync( join(codexHome, "auth.json"), - JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + JSON.stringify({ tokens: { access_token: stored, refresh_token: "stored_main_refresh", account_id: "stored_main_acc" } }), ); const server = await startOwnedServer(); @@ -1096,7 +1148,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(response.status).toBe(200); expect(nativeAuth).toEqual([`Bearer ${stored}`]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); @@ -1108,10 +1160,10 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => saveConfig(config); writeFileSync( join(codexHome, "auth.json"), - JSON.stringify({ tokens: { access_token: liveJwt(), account_id: "stored_main_acc" } }), + JSON.stringify({ tokens: { access_token: liveJwt(), refresh_token: "stored_main_refresh", account_id: "stored_main_acc" } }), ); - const server = startServer(0, { + const server = startTrackedServer(0, { inspectNativeCodexOwnership: () => ({ ownership: "foreign", reason: "fixture-owned by another service" }), }); try { @@ -1123,7 +1175,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(response.status).toBe(401); expect(nativeAuth).toEqual([]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); @@ -1137,6 +1189,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => authMode: "forward", codexAccountMode: "direct", defaultModel: "gpt-5.6-luna", + upstreamWebsocket: false, }; config.shadowCallIntercept = { enabled: true, @@ -1146,7 +1199,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => saveConfig(config); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); try { const authHeaders = { "x-opencodex-api-key": ADMISSION_SECRET, @@ -1160,7 +1213,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(capturedAuth).toEqual([]); expect([200, 401, 502]).toContain(status); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -1170,7 +1223,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => await withCursorCaptureServer(async (baseUrl, capturedAuth) => { saveConfig(cursorForwardConfig(baseUrl)); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, + tokens: { access_token: liveJwt(), refresh_token: "stored_main_refresh", account_id: "stored_main_acc" }, })); const server = await startOwnedServer(); @@ -1179,7 +1232,7 @@ describe("bearer admission is not reused as a Cursor upstream credential", () => expect(response.status).toBe(502); expect(capturedAuth).toEqual([]); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); }); @@ -1213,6 +1266,7 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", defaultModel: "gpt-5.5", + upstreamWebsocket: false, }, }, } as OcxConfig; @@ -1222,7 +1276,7 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate saveConfig(customNamedCanonicalConfig()); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); try { await waitForNativeMainStartupGate(); const response = await postResponses(server.url, "mirror/gpt-5.5"); @@ -1233,7 +1287,7 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate expect(nativeAuth.join("|")).not.toContain(ADMISSION_SECRET); expect(response.status).not.toBe(200); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); @@ -1242,10 +1296,10 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate const stored = liveJwt(); writeFileSync( join(codexHome, "auth.json"), - JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + JSON.stringify({ tokens: { access_token: stored, refresh_token: "stored_main_refresh", account_id: "stored_main_acc" } }), ); - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); try { await waitForNativeMainStartupGate(); await postResponses(server.url, "mirror/gpt-5.5"); @@ -1253,7 +1307,7 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate expect(nativeAuth.join("|")).not.toContain(ADMISSION_SECRET); for (const sent of nativeAuth) expect(sent).toBe(`Bearer ${stored}`); } finally { - await server.stop(true); + await stopTrackedServer(server); } }); @@ -1262,10 +1316,10 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate const stored = liveJwt(); writeFileSync( join(codexHome, "auth.json"), - JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + JSON.stringify({ tokens: { access_token: stored, refresh_token: "stored_main_refresh", account_id: "stored_main_acc" } }), ); - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); let drain: ReturnType = null; try { await waitForNativeMainStartupGate(); @@ -1279,7 +1333,7 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate expect(routedAuth).toHaveLength(0); } finally { drain?.release(); - await server.stop(true); + await stopTrackedServer(server); } }); @@ -1288,7 +1342,7 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate const stored = liveJwt(); writeFileSync( join(codexHome, "auth.json"), - JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + JSON.stringify({ tokens: { access_token: stored, refresh_token: "stored_main_refresh", account_id: "stored_main_acc" } }), ); let signalUpstreamStarted!: () => void; const upstreamStarted = new Promise((resolve) => { signalUpstreamStarted = resolve; }); @@ -1306,7 +1360,7 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate return originalFetch(input, init); }) as typeof fetch; - const server = startServer(0, { inspectNativeCodexOwnership }); + const server = startTrackedServer(0, { inspectNativeCodexOwnership }); let pending: Promise | null = null; try { await waitForNativeMainStartupGate(); @@ -1350,7 +1404,7 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate } finally { releaseUpstream(); await pending?.catch(() => {}); - await server.stop(true); + await stopTrackedServer(server); } }); }); diff --git a/tests/codex-integration/codex-account-selection-atomicity.test.ts b/tests/codex-integration/codex-account-selection-atomicity.test.ts index 4867965aab1..568eae561ef 100644 --- a/tests/codex-integration/codex-account-selection-atomicity.test.ts +++ b/tests/codex-integration/codex-account-selection-atomicity.test.ts @@ -66,7 +66,7 @@ beforeEach(() => { mkdirSync(process.env.CODEX_HOME); clearState(); writeFileSync(join(process.env.CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: credential(MAIN).accessToken, account_id: credential(MAIN).chatgptAccountId }, + tokens: { access_token: credential(MAIN).accessToken, refresh_token: credential(MAIN).refreshToken, account_id: credential(MAIN).chatgptAccountId }, })); writeFileSync(join(process.env.OPENCODEX_HOME, "codex-accounts.json"), JSON.stringify({ [ACCOUNT_A]: credential(ACCOUNT_A), [ACCOUNT_B]: credential(ACCOUNT_B), diff --git a/tests/codex-integration/codex-account-unusable-reason.test.ts b/tests/codex-integration/codex-account-unusable-reason.test.ts index 2486e3c4dea..245da547863 100644 --- a/tests/codex-integration/codex-account-unusable-reason.test.ts +++ b/tests/codex-integration/codex-account-unusable-reason.test.ts @@ -1,4 +1,5 @@ import { CodexPoolAuthenticationError } from "../../src/codex/auth-context"; +import { writeNativeMainAuthFileForTests } from "../helpers/native-main-auth"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -22,9 +23,9 @@ let prevCodexHome: string | undefined; function writeMainAuth(): void { mkdirSync(CODEX_DIR, { recursive: true }); - writeFileSync( + writeNativeMainAuthFileForTests( join(CODEX_DIR, "auth.json"), - JSON.stringify({ tokens: { access_token: "main_access", account_id: "main_acct" } }), + JSON.stringify({ tokens: { access_token: "main_access", refresh_token: "main_refresh", account_id: "main_acct" } }), ); } @@ -100,7 +101,7 @@ describe("codex account unusable reason", () => { }); test("the main account without a native credential reports main_credential_unavailable", () => { - rmSync(join(CODEX_DIR, "auth.json")); + process.env.CODEX_HOME = join(CODEX_DIR, "empty-home"); expect(codexAccountUnusableReason(makeConfig(), MAIN_CODEX_ACCOUNT_ID)) .toBe("main_credential_unavailable"); }); diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 4ed7e9fbf0f..0032c837c54 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -1,4 +1,5 @@ import { registerWarmupRateLimitCases } from "../helpers/codex-warmup-rate-limit"; +import { writeNativeMainAuthFileForTests, writeNativeMainAuthTokensForTests } from "../helpers/native-main-auth"; import { registerResetCreditConsumeValidationTests } from "../helpers/reset-credit-consume-validation"; import { registerPoolReauthCauseCases } from "../helpers/pool-reauth-cause"; import * as usageHistoryModule from "../../src/usage/log"; @@ -283,8 +284,8 @@ function seedPoolAccount( describe("main quota refresh diagnostics", () => { function writeMain(accountId = "fixture-account"): string { const accessToken = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: accessToken, account_id: accountId }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: accessToken, refresh_token: "fixture-main-refresh", account_id: accountId }, })); return accessToken; } @@ -514,9 +515,9 @@ describe("main quota refresh diagnostics", () => { let reads = 0; globalThis.fetch = (async () => { reads++; - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), - account_id: `replacement-${reads}` }, + refresh_token: "fixture-main-refresh", account_id: `replacement-${reads}` }, })); return Response.json({ rate_limit: { primary_window: { used_percent: 37 } } }); }) as typeof fetch; @@ -598,8 +599,8 @@ afterEach(async () => { describe("codex-auth API", () => { test("account polling owns native main through publication so switch and rollback wait", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-poll-owned", account_id: "acct-main-poll-owned" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-poll-owned", refresh_token: "fixture-main-refresh", account_id: "acct-main-poll-owned" }, })); for (const scenario of [ @@ -677,8 +678,8 @@ describe("codex-auth API", () => { }); test("account list discards a main snapshot invalidated while pool publication is pending", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-generation", account_id: "acct-main-generation" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-generation", refresh_token: "fixture-main-refresh", account_id: "acct-main-generation" }, })); const config = makeConfig(); seedPoolAccount(config, { @@ -731,8 +732,8 @@ describe("codex-auth API", () => { }); test("account list preserves only the last claim-owned credential snapshot while main is fenced", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-snapshot", account_id: "acct-main-snapshot" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-snapshot", refresh_token: "fixture-main-refresh", account_id: "acct-main-snapshot" }, })); globalThis.fetch = (async () => Response.json({ email: "snapshot@example.test", @@ -759,11 +760,11 @@ describe("codex-auth API", () => { await listMain(); rmSync(join(TEST_CODEX_HOME, "auth.json")); - expect((await listMain()).hasCredential).toBe(false); + expect((await listMain()).hasCredential).toBe(true); drain = acquireNativeMainProfileDrain("credential-snapshot-warm-cache"); try { const main = await listMain(); - expect(main.hasCredential).toBe(false); + expect(main.hasCredential).toBe(true); expect(main).not.toHaveProperty("quotaRefresh"); } finally { drain?.release(); @@ -771,8 +772,8 @@ describe("codex-auth API", () => { }); test("bulk pause retains native-main ownership through pool settlement and persisted publication", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-pause-owned", account_id: "acct-main-pause-owned" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-pause-owned", refresh_token: "fixture-main-refresh", account_id: "acct-main-pause-owned" }, })); const config = makeConfig(); seedPoolAccount(config, { id: "pause-pool", email: "pool@example.test", plan: "plus" }); @@ -827,8 +828,8 @@ describe("codex-auth API", () => { }); test("bulk pause settles a rejected pool probe before atomically publishing main pause", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-pause-settled", account_id: "acct-main-pause-settled" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-pause-settled", refresh_token: "fixture-main-refresh", account_id: "acct-main-pause-settled" }, })); const config = makeConfig(); seedPoolAccount(config, { id: "busy-pool", email: "busy@example.test", plan: "plus" }); @@ -855,8 +856,8 @@ describe("codex-auth API", () => { }); test("main reset-credit consume owns native main through destructive upstream work and rejects post-fence work", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-reset-owned", account_id: "acct-main-reset-owned" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-reset-owned", refresh_token: "fixture-main-refresh", account_id: "acct-main-reset-owned" }, })); let consumeCalls = 0; let releaseConsume!: () => void; @@ -1047,6 +1048,7 @@ describe("codex-auth API", () => { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", + upstreamWebsocket: false, }, }, }); @@ -1136,8 +1138,8 @@ describe("codex-auth API", () => { test("main account 401 with an undecodable-exp token is terminal and marks needsReauth (#327, #1932)", async () => { // "expired-main" is not a decodable JWT, so its exp cannot vouch for liveness. // Undecodable exp must fail toward reauth: only a decodable future exp counts as live. - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "expired-main", account_id: "acct-main" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "expired-main", refresh_token: "fixture-main-refresh", account_id: "acct-main" }, })); globalThis.fetch = (async () => new Response("", { status: 401 })) as typeof fetch; @@ -1151,8 +1153,8 @@ describe("codex-auth API", () => { }); test("bare main account 401 with a verifiably live token is transient, not reauth (#1932)", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), account_id: "acct-main" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), refresh_token: "fixture-main-refresh", account_id: "acct-main" }, })); globalThis.fetch = (async () => new Response("", { status: 401 })) as typeof fetch; @@ -1166,8 +1168,8 @@ describe("codex-auth API", () => { }); test("main account 401 with a live token but terminal body code is still terminal (#1932)", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), account_id: "acct-main" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), refresh_token: "fixture-main-refresh", account_id: "acct-main" }, })); globalThis.fetch = (async () => Response.json( { detail: { code: "invalid_refresh_token" } }, @@ -1184,8 +1186,8 @@ describe("codex-auth API", () => { }); test("main account 401 with an expired access token is terminal (#1932)", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: jwtWithExp(1), account_id: "acct-main" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: jwtWithExp(1), refresh_token: "fixture-main-refresh", account_id: "acct-main" }, })); globalThis.fetch = (async () => new Response("", { status: 401 })) as typeof fetch; @@ -1199,8 +1201,8 @@ describe("codex-auth API", () => { }); test("main account invalid-workspace 403 is terminal but a generic 403 is not (#327)", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "workspace-main", account_id: "acct-main" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "workspace-main", refresh_token: "fixture-main-refresh", account_id: "acct-main" }, })); globalThis.fetch = (async () => Response.json( { detail: { code: "invalid_workspace_selected" } }, @@ -1226,8 +1228,8 @@ describe("codex-auth API", () => { }); test("successful main account refresh clears a prior needsReauth mark (#327)", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "fresh-main", account_id: "acct-main" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "fresh-main", refresh_token: "fixture-main-refresh", account_id: "acct-main" }, })); markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); globalThis.fetch = (async () => Response.json({ @@ -1251,8 +1253,8 @@ describe("codex-auth API", () => { // token, so the periodic refresh must not read its own 200 as proof the account // can serve traffic again — doing so returned the account to rotation, the next // request failed identically and re-marked it, and needsReauth never settled. - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "live-main", account_id: "acct-main" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "live-main", refresh_token: "fixture-main-refresh", account_id: "acct-main" }, })); markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); clearMainAccountInfoCache(); @@ -1283,8 +1285,8 @@ describe("codex-auth API", () => { // re-read past a now-stale cache. That argument must not double as operator intent: // a background poll that crosses an identity change would otherwise come back with // the authority to retract a quarantine it was never allowed to touch. - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "live-main", account_id: "acct-before" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "live-main", refresh_token: "fixture-main-refresh", account_id: "acct-before" }, })); clearMainAccountInfoCache(); let usageCalls = 0; @@ -1293,8 +1295,8 @@ describe("codex-auth API", () => { if (usageCalls === 1) { // Identity changes on disk mid-probe. The retry's purge legitimately drops the // *previous* identity's runtime state — that is not what this test is about. - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "live-main", account_id: "acct-after" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "live-main", refresh_token: "fixture-main-refresh", account_id: "acct-after" }, })); } else { // Real traffic quarantines the *new* identity while the retry probe is in @@ -1316,12 +1318,7 @@ describe("codex-auth API", () => { }); test("BUG-R327: main account exposes and updates needsReauth from WHAM auth responses", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { - access_token: "main-access", - account_id: "main-account", - }, - })); + writeNativeMainAuthTokensForTests(join(TEST_CODEX_HOME, "auth.json"), { access_token: "main-access", refresh_token: "fixture-main-refresh", account_id: "main-account" }); let status = 401; globalThis.fetch = (async (input: RequestInfo | URL) => { expect(String(input)).toBe("https://chatgpt.com/backend-api/wham/usage"); @@ -2055,12 +2052,7 @@ describe("codex-auth API", () => { }); test("main account list uses chatgpt_plan_type when WHAM omits plan_type (#1989)", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { - access_token: chatgptPlanJwt("pro", "acct-main-jwt"), - account_id: "acct-main-jwt", - }, - })); + writeNativeMainAuthTokensForTests(join(TEST_CODEX_HOME, "auth.json"), { access_token: chatgptPlanJwt("pro", "acct-main-jwt"), refresh_token: "fixture-main-refresh", account_id: "acct-main-jwt" }); globalThis.fetch = (async () => Response.json({ email: "main-jwt@example.test", rate_limit: { primary_window: { used_percent: 2, reset_at: 1782628379 } }, @@ -3027,8 +3019,8 @@ describe("codex-auth API", () => { // /wham/usage carries rate_limit_reset_credits only intermittently. Pool DTOs survive // that because they re-read the merged store; the main DTO used to serialize the raw // parse result, so the ticket badge disappeared on every response that omitted it. - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-dto-credits", account_id: "acct-main-dto-credits" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-dto-credits", refresh_token: "fixture-main-refresh", account_id: "acct-main-dto-credits" }, })); reconcileMainCodexAccountRuntimeState(); const originalFetch = globalThis.fetch; @@ -3064,8 +3056,8 @@ describe("codex-auth API", () => { // `__main__` is an alias: ~/.codex/auth.json can be swapped for another physical // ChatGPT account, so a carried ticket count must be bound to the identity it was read // from or one account's credits show up on another's card. - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-ident-a", account_id: "acct-main-ident-a" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-ident-a", refresh_token: "fixture-main-refresh", account_id: "acct-main-ident-a" }, })); reconcileMainCodexAccountRuntimeState(); const originalFetch = globalThis.fetch; @@ -3089,8 +3081,8 @@ describe("codex-auth API", () => { // The operator signs in as a different physical account and the next usage response // happens not to carry the summary. - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-ident-b", account_id: "acct-main-ident-b" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-ident-b", refresh_token: "fixture-main-refresh", account_id: "acct-main-ident-b" }, })); includeCredits = false; const second = await listCodexAuthAccounts(makeConfig(), true); @@ -3104,8 +3096,8 @@ describe("codex-auth API", () => { test("a freshly parsed main resetCredits of zero overrides the stored value", async () => { // Zero is a real reading, not an absence: the DTO fill must never resurrect a stale // non-zero ticket count over it. - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-dto-zero", account_id: "acct-main-dto-zero" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-dto-zero", refresh_token: "fixture-main-refresh", account_id: "acct-main-dto-zero" }, })); reconcileMainCodexAccountRuntimeState(); updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, undefined, undefined, undefined, undefined, 3); @@ -3133,8 +3125,8 @@ describe("codex-auth API", () => { }); test("reset-credit consume omits remaining when main WHAM refresh is non-2xx", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-reset-fail", account_id: "acct-main-reset-fail" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-reset-fail", refresh_token: "fixture-main-refresh", account_id: "acct-main-reset-fail" }, })); // Stabilize identity tracking so seeding stale quota is not purged by reconcile. reconcileMainCodexAccountRuntimeState(); @@ -3167,8 +3159,8 @@ describe("codex-auth API", () => { }); test("reset-credit consume omits remaining when main WHAM omits rate_limit_reset_credits", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-reset-omit", account_id: "acct-main-reset-omit" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-reset-omit", refresh_token: "fixture-main-refresh", account_id: "acct-main-reset-omit" }, })); reconcileMainCodexAccountRuntimeState(); updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, undefined, undefined, undefined, undefined, 6); @@ -3205,8 +3197,8 @@ describe("codex-auth API", () => { // Adapted from luvs01's #3995, e172453052bf7bbc4a0ae5aa24592982c0c64b15. test.each(["reset", "already_redeemed"])("cold main %s returns fresh WHAM credits without a prior lookup", async code => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "cold-main-reset-token", account_id: "cold-main-reset-account" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "cold-main-reset-token", refresh_token: "fixture-main-refresh", account_id: "cold-main-reset-account" }, })); // Intentionally no listing, reconciliation, writer observation or quota seed. let consumeCalls = 0; let usageCalls = 0; @@ -3240,8 +3232,8 @@ describe("codex-auth API", () => { }); test("reset-credit consume returns remaining from fresh main WHAM credits", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-reset-ok", account_id: "acct-main-reset-ok" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-reset-ok", refresh_token: "fixture-main-refresh", account_id: "acct-main-reset-ok" }, })); reconcileMainCodexAccountRuntimeState(); updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, undefined, undefined, undefined, undefined, 9); @@ -4171,8 +4163,8 @@ describe("codex-auth API", () => { }); test("bulk pause preserves a known Free main plan when WHAM omits plan_type", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-access", account_id: "main-account" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", refresh_token: "fixture-main-refresh", account_id: "main-account" }, })); let call = 0; globalThis.fetch = (async () => { @@ -4204,8 +4196,8 @@ describe("codex-auth API", () => { }); test("bulk pause fails closed when the main quota plan cannot be established", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-access", account_id: "main-account" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", refresh_token: "fixture-main-refresh", account_id: "main-account" }, })); globalThis.fetch = (async () => Response.json({ email: "main@example.test", @@ -4243,12 +4235,13 @@ describe("codex-auth API", () => { }); test("repeated main bulk-pause reads honor failed-query pacing under a shared claim", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "bulk-main-access", account_id: "bulk-main-account" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "bulk-main-access", refresh_token: "fixture-main-refresh", account_id: "bulk-main-account" }, })); const config = makeConfig({ providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", + upstreamWebsocket: false, } } }); let calls = 0; globalThis.fetch = (async () => { calls++; return new Response(null, { status: 503 }); }) as typeof fetch; @@ -4303,8 +4296,8 @@ describe("codex-auth API", () => { }); test("PUT /api/codex-auth/accounts/pause-exhausted includes a freshly exhausted main account", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-access", account_id: "main-account" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", refresh_token: "fixture-main-refresh", account_id: "main-account" }, })); globalThis.fetch = (async () => Response.json({ email: "main@example.test", @@ -4410,6 +4403,7 @@ describe("codex-auth API", () => { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", + upstreamWebsocket: false, }, }, }); @@ -4575,8 +4569,8 @@ describe("codex-auth API", () => { expiresAt: Date.now() + 5 * 60_000, chatgptAccountId: "legacy-pool-account", }); - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "desktop-access", account_id: "desktop-account" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "desktop-access", refresh_token: "fixture-main-refresh", account_id: "desktop-account" }, })); expect(getMainChatgptAccountId()).toBe("desktop-account"); @@ -6399,9 +6393,10 @@ describe("manual reset cooldown recovery (#3973)", () => { const config = makeConfig({ codexMainAccountHardLock: true, providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", + upstreamWebsocket: false, } } }); const accessToken = "ordered-main-token"; const accountId = "ordered-main-account"; - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: accessToken, account_id: accountId } })); + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: accessToken, refresh_token: "fixture-main-refresh", account_id: accountId } })); reconcileMainCodexAccountRuntimeState(); const writer = observeMainQuotaCredential(accessToken, accountId)!; setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, { weeklyPercent: 100, resetCredits: 5 }, captureConfigGeneration(), writer); @@ -6451,8 +6446,8 @@ describe("manual reset cooldown recovery (#3973)", () => { }); test("concurrent same-key main reads share one published quota result", async () => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "publication-main-token", account_id: "publication-main-account" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "publication-main-token", refresh_token: "fixture-main-refresh", account_id: "publication-main-account" }, })); reconcileMainCodexAccountRuntimeState(); const started = gate(); const finish = gate(); let reads = 0; @@ -6476,12 +6471,13 @@ describe("manual reset cooldown recovery (#3973)", () => { }); test.each(["absent", "direct"])("%s non-pool main forces dispatch after a failed read", async mode => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "non-pool-access", account_id: "non-pool-account" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "non-pool-access", refresh_token: "fixture-main-refresh", account_id: "non-pool-account" }, })); const config = makeConfig(mode === "direct" ? { providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", + upstreamWebsocket: false, } } } : {}); let calls = 0; globalThis.fetch = (async () => { calls++; return new Response(null, { status: 503 }); }) as typeof fetch; @@ -6492,8 +6488,8 @@ describe("manual reset cooldown recovery (#3973)", () => { test("main reset usage does not erase an existing reauth quarantine", async () => { const config = makeConfig(); - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "manual-main-token", account_id: "manual-main-account" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "manual-main-token", refresh_token: "fixture-main-refresh", account_id: "manual-main-account" }, })); reconcileMainCodexAccountRuntimeState(); cool(config, MAIN_CODEX_ACCOUNT_ID); @@ -6508,8 +6504,8 @@ describe("manual reset cooldown recovery (#3973)", () => { test("conflicting main token/header identity supplies no recovery proof", async () => { const config = makeConfig(); const payload = Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "token-account" } })).toString("base64url"); - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "manual-main-token", id_token: `e30.${payload}.sig`, account_id: "header-account" }, + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "manual-main-token", refresh_token: "fixture-main-refresh", id_token: `e30.${payload}.sig`, account_id: "header-account" }, })); reconcileMainCodexAccountRuntimeState(); cool(config, MAIN_CODEX_ACCOUNT_ID); const urls = mock(() => Response.json({ code: "reset" }), () => Response.json(usage())); @@ -6521,7 +6517,7 @@ describe("manual reset cooldown recovery (#3973)", () => { test.each(["same", "bearer", "other-account", "aba"])("main recovery uses its own live credential proof: %s", async change => { const config = makeConfig(); const writeMain = (accountId: string, accessToken = "manual-main-token") => { - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: accessToken, account_id: accountId } })); + writeNativeMainAuthFileForTests(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: accessToken, refresh_token: "fixture-main-refresh", account_id: accountId } })); }; writeMain("manual-main-account"); reconcileMainCodexAccountRuntimeState(); cool(config, MAIN_CODEX_ACCOUNT_ID); diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 9f1244eb43e..393ee71dd3e 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -1,6 +1,7 @@ import { registerStoredDirectIdentityTests } from "../helpers/stored-direct-identity"; +import { writeNativeMainAuthFileForTests } from "../helpers/native-main-auth"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -137,7 +138,7 @@ function config(): OcxConfig { activeCodexAccountId: "pool-a", providers: { routed: { adapter: "openai-chat", baseUrl: "https://routed.test/v1", apiKey: "routed-key" }, - chatgpt: { adapter: "openai-responses", baseUrl: "https://chatgpt.test/backend-api/codex", authMode: "forward" }, + chatgpt: { adapter: "openai-responses", baseUrl: "https://chatgpt.test/backend-api/codex", authMode: "forward", upstreamWebsocket: false,}, }, codexAccounts: [ { id: "main", email: "main@example.test", isMain: true }, @@ -162,6 +163,7 @@ function guardianConfig(): OcxConfig { authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive", + upstreamWebsocket: false, }, }; cfg.tokenGuardian = { @@ -218,6 +220,7 @@ const forwardProvider: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://chatgpt.test/backend-api/codex", authMode: "forward", + upstreamWebsocket: false, }; @@ -302,9 +305,9 @@ describe("Codex auth context", () => { }); test("recovery excludes main from affinity, rotation, and retry while healthy pool continues", async () => { - writeFileSync( + writeNativeMainAuthFileForTests( join(testDir, "auth.json"), - JSON.stringify({ tokens: { access_token: "main_token", account_id: "main-account" } }), + JSON.stringify({ tokens: { access_token: "main_token", refresh_token: "main-refresh", account_id: "main-account" } }), ); saveCodexAccountCredential("pool-a", { accessToken: "pool_token", @@ -378,9 +381,9 @@ describe("Codex auth context", () => { }); test("post-fence main selection rejects before injected token materialization", async () => { - writeFileSync( + writeNativeMainAuthFileForTests( join(testDir, "auth.json"), - JSON.stringify({ tokens: { access_token: "main_token", account_id: "main-account" } }), + JSON.stringify({ tokens: { access_token: "main_token", refresh_token: "main-refresh", account_id: "main-account" } }), ); const cfg = config(); cfg.activeCodexAccountId = MAIN_CODEX_ACCOUNT_ID; @@ -400,8 +403,8 @@ describe("Codex auth context", () => { expect(nativeReads).toBe(0); // Selection-only quota scoring must not lazily read/cache the fenced main // plan before the atomic claim rejects the request. - writeFileSync(join(testDir, "auth.json"), JSON.stringify({ - tokens: { access_token: chatgptPlanJwt("pro"), account_id: "main-account" }, + writeNativeMainAuthFileForTests(join(testDir, "auth.json"), JSON.stringify({ + tokens: { access_token: chatgptPlanJwt("pro"), refresh_token: "main-refresh", account_id: "main-account" }, })); expect(getMainAccountPlan()).toBe("pro"); } finally { @@ -443,8 +446,8 @@ describe("Codex auth context", () => { // Pin retirement must not lazily score/read main while the drain fence is up. // A prior plan read against the intentionally missing auth.json would cache // `undefined` and make this post-fence JWT fallback fail. - writeFileSync(join(testDir, "auth.json"), JSON.stringify({ - tokens: { access_token: chatgptPlanJwt("pro"), account_id: "main-account" }, + writeNativeMainAuthFileForTests(join(testDir, "auth.json"), JSON.stringify({ + tokens: { access_token: chatgptPlanJwt("pro"), refresh_token: "main-refresh", account_id: "main-account" }, })); expect(getMainAccountPlan()).toBe("pro"); } finally { @@ -643,8 +646,8 @@ describe("Codex auth context", () => { test("account-gated native routing skips an active account without the model grant", async () => { const cfg = config(); cfg.activeCodexAccountPinned = "pool-a"; - writeFileSync(join(testDir, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-token", account_id: "main-account" }, + writeNativeMainAuthFileForTests(join(testDir, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-token", refresh_token: "main-refresh", account_id: "main-account" }, })); saveCodexAccountCredential("pool-a", { accessToken: "pool-token", @@ -1138,8 +1141,8 @@ describe("Codex auth context", () => { test("exact main-account resolution reads auth.json without consulting the active Pool account", async () => { const cfg = config(); - writeFileSync(join(testDir, "auth.json"), JSON.stringify({ - tokens: { access_token: "opaque-live-main-token", account_id: "main-chatgpt-account" }, + writeNativeMainAuthFileForTests(join(testDir, "auth.json"), JSON.stringify({ + tokens: { access_token: "opaque-live-main-token", refresh_token: "main-refresh", account_id: "main-chatgpt-account" }, })); await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { @@ -1167,7 +1170,7 @@ describe("Codex auth context", () => { account_id: "operator-main-account", }, }); - writeFileSync(authPath, originalAuth); + writeNativeMainAuthFileForTests(authPath, originalAuth); const inbound = new Headers({ authorization: "Bearer caller-keyring-token", "chatgpt-account-id": "caller-keyring-account", @@ -1608,7 +1611,7 @@ describe("Codex auth context", () => { test("substitution fails closed when no usable main credential exists (#1686)", () => { // Falling through here would forward the admission secret upstream, which is exactly // the leak the forward guard exists to prevent. Throw before any I/O instead. - writeFileSync(join(testDir, "auth.json"), JSON.stringify({ tokens: {} })); + writeNativeMainAuthFileForTests(join(testDir, "auth.json"), JSON.stringify({ tokens: {} })); expect(() => materializeCodexUpstreamAuth( new Headers({ authorization: "Bearer ocx_data_localsecret" }), @@ -2391,6 +2394,7 @@ test("context Direct bearer admission uses real stored-main materialization and cfg.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", + upstreamWebsocket: false, }; const token = liveJwt(); const admission = { kind: "environment", source: "bearer", contextPrincipalId: "principal-a" } as const; @@ -2415,7 +2419,15 @@ test("context Direct bearer admission uses real stored-main materialization and }, { preconnect: originalFetch.preconnect }); try { for (const available of [true, false]) { - writeFileSync(join(testDir, "auth.json"), JSON.stringify({ tokens: available ? { access_token: token, account_id: "stored_main_acc" } : {} })); + const codexHome = available ? testDir : join(testDir, "without-main-credential"); + process.env.CODEX_HOME = codexHome; + mkdirSync(codexHome, { recursive: true }); + writeFileSync(join(codexHome, "config.toml"), "[features]\ncontext_management.experimental_mode = true\n"); + if (available) { + writeNativeMainAuthFileForTests(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: token, refresh_token: "main-refresh", account_id: "stored_main_acc" }, + })); + } const turn = tryAdmitTurn(); expect(turn).not.toBeNull(); try { diff --git a/tests/codex-integration/codex-auth-release.test.ts b/tests/codex-integration/codex-auth-release.test.ts index 1b03fe4b596..2af0eba5147 100644 --- a/tests/codex-integration/codex-auth-release.test.ts +++ b/tests/codex-integration/codex-auth-release.test.ts @@ -198,7 +198,7 @@ test("an unconfirmed release may already be present in published bytes", async ( test.each(["work", MAIN_CODEX_ACCOUNT_ID])("guarded release accepts the matching %s pin", async id => { const config = guardedPool(); - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: "fixture-native-access", account_id: "fixture-native" } })); + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: "fixture-native-access", refresh_token: "fixture-native-refresh", account_id: "fixture-native" } })); await pin(config, id); const request = put({ accountId: null, expectedPinnedAccountId: id }); const response = (await handleCodexAuthAPI(request, new URL(request.url), config))!; diff --git a/tests/codex-integration/codex-catalog-restore.test.ts b/tests/codex-integration/codex-catalog-restore.test.ts index 36d86ff6184..ded3deb9e75 100644 --- a/tests/codex-integration/codex-catalog-restore.test.ts +++ b/tests/codex-integration/codex-catalog-restore.test.ts @@ -357,7 +357,7 @@ describe("Codex catalog restore", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: "catalog-main-access", account_id: "catalog-main-account" }, + tokens: { access_token: "catalog-main-access", refresh_token: "catalog-main-refresh", account_id: "catalog-main-account" }, }), "utf8"); writeFileSync(catalogPath, JSON.stringify({ models: [ diff --git a/tests/codex-integration/codex-catalog-sync-hardening.test.ts b/tests/codex-integration/codex-catalog-sync-hardening.test.ts index a28975af281..f1a7a5225bb 100644 --- a/tests/codex-integration/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-integration/codex-catalog-sync-hardening.test.ts @@ -23,6 +23,7 @@ function runScript( ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome, + CODEX_CLI_PATH: "", ...extraEnv, }, encoding: "utf8", @@ -201,6 +202,7 @@ describe("Codex catalog sync hardening", () => { providers: { openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", liveModels: false, + upstreamWebsocket: false, } }, codexAccounts: [{ id: "stored-side-account", isMain: false }], codexAccountNamespaces: { desktop: "@main", side: "stored-side-account" }, @@ -525,7 +527,7 @@ describe("Codex catalog sync hardening", () => { }], }, null, 2) + "\n"); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-token", account_id: "main-account" }, + tokens: { access_token: "main-token", refresh_token: "main-refresh", account_id: "main-account" }, }), "utf8"); const r = runScript(codexHome, opencodexHome, ` @@ -590,7 +592,7 @@ describe("Codex catalog sync hardening", () => { const config = { port: 10100, defaultProvider: "openai", - providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool" } }, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", upstreamWebsocket: false,} }, codexAccountPickerEnabled: false, customModels: [{ id: "astra", provider: "openai", modelId: "gpt-6-astra", reasoningEfforts: ["none", "minimal", "low"], defaultReasoningEffort: "minimal" }] }; @@ -636,7 +638,8 @@ describe("Codex catalog sync hardening", () => { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", - codexAccountMode: "pool" + codexAccountMode: "pool", + upstreamWebsocket: false, } }, defaultProvider: "openai", @@ -1097,7 +1100,7 @@ describe("Codex catalog sync hardening", () => { expect(slugs).toContain("cursor/composer-2.5"); }, 15_000); - test("drops legacy-signature ghost rows in both gather branches", () => { + test("drops legacy-signature ghost rows after partial gather", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); writeFileSync(catalogPath, JSON.stringify({ @@ -1120,11 +1123,14 @@ describe("Codex catalog sync hardening", () => { }, }); expect(partial.status).toBe(0); - let slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug); + const slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug); expect(slugs).not.toContain("future-grok/legacy-model"); expect(slugs).toContain("cursor/composer-2.5"); + }); - // Empty-gather branch: re-seed the legacy ghost and gather nothing. + test("drops legacy-signature ghost rows after empty gather", () => { + const catalogPath = join(codexHome, "catalog.json"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); writeFileSync(catalogPath, JSON.stringify({ models: [ nativeEntry("gpt-5.5", 0), @@ -1134,12 +1140,13 @@ describe("Codex catalog sync hardening", () => { }, null, 2) + "\n"); const empty = syncWithSavedConfig(codexHome, opencodexHome, { providers: {} }); expect(empty.status).toBe(0); - slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug); + const slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug); expect(slugs).not.toContain("future-grok/legacy-model"); expect(slugs).toContain("cursor/composer-2.5"); }); - test("drops legacy combo-alias ghost rows in both gather branches", () => { + test("drops legacy combo-alias ghost rows after partial gather", () => { + const catalogPath = join(codexHome, "catalog.json"); const legacyComboAlias = { ...routedEntry("vendor/fast", 5), description: "Routed via opencodex → combo (combo).", @@ -1152,7 +1159,6 @@ describe("Codex catalog sync hardening", () => { routedEntry("cursor/composer-2.5", 6), ], }, null, 2) + "\n"); - const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); // Partial-gather branch: a PHYSICAL combo provider bypasses the generic @@ -1172,8 +1178,23 @@ describe("Codex catalog sync hardening", () => { let slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug); expect(slugs).not.toContain("vendor/fast"); expect(slugs).toContain("cursor/composer-2.5"); + }); - // Empty-gather branch: physical combo present but gathers zero rows. + test("drops legacy combo-alias ghost rows after empty gather", () => { + const legacyComboAlias = { + ...routedEntry("vendor/fast", 5), + description: "Routed via opencodex → combo (combo).", + owned_by: "combo", + }; + const catalogPath = join(codexHome, "catalog.json"); + const seed = () => writeFileSync(catalogPath, JSON.stringify({ + models: [ + nativeEntry("gpt-5.5", 0), + legacyComboAlias, + routedEntry("cursor/composer-2.5", 6), + ], + }, null, 2) + "\n"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); seed(); const empty = syncWithSavedConfig(codexHome, opencodexHome, { providers: { @@ -1186,7 +1207,7 @@ describe("Codex catalog sync hardening", () => { }, }); expect(empty.status).toBe(0); - slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug); + const slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug); expect(slugs).not.toContain("vendor/fast"); expect(slugs).toContain("cursor/composer-2.5"); }); diff --git a/tests/codex-integration/codex-catalog-write-audit.test.ts b/tests/codex-integration/codex-catalog-write-audit.test.ts index f8f4cee61f5..000c13b5e28 100644 --- a/tests/codex-integration/codex-catalog-write-audit.test.ts +++ b/tests/codex-integration/codex-catalog-write-audit.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, symlinkSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, symlinkSync } from "node:fs"; import * as fs from "node:fs"; import * as secretAcl from "../../src/lib/windows-secret-acl"; import { tmpdir } from "node:os"; @@ -282,6 +282,7 @@ describe("bounded audit descriptors and privacy", () => { expect(replaceAs("restore", catalogBytes(native, routed))).toEqual({ kind: "written" }); rmSync(auditPath(), { recursive: true }); writeFileSync(auditPath(), '{}\n', { mode: 0o644 }); + chmodSync(auditPath(), 0o644); expect(replaceAs("restore", catalogBytes(native))).toEqual({ kind: "written" }); expect(readFileSync(auditPath(), "utf8")).toBe('{}\n'); if (process.platform !== "win32") { diff --git a/tests/codex-integration/codex-composed-acceptance.test.ts b/tests/codex-integration/codex-composed-acceptance.test.ts index b383c7a35c4..1131582f790 100644 --- a/tests/codex-integration/codex-composed-acceptance.test.ts +++ b/tests/codex-integration/codex-composed-acceptance.test.ts @@ -694,6 +694,7 @@ describe("WP13 composed toggle acceptance", () => { writeFileSync(join(fx.codex, "auth.json"), JSON.stringify({ tokens: { access_token: `header.${expiredPayload}.signature`, + refresh_token: "foreign-main-refresh", account_id: "foreign-main-account", }, })); @@ -748,6 +749,7 @@ describe("WP13 composed toggle acceptance", () => { writeFileSync(join(fx.codex, "auth.json"), JSON.stringify({ tokens: { access_token: "opaque-main-token", + refresh_token: "unknown-main-refresh", account_id: "unknown-main-account", }, })); diff --git a/tests/codex-integration/codex-convergence-account-selectors.test.ts b/tests/codex-integration/codex-convergence-account-selectors.test.ts index 7b5286692c3..4f559b5282a 100644 --- a/tests/codex-integration/codex-convergence-account-selectors.test.ts +++ b/tests/codex-integration/codex-convergence-account-selectors.test.ts @@ -164,6 +164,7 @@ function config(pickerEnabled: boolean, disabledModels: string[] = []): OcxConfi adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward", + upstreamWebsocket: false, }, }, defaultProvider: "openai", @@ -310,7 +311,7 @@ beforeEach(() => { modelRostersByChatgptAccount = new Map(); modelAccessProgramsByChatgptAccount = new Map(); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-token", account_id: "main-chatgpt-account" }, + tokens: { access_token: "main-token", refresh_token: "main-refresh", account_id: "main-chatgpt-account" }, })); saveCodexAccountCredential("side-account-id", { accessToken: "side-token", @@ -472,7 +473,7 @@ test("convergence projects the observed Daybreak row onto its selector and one b writeCatalog([nativeEntry()]); removeCodexAccountCredential("side-account-id"); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-token", account_id: "main-chatgpt-account" }, + tokens: { access_token: "main-token", refresh_token: "main-refresh", account_id: "main-chatgpt-account" }, })); writeFileSync(join(codexHome, "models_cache.json"), JSON.stringify({ models: [{ @@ -516,7 +517,7 @@ test("convergence projects the observed Daybreak row onto its selector and one b test("Direct convergence does not borrow a Pool-only Daybreak grant for the bare row", async () => { writeCatalog([nativeEntry()]); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-token", account_id: "main-chatgpt-account" }, + tokens: { access_token: "main-token", refresh_token: "main-refresh", account_id: "main-chatgpt-account" }, })); saveCodexAccountCredential("side-account-id", { accessToken: "side-token", diff --git a/tests/codex-integration/codex-credits-probes.test.ts b/tests/codex-integration/codex-credits-probes.test.ts index 71e7602d963..6bc2e1c7b70 100644 --- a/tests/codex-integration/codex-credits-probes.test.ts +++ b/tests/codex-integration/codex-credits-probes.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { writeNativeMainAuthFileForTests } from "../helpers/native-main-auth"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -24,8 +25,8 @@ const account: CodexAccount = { id: "credits-pool", email: "credits@example.test const config = (): OcxConfig => ({ port: 10100, defaultProvider: "openai", providers: {}, codexAccounts: [] }); function writeMain(bearer = "fixture-main-bearer", identity = "fixture-main-identity") { - writeFileSync(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ - tokens: { access_token: bearer, account_id: identity }, + writeNativeMainAuthFileForTests(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ + tokens: { access_token: bearer, refresh_token: "fixture-main-refresh", account_id: identity }, })); } function poolContext() { diff --git a/tests/codex-integration/codex-envkey-admission-substitution.test.ts b/tests/codex-integration/codex-envkey-admission-substitution.test.ts index cb1a0853d13..b822892bdd7 100644 --- a/tests/codex-integration/codex-envkey-admission-substitution.test.ts +++ b/tests/codex-integration/codex-envkey-admission-substitution.test.ts @@ -24,6 +24,7 @@ const originalFetch = globalThis.fetch; const previousOcxHome = process.env.OPENCODEX_HOME; const previousCodexHome = process.env.CODEX_HOME; const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; +const acquiredServers = new Set>(); /** * Start the proxy with ownership scoped to THIS fixture's homes. @@ -43,9 +44,11 @@ const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; * state files. */ function startFixtureServer(): ReturnType { - return startServer(0, { + const server = startServer(0, { inspectNativeCodexOwnership: () => ({ ownership: "owned" as const }), }); + acquiredServers.add(server); + return server; } /** @@ -95,6 +98,7 @@ function directConfig(): OcxConfig { authMode: "forward", codexAccountMode: "direct", defaultModel: "gpt-5.5", + upstreamWebsocket: false, }, }, apiKeys: [ @@ -106,7 +110,7 @@ function directConfig(): OcxConfig { function writeStoredMain(accessToken: string): void { writeFileSync( join(codexHome, "auth.json"), - JSON.stringify({ tokens: { access_token: accessToken, account_id: "stored_main_acc" } }), + JSON.stringify({ tokens: { access_token: accessToken, refresh_token: "stored_main_refresh", account_id: "stored_main_acc" } }), ); } @@ -131,7 +135,16 @@ beforeEach(() => { }) as typeof fetch; }); -afterEach(() => { +afterEach(async () => { + let stopError: unknown; + for (const server of [...acquiredServers]) { + try { + await server.stop(true); + } catch (error) { + stopError ??= error; + } + } + acquiredServers.clear(); globalThis.fetch = originalFetch; resetDebugSettingsForTests(); resetDebugLogBufferForTests(); @@ -145,6 +158,7 @@ afterEach(() => { if (codexHome) removeTreeWithRetry(codexHome); ocxHome = ""; codexHome = ""; + if (stopError) throw stopError; }); async function postResponses(url: string | URL, authorization: string): Promise { @@ -171,28 +185,24 @@ describe("#1686 env_key bearer admission reaches Direct with substitution", () = writeStoredMain(stored); const server = await startSettledFixtureServer(); - try { - const response = await postResponses(server.url, `Bearer ${ADMISSION_SECRET}`); - - // Before this change the same request answered 401: admission accepted the bearer at the - // door, then Direct refused it because it could not tell it from a user's own credential. - expect(response.status).toBe(200); - expect(upstreamAuth).toEqual([`Bearer ${stored}`]); - // The proof that matters: our own secret never reached the wire. - expect(upstreamAuth.join("|")).not.toContain(ADMISSION_SECRET); - const affinityLine = getDebugLogEntries() - .map(entry => entry.line) - .find(line => line.startsWith("[ocx:codex:affinity] ")); - expect(affinityLine).toBeDefined(); - expect(JSON.parse(affinityLine!.slice("[ocx:codex:affinity] ".length))).toMatchObject({ - authKind: "main", - accountMode: "direct", - credentialSubstituted: true, - status: 200, - }); - } finally { - await server.stop(true); - } + const response = await postResponses(server.url, `Bearer ${ADMISSION_SECRET}`); + + // Before this change the same request answered 401: admission accepted the bearer at the + // door, then Direct refused it because it could not tell it from a user's own credential. + expect(response.status).toBe(200); + expect(upstreamAuth).toEqual([`Bearer ${stored}`]); + // The proof that matters: our own secret never reached the wire. + expect(upstreamAuth.join("|")).not.toContain(ADMISSION_SECRET); + const affinityLine = getDebugLogEntries() + .map(entry => entry.line) + .find(line => line.startsWith("[ocx:codex:affinity] ")); + expect(affinityLine).toBeDefined(); + expect(JSON.parse(affinityLine!.slice("[ocx:codex:affinity] ".length))).toMatchObject({ + authKind: "main", + accountMode: "direct", + credentialSubstituted: true, + status: 200, + }); }); test("substitution fails closed before any upstream I/O when no main credential is stored", async () => { @@ -200,16 +210,12 @@ describe("#1686 env_key bearer admission reaches Direct with substitution", () = writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); const server = await startSettledFixtureServer(); - try { - const response = await postResponses(server.url, `Bearer ${ADMISSION_SECRET}`); + const response = await postResponses(server.url, `Bearer ${ADMISSION_SECRET}`); - expect(response.status).toBe(401); - // Falling through would have forwarded the admission secret, which is the leak the - // forward guard exists to prevent. Nothing may reach an upstream on this path. - expect(upstreamAuth).toHaveLength(0); - } finally { - await server.stop(true); - } + expect(response.status).toBe(401); + // Falling through would have forwarded the admission secret, which is the leak the + // forward guard exists to prevent. Nothing may reach an upstream on this path. + expect(upstreamAuth).toHaveLength(0); }); test("compact reports missing substitution credentials as authentication failure", async () => { @@ -217,17 +223,13 @@ describe("#1686 env_key bearer admission reaches Direct with substitution", () = writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); const server = await startSettledFixtureServer(); - try { - const response = await postCompact(server.url, `Bearer ${ADMISSION_SECRET}`); - const body = await response.json() as { error?: { type?: string; message?: string } }; - - expect(response.status).toBe(401); - expect(body.error?.type).toBe("authentication_error"); - expect(body.error?.message).toBe("No usable Codex main credential to serve this request"); - expect(upstreamAuth).toHaveLength(0); - } finally { - await server.stop(true); - } + const response = await postCompact(server.url, `Bearer ${ADMISSION_SECRET}`); + const body = await response.json() as { error?: { type?: string; message?: string } }; + + expect(response.status).toBe(401); + expect(body.error?.type).toBe("authentication_error"); + expect(body.error?.message).toBe("No usable Codex main credential to serve this request"); + expect(upstreamAuth).toHaveLength(0); }); test("a foreign bearer is still Codex Direct passthrough, not admission", async () => { @@ -235,13 +237,9 @@ describe("#1686 env_key bearer admission reaches Direct with substitution", () = writeStoredMain(liveJwt()); const server = await startSettledFixtureServer(); - try { - // A real ChatGPT credential is NOT one of our secrets, so it must not be admitted as one. - const response = await postResponses(server.url, "Bearer sk-user-chatgpt-token"); - expect(response.status).toBe(401); - expect(upstreamAuth).toHaveLength(0); - } finally { - await server.stop(true); - } + // A real ChatGPT credential is NOT one of our secrets, so it must not be admitted as one. + const response = await postResponses(server.url, "Bearer sk-user-chatgpt-token"); + expect(response.status).toBe(401); + expect(upstreamAuth).toHaveLength(0); }); }); diff --git a/tests/codex-integration/codex-linked-account-pause.test.ts b/tests/codex-integration/codex-linked-account-pause.test.ts index 3bed76d4f71..7f8e3bee07d 100644 --- a/tests/codex-integration/codex-linked-account-pause.test.ts +++ b/tests/codex-integration/codex-linked-account-pause.test.ts @@ -78,7 +78,7 @@ for (const selectedId of [MAIN_CODEX_ACCOUNT_ID, "same-login"]) { email: "SAME@example.test", chatgpt_account_id: "personal-scope", })).toString("base64url")}.signature`; const auth = JSON.stringify({ tokens: { - access_token: "main-linked-access", account_id: "personal-scope", id_token: idToken, + access_token: "main-linked-access", refresh_token: "main-linked-refresh", account_id: "personal-scope", id_token: idToken, } }); writeFileSync(join(TEST_CODEX_HOME, "auth.json"), auth); seedPoolAccount(config, { id: "same-login", email: " same@example.test ", chatgptAccountId: "personal-scope" }); @@ -138,7 +138,7 @@ test("manual pause cannot publish a partial identity group while native main is test("manual pause does not link a main login whose email identity is unknown", async () => { const config = makeConfig(); writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "opaque-main", account_id: "shared-workspace" }, + tokens: { access_token: "opaque-main", refresh_token: "opaque-main-refresh", account_id: "shared-workspace" }, })); seedPoolAccount(config, { id: "known-member", email: "member@example.test", chatgptAccountId: "shared-workspace" }); const req = new Request("http://localhost/api/codex-auth/accounts/pause", { @@ -184,7 +184,7 @@ test("manual pause refuses conflicting native-main workspace claims", async () = email: "same@example.test", chatgpt_account_id: "different-scope", })).toString("base64url")}.signature`; writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "opaque-main", account_id: "wire-scope", id_token: idToken }, + tokens: { access_token: "opaque-main", refresh_token: "opaque-main-refresh", account_id: "wire-scope", id_token: idToken }, })); seedPoolAccount(config, { id: "pause-conflict", email: "same@example.test", chatgptAccountId: "different-scope" }); const req = new Request("http://localhost/api/codex-auth/accounts/pause", { @@ -202,7 +202,7 @@ test("manual pause refuses main tokens that name different members of one worksp email, chatgpt_account_id: "shared-scope", })).toString("base64url")}.signature`; writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: token("bob@example.test"), account_id: "shared-scope", id_token: token("alice@example.test") }, + tokens: { access_token: token("bob@example.test"), refresh_token: "opaque-main-refresh", account_id: "shared-scope", id_token: token("alice@example.test") }, })); seedPoolAccount(config, { id: "alice-login", email: "alice@example.test", chatgptAccountId: "shared-scope" }); const req = new Request("http://localhost/api/codex-auth/accounts/pause", { @@ -220,7 +220,7 @@ test("manual pause matches the selected workspace instead of the first organizat email: "same@example.test", organizations: [{ id: "other-membership" }], })).toString("base64url")}.signature`; writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: "opaque-main", account_id: "selected-workspace", id_token: idToken }, + tokens: { access_token: "opaque-main", refresh_token: "opaque-main-refresh", account_id: "selected-workspace", id_token: idToken }, })); seedPoolAccount(config, { id: "selected-login", email: "same@example.test", chatgptAccountId: "selected-workspace" }); const req = new Request("http://localhost/api/codex-auth/accounts/pause", { @@ -285,7 +285,7 @@ for (const mainState of ["oversized", "directory", "invalid-home", "api-key-malf } else if (mainState === "oversized") { // Valid ChatGPT JSON would succeed with the old unbounded reader. writeFileSync(authPath, JSON.stringify({ - tokens: { access_token: "opaque-main", account_id: "main-scope" }, + tokens: { access_token: "opaque-main", refresh_token: "opaque-main-refresh", account_id: "main-scope" }, padding: " ".repeat(MAX_AUTH_BYTES), })); } else { diff --git a/tests/codex-integration/codex-main-account-refresh.test.ts b/tests/codex-integration/codex-main-account-refresh.test.ts index 17ba7ac84fc..16954b35f50 100644 --- a/tests/codex-integration/codex-main-account-refresh.test.ts +++ b/tests/codex-integration/codex-main-account-refresh.test.ts @@ -3,11 +3,17 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, renameSync, writeFi import { tmpdir } from "node:os"; import { join } from "node:path"; import { + getMainAccountToken, getValidMainAccountToken, isMainAccountRefreshGrantRejected, setMainAuthJsonBeforeRenameHookForTests, } from "../../src/codex/main-account"; import { codexCredentialMutationEpoch } from "../../src/codex/credential-mutation-epoch"; +import { + getNativeMainAuthMemory, + observeNativeMainAuthFile, + stopNativeMainCredentialWatchers, +} from "../../src/codex/native-main-credentials"; import { removeTreeWithRetry } from "../helpers/remove-tree"; let home: string; @@ -18,6 +24,11 @@ function expiredJwt(): string { return `header.${payload}.signature`; } +function jwtWithExp(expSeconds: number): string { + const payload = Buffer.from(JSON.stringify({ exp: expSeconds })).toString("base64url"); + return `header.${payload}.signature`; +} + beforeEach(() => { home = mkdtempSync(join(tmpdir(), "ocx-main-refresh-")); previousCodexHome = process.env.CODEX_HOME; @@ -26,17 +37,19 @@ beforeEach(() => { afterEach(() => { setMainAuthJsonBeforeRenameHookForTests(null); + stopNativeMainCredentialWatchers(); if (previousCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; removeTreeWithRetry(home); }); describe("native main token refresh", () => { - test("refreshes a refresh-only auth file and atomically preserves unrelated fields", async () => { + test("refreshes a complete ChatGPT auth file and atomically preserves unrelated fields", async () => { const authPath = join(home, "auth.json"); const original = { auth_mode: "chatgpt", tokens: { + access_token: expiredJwt(), refresh_token: "old-refresh", account_id: "account-main", future_token_field: "preserve-token", @@ -110,7 +123,7 @@ describe("native main token refresh", () => { expect(token).toEqual({ accessToken: "new-access", chatgptAccountId: "account-main" }); expect(readFileSync(authPath, "utf8")).toBe(external); expect(readdirSync(home).filter(name => name.includes(".tmp"))).toEqual([]); - expect(codexCredentialMutationEpoch()).toBe(epochBefore); + expect(codexCredentialMutationEpoch()).toBe(epochBefore + 1); expect(isMainAccountRefreshGrantRejected()).toBe(false); }); @@ -254,7 +267,7 @@ describe("native main token refresh", () => { }); }); -describe("publication never overwrites an external Codex writer (#2999)", () => { +describe("publication adopts newer external Codex credentials (#2999)", () => { const refreshOk = async () => ({ access: "ocx-staged-access", refresh: "ocx-staged-refresh", @@ -289,10 +302,7 @@ describe("publication never overwrites an external Codex writer (#2999)", () => expect(readFileSync(authPath, "utf8")).not.toContain("ocx-staged-access"); }); - test("a same-bytes replacement with a new inode is still refused", async () => { - // The case a content hash cannot see. rename(2) replaces unconditionally, so the - // question that matters at the boundary is "is this the same FILE", not "does it hash - // the same" - an external writer that rewrote identical bytes still owns the target. + test("a same-bytes replacement with a new inode still refuses publication", async () => { const authPath = join(home, "auth.json"); seedExpired(authPath); const identical = readFileSync(authPath, "utf8"); @@ -310,25 +320,32 @@ describe("publication never overwrites an external Codex writer (#2999)", () => expect(readFileSync(authPath, "utf8")).not.toContain("ocx-staged-access"); }); - test("the canonical target survives a refused publication", async () => { - // A refusal must never leave the credential missing: losing auth.json is worse than - // losing the refresh, because Codex CLI then has nothing to authenticate with. + test("an access-token-only Codex write is a hiccup while the rotated grant stays in memory", async () => { const authPath = join(home, "auth.json"); seedExpired(authPath); + const external = JSON.stringify({ auth_mode: "chatgpt", tokens: { access_token: "other" } }); setMainAuthJsonBeforeRenameHookForTests(() => { - writeFileSync(authPath, JSON.stringify({ auth_mode: "chatgpt", tokens: { access_token: "other" } })); + writeFileSync(authPath, external); }); - await getValidMainAccountToken({ refreshToken: refreshOk }); + const token = await getValidMainAccountToken({ refreshToken: refreshOk }); + const memory = getNativeMainAuthMemory(authPath); + const memoryRaw = memory.snapshot?.raw.toString("utf8"); + memory.snapshot?.raw.fill(0); + memory.raw?.fill(0); + expect(token?.accessToken).toBe("ocx-staged-access"); + expect(memoryRaw).toContain("ocx-staged-refresh"); + expect(memory).toMatchObject({ fileStatus: "invalid", canRefreshOrWrite: false }); expect(existsSync(authPath)).toBe(true); + expect(readFileSync(authPath, "utf8")).toBe(external); expect(readdirSync(home).filter(name => name.startsWith("auth.json.")).length).toBe(0); }); test("a torn in-place rewrite is left alone and the next read sees the writer's file", async () => { // Codex rewrites auth.json in place (truncate, then write). A refresh that lands while the - // file is empty must not publish over it, must not mark the old grant rejected, and must - // not keep the rotated grant anywhere: the next read follows whatever Codex finished. + // file is empty must not publish over it; memory keeps the rotated grant during the hiccup + // and adopts Codex's finished file when the watcher observes it. const authPath = join(home, "auth.json"); seedExpired(authPath); const epochBefore = codexCredentialMutationEpoch(); @@ -338,7 +355,7 @@ describe("publication never overwrites an external Codex writer (#2999)", () => expect(token).toEqual({ accessToken: "ocx-staged-access", chatgptAccountId: "account-main" }); expect(readFileSync(authPath, "utf8")).toBe(""); - expect(codexCredentialMutationEpoch()).toBe(epochBefore); + expect(codexCredentialMutationEpoch()).toBe(epochBefore + 1); expect(readdirSync(home).filter(name => name.includes(".tmp"))).toEqual([]); const codexFinished = JSON.stringify({ @@ -346,6 +363,7 @@ describe("publication never overwrites an external Codex writer (#2999)", () => tokens: { access_token: "codex-finished-access", refresh_token: "codex-refresh", account_id: "account-main" }, }); writeFileSync(authPath, codexFinished); + observeNativeMainAuthFile(authPath); expect(isMainAccountRefreshGrantRejected()).toBe(false); const next = await getValidMainAccountToken({ refreshToken: async () => { throw new Error("must not refresh a fresh Codex token"); }, @@ -415,7 +433,7 @@ describe("publication never overwrites an external Codex writer (#2999)", () => expect(codexCredentialMutationEpoch()).toBe(epochBefore + 1); }); - test("an external writer during a cancelled exchange still refuses publication", async () => { + test("an external login during a cancelled exchange stays adopted before cancellation surfaces", async () => { const authPath = join(home, "auth.json"); seedExpired(authPath); const external = JSON.stringify({ @@ -437,7 +455,8 @@ describe("publication never overwrites an external Codex writer (#2999)", () => })).rejects.toBe(abortReason); expect(readFileSync(authPath, "utf8")).toBe(external); + expect(getMainAccountToken()?.accessToken).toBe("external-access"); expect(readdirSync(home).filter(name => name.includes(".tmp"))).toEqual([]); - expect(codexCredentialMutationEpoch()).toBe(epochBefore); + expect(codexCredentialMutationEpoch()).toBe(epochBefore + 1); }); }); diff --git a/tests/codex-integration/codex-main-grant-refusal.test.ts b/tests/codex-integration/codex-main-grant-refusal.test.ts index 1b3b6079edf..2bd19ff4186 100644 --- a/tests/codex-integration/codex-main-grant-refusal.test.ts +++ b/tests/codex-integration/codex-main-grant-refusal.test.ts @@ -1,4 +1,5 @@ import { sharedStateSelectionOptions } from "../../src/codex/routing/selection"; +import { writeNativeMainAuthFileForTests } from "../helpers/native-main-auth"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; @@ -37,7 +38,7 @@ const cfg = { providers: {}, codexAccounts: [], activeCodexAccountId: MAIN_CODEX function writeCredential(grant = "fixture-grant-a", target = home): string { const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 86_400 })).toString("base64url"); const access = `header.${payload}.signature`; - writeFileSync(join(target, "auth.json"), JSON.stringify({ + writeNativeMainAuthFileForTests(join(target, "auth.json"), JSON.stringify({ tokens: { access_token: access, refresh_token: grant, account_id: "fixture-main" }, })); return access; @@ -159,7 +160,7 @@ describe("native main refresh refusal is scoped to the physical path and grant", expect(codexAccountUnusableReason(cfg, MAIN_CODEX_ACCOUNT_ID)).toBeUndefined(); await expect(getValidMainAccountToken()).resolves.toMatchObject({ accessToken: replacement }); expect(readFileSync(join(home, "auth.json"), "utf8")).toBe(replacedBytes); - expect(codexCredentialMutationEpoch()).toBe(epoch); + expect(codexCredentialMutationEpoch()).toBe(epoch + 1); expect(grants).toEqual(["fixture-grant-a"]); // Returning to the old grant also proves the stale refusal never published a record. writeCredential("fixture-grant-a"); @@ -424,7 +425,7 @@ describe("native refresh success diagnostics require a valid credential", () => }); }); -test("main usability consumes one coherent physical snapshot under an ordinary reauth mark", () => { +test("main usability consumes one coherent memory snapshot under an ordinary reauth mark", () => { const access = writeCredential(); markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); const originalRead = fs.readFileSync as (...args: unknown[]) => unknown; @@ -435,13 +436,13 @@ test("main usability consumes one coherent physical snapshot under an ordinary r }) as typeof fs.readFileSync); try { expect(isMainAccountCredentialUsable()).toBe(true); - expect(authReads).toBe(1); + expect(authReads).toBe(0); authReads = 0; const reason = codexAccountUnusableReason(cfg, MAIN_CODEX_ACCOUNT_ID); - expect(reason).toBeUndefined(); + expect(reason).toBe("needs_reauth"); expect(warnings.join("\n")).not.toContain(access); expect(warnings.join("\n")).not.toContain("fixture-grant-a"); - expect(authReads).toBe(1); + expect(authReads).toBe(0); } finally { readSpy.mockRestore(); } diff --git a/tests/codex-integration/codex-main-rotation.test.ts b/tests/codex-integration/codex-main-rotation.test.ts index ffa98019c1e..868f29a92b4 100644 --- a/tests/codex-integration/codex-main-rotation.test.ts +++ b/tests/codex-integration/codex-main-rotation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { clearThreadAccountMap, @@ -16,13 +16,14 @@ import { isCodexAuthContextUsable, resolveCodexAuthContext, } from "../../src/codex/auth-context"; -import { isCodexAccountUsable } from "../../src/codex/account-usability"; +import { codexAccountUnusableReason, isCodexAccountUsable } from "../../src/codex/account-usability"; import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests, } from "../../src/codex/account-lifecycle"; -import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../../src/codex/main-account"; -import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { getMainAccountCredentialStatus, getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../../src/codex/main-account"; +import { refreshGrantFingerprintForToken, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearMainRefreshGrantRejection, markMainRefreshGrantRejected } from "../../src/codex/account-runtime-state"; import { clearAccountNeedsReauth, clearAccountQuota, @@ -36,6 +37,7 @@ import { } from "../../src/codex/auth-api"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { writeNativeMainAuthFileForTests } from "../helpers/native-main-auth"; const STORE_DIR = join(import.meta.dir, ".tmp-main-rotation-store"); const CODEX_DIR = join(import.meta.dir, ".tmp-main-rotation-codex"); @@ -44,9 +46,9 @@ let prevCodexHome: string | undefined; function writeMainAuth(): void { mkdirSync(CODEX_DIR, { recursive: true }); - writeFileSync( + writeNativeMainAuthFileForTests( join(CODEX_DIR, "auth.json"), - JSON.stringify({ tokens: { access_token: "main_access", account_id: "main_acct" } }), + JSON.stringify({ tokens: { access_token: "main_access", refresh_token: "main_refresh", account_id: "main_acct" } }), ); } @@ -117,13 +119,22 @@ describe("main account rotation (Option A)", () => { }); test("main account is not usable when auth.json is absent", () => { - rmSync(join(CODEX_DIR, "auth.json")); + process.env.CODEX_HOME = join(CODEX_DIR, "empty-home"); expect(isCodexAccountUsable(makeConfig(), MAIN_CODEX_ACCOUNT_ID)).toBe(false); }); - test("main account is not usable when flagged needs-reauth", () => { - markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); - expect(isCodexAccountUsable(makeConfig(), MAIN_CODEX_ACCOUNT_ID)).toBe(false); + test("main account is not usable when its refresh grant is rejected", () => { + const refreshToken = "main_refresh"; + const refreshGrantKey = `${join(CODEX_DIR, "auth.json")}:${refreshGrantFingerprintForToken(refreshToken)}`; + markMainRefreshGrantRejected(refreshGrantKey); + try { + expect(isCodexAccountUsable(makeConfig(), MAIN_CODEX_ACCOUNT_ID)).toBe(false); + expect(codexAccountUnusableReason(makeConfig(), MAIN_CODEX_ACCOUNT_ID)).toBe("needs_reauth"); + expect(getMainAccountCredentialStatus()).toMatchObject({ refreshGrantRejected: true, hasRefreshGrant: false }); + } finally { + clearMainRefreshGrantRejection(refreshGrantKey); + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + } }); test("quota auto-switch can move from a hot pool account onto the main account", () => { @@ -145,7 +156,7 @@ describe("main account rotation (Option A)", () => { }); test("main is excluded from rotation candidates when its token is missing", () => { - rmSync(join(CODEX_DIR, "auth.json")); + process.env.CODEX_HOME = join(CODEX_DIR, "empty-home"); const config = makeConfig(); updateAccountQuota("a", 90, 0); updateAccountQuota("b", 50, 0); @@ -182,9 +193,9 @@ describe("main account rotation (Option A)", () => { recordCodexUpstreamOutcome(config, MAIN_CODEX_ACCOUNT_ID, 429, { retryAfter: "3600" }); markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 100, 0); - writeFileSync( + writeNativeMainAuthFileForTests( join(CODEX_DIR, "auth.json"), - JSON.stringify({ tokens: { access_token: "replacement_access", account_id: "replacement_acct" } }), + JSON.stringify({ tokens: { access_token: "replacement_access", refresh_token: "replacement_refresh", account_id: "replacement_acct" } }), ); await expect(resolveCodexAuthContext(new Headers(), config, "pool")).resolves.toEqual({ @@ -214,6 +225,7 @@ describe("main account rotation (Option A)", () => { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", + upstreamWebsocket: false, }, }, }); @@ -229,9 +241,9 @@ describe("main account rotation (Option A)", () => { recordCodexUpstreamOutcome(config, MAIN_CODEX_ACCOUNT_ID, 429, { retryAfter: "3600" }); markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 100, 0); - writeFileSync( + writeNativeMainAuthFileForTests( join(CODEX_DIR, "auth.json"), - JSON.stringify({ tokens: { access_token: "replacement_access", account_id: "replacement_acct" } }), + JSON.stringify({ tokens: { access_token: "replacement_access", refresh_token: "replacement_refresh", account_id: "replacement_acct" } }), ); expect(reconcileMainCodexAccountRuntimeState()).toBe(true); @@ -269,9 +281,9 @@ describe("main account rotation (Option A)", () => { expect(reconcileMainCodexAccountRuntimeState()).toBe(false); expect((await fetchMainAccountInfo(false)).email).toBe("a@example.test"); email = "b@example.test"; - writeFileSync( + writeNativeMainAuthFileForTests( join(CODEX_DIR, "auth.json"), - JSON.stringify({ tokens: { access_token: "replacement_access", account_id: "replacement_acct" } }), + JSON.stringify({ tokens: { access_token: "replacement_access", refresh_token: "replacement_refresh", account_id: "replacement_acct" } }), ); expect(reconcileMainCodexAccountRuntimeState()).toBe(true); expect((await fetchMainAccountInfo(false)).email).toBe("b@example.test"); @@ -318,8 +330,8 @@ describe("main account rotation (Option A)", () => { expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.weeklyPercent).toBe(42); expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); - // 4. Routing still fails closed while the file cannot be read. - expect(isCodexAccountUsable(makeConfig(), MAIN_CODEX_ACCOUNT_ID)).toBe(false); + // 4. Requests keep using the last complete credential through a filesystem hiccup. + expect(isCodexAccountUsable(makeConfig(), MAIN_CODEX_ACCOUNT_ID)).toBe(true); // 5. Restoring the credential brings the same identity back without a spurious reauth. writeMainAuth(); @@ -358,9 +370,9 @@ describe("main account rotation (Option A)", () => { await firstUsageEntered.promise; expect(requestedAccountIds).toEqual(["main_acct"]); - writeFileSync( + writeNativeMainAuthFileForTests( join(CODEX_DIR, "auth.json"), - JSON.stringify({ tokens: { access_token: "replacement_access", account_id: "replacement_acct" } }), + JSON.stringify({ tokens: { access_token: "replacement_access", refresh_token: "replacement_refresh", account_id: "replacement_acct" } }), ); expect(reconcileMainCodexAccountRuntimeState()).toBe(true); resolveFirstUsage(new Response(JSON.stringify({ @@ -438,7 +450,7 @@ describe("main account rotation (Option A)", () => { }); test("no active id selects an added account when the main token is unavailable", async () => { - rmSync(join(CODEX_DIR, "auth.json")); + process.env.CODEX_HOME = join(CODEX_DIR, "empty-home"); const config = makeConfig({ activeCodexAccountId: undefined, autoSwitchThreshold: 0 }); updateAccountQuota("a", 10, 0); updateAccountQuota("b", 20, 0); @@ -447,21 +459,73 @@ describe("main account rotation (Option A)", () => { expect(config.activeCodexAccountId).toBe("a"); }); - test("no active id fails closed for expired, reauth-marked, or cooled main-only credentials", async () => { + test("an incomplete ChatGPT envelope stays unavailable under the complete-envelope rule (a)", async () => { const mainOnly = () => makeConfig({ activeCodexAccountId: undefined, autoSwitchThreshold: 0, codexAccounts: [] }); - const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) - 60 })).toString("base64url"); - writeFileSync(join(CODEX_DIR, "auth.json"), JSON.stringify({ + const incompleteHome = join(CODEX_DIR, "incomplete-home"); + mkdirSync(incompleteHome, { recursive: true }); + process.env.CODEX_HOME = incompleteHome; + const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64url"); + writeNativeMainAuthFileForTests(join(incompleteHome, "auth.json"), JSON.stringify({ + auth_mode: "chatgpt", tokens: { access_token: `header.${payload}.signature`, account_id: "main_acct" }, })); + expect(isCodexAccountUsable(mainOnly(), MAIN_CODEX_ACCOUNT_ID)).toBe(false); await expect(resolveCodexAuthContext(new Headers(), mainOnly(), "pool")) .rejects.toBeInstanceOf(CodexPoolAuthenticationError); + }); + test("an ordinary needs-reauth flag excludes a usable unrejected main grant", async () => { + const mainOnly = makeConfig({ activeCodexAccountId: undefined, autoSwitchThreshold: 0, codexAccounts: [] }); + process.env.CODEX_HOME = CODEX_DIR; writeMainAuth(); markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); - await expect(resolveCodexAuthContext(new Headers(), mainOnly(), "pool")) - .rejects.toBeInstanceOf(CodexPoolAuthenticationError); + try { + expect(getMainAccountCredentialStatus()).toMatchObject({ refreshGrantRejected: false, hasRefreshGrant: true, usable: true }); + expect(isCodexAccountUsable(mainOnly, MAIN_CODEX_ACCOUNT_ID)).toBe(false); + await expect(resolveCodexAuthContext(new Headers(), mainOnly, "pool")) + .rejects.toBeInstanceOf(CodexPoolAuthenticationError); + } finally { + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + } + }); - clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + test("an expired complete main credential reaches the refresh exchange", async () => { + const expiry = Math.floor(Date.now() / 1000) - 60; + const payload = Buffer.from(JSON.stringify({ + exp: expiry, + "https://api.openai.com/auth": { chatgpt_account_id: "main_acct" }, + })).toString("base64url"); + writeNativeMainAuthFileForTests(join(CODEX_DIR, "auth.json"), JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: `header.${payload}.signature`, + refresh_token: "main_refresh", + account_id: "main_acct", + }, + })); + let refreshCalls = 0; + const result = await getValidMainAccountToken({ + refreshToken: async refreshToken => { + refreshCalls++; + expect(refreshToken).toBe("main_refresh"); + return { + access: "fresh-main-access", + refresh: "fresh-main-refresh", + expires: Date.now() + 3600_000, + accountId: "main_acct", + }; + }, + }); + expect(refreshCalls).toBe(1); + expect(result).toEqual({ accessToken: "fresh-main-access", chatgptAccountId: "main_acct" }); + expect(JSON.parse(readFileSync(join(CODEX_DIR, "auth.json"), "utf8")).tokens.refresh_token) + .toBe("fresh-main-refresh"); + }); + + test("no active id fails closed for a cooled main-only credential", async () => { + const mainOnly = () => makeConfig({ activeCodexAccountId: undefined, autoSwitchThreshold: 0, codexAccounts: [] }); + process.env.CODEX_HOME = CODEX_DIR; + writeMainAuth(); recordCodexUpstreamOutcome(mainOnly(), MAIN_CODEX_ACCOUNT_ID, 429, { retryAfter: "60" }); await expect(resolveCodexAuthContext(new Headers(), mainOnly(), "pool")) .rejects.toBeInstanceOf(CodexPoolAuthenticationError); @@ -469,7 +533,7 @@ describe("main account rotation (Option A)", () => { test("active __main__ fails closed when the pool token vanishes", async () => { const config = makeConfig({ activeCodexAccountId: MAIN_CODEX_ACCOUNT_ID, autoSwitchThreshold: 0, codexAccounts: [] }); - rmSync(join(CODEX_DIR, "auth.json")); + process.env.CODEX_HOME = join(CODEX_DIR, "empty-home"); await expect(resolveCodexAuthContext(new Headers(), config, "pool")).rejects.toThrow( "no usable account credential", ); diff --git a/tests/codex-integration/codex-main-token-invalidated.test.ts b/tests/codex-integration/codex-main-token-invalidated.test.ts index bb57d586343..00d7f56dc9c 100644 --- a/tests/codex-integration/codex-main-token-invalidated.test.ts +++ b/tests/codex-integration/codex-main-token-invalidated.test.ts @@ -51,7 +51,7 @@ beforeEach(() => { setMainAccountPlan(null); resetMainCodexAccountIdentityTrackingForTests(); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), account_id: "acct-main" }, + tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), refresh_token: "fixture-main-refresh", account_id: "acct-main" }, })); }); @@ -155,7 +155,7 @@ test.each([ { status: 403, live: true, code: "permission_denied", reason: "refresh_failed" }, ])("reauth attribution follows terminal probe evidence: %j", async ({ status, live, code, reason }) => { writeFileSync(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ - tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + (live ? 3600 : -60)), account_id: "acct-main" }, + tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + (live ? 3600 : -60)), refresh_token: "fixture-main-refresh", account_id: "acct-main" }, })); markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); globalThis.fetch = (async () => Response.json({ error: { code } }, { status })) as typeof fetch; diff --git a/tests/codex-integration/codex-model-entitlements.test.ts b/tests/codex-integration/codex-model-entitlements.test.ts index d7589eb0e99..377cdfbaa9b 100644 --- a/tests/codex-integration/codex-model-entitlements.test.ts +++ b/tests/codex-integration/codex-model-entitlements.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { writeNativeMainAuthFileForTests } from "../helpers/native-main-auth"; import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -529,9 +530,10 @@ describe("ensureCodexEntitlementFreshness", () => { }; const writeMainAuth = (suffix: string): void => { - writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + writeNativeMainAuthFileForTests(join(codexHome, "auth.json"), JSON.stringify({ tokens: { access_token: `access-${suffix}`, + refresh_token: `refresh-${suffix}`, account_id: `chatgpt-${suffix}`, }, })); @@ -661,7 +663,7 @@ describe("ensureCodexEntitlementFreshness", () => { }); test("a same-identity main-token write invalidates a failed credential memo by epoch", async () => { - writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + writeNativeMainAuthFileForTests(join(codexHome, "auth.json"), JSON.stringify({ tokens: { access_token: "access-memo-same", refresh_token: "refresh-memo-same", @@ -701,7 +703,7 @@ describe("ensureCodexEntitlementFreshness", () => { }); test("a local write after absence observation fences stale negative-memo publication by epoch", async () => { - writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + writeNativeMainAuthFileForTests(join(codexHome, "auth.json"), JSON.stringify({ tokens: { access_token: "access-publication-local", refresh_token: "refresh-publication-local", @@ -855,7 +857,7 @@ describe("ensureCodexEntitlementFreshness", () => { }); test("a same-identity main-token write cannot join its pre-write roster flight", async () => { - writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + writeNativeMainAuthFileForTests(join(codexHome, "auth.json"), JSON.stringify({ tokens: { access_token: "access-same-identity", refresh_token: "refresh-same-identity", diff --git a/tests/codex-integration/codex-native-availability-nux.test.ts b/tests/codex-integration/codex-native-availability-nux.test.ts index 016ad8b32e0..5a8c4a85429 100644 --- a/tests/codex-integration/codex-native-availability-nux.test.ts +++ b/tests/codex-integration/codex-native-availability-nux.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, setDefaultTimeout, test } from "bun:test"; -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { writeNativeMainAuthFileForTests } from "../helpers/native-main-auth"; +import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { captureCatalogAdmissionSnapshot } from "../../src/codex/catalog-admission"; import { loadBundledCodexCatalog, resetCatalogRuntimeStateForTests, syncCatalogModels } from "../../src/codex/catalog"; @@ -16,6 +17,16 @@ import { type CodexModelEntitlementCredentialSnapshot, } from "../../src/codex/model-entitlements"; import { resetCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; +import { clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { setMainAccountPlan } from "../../src/codex/main-account"; +import { + initializeNativeMainStartupGate, + nativeMainStartupGateSnapshot, +} from "../../src/codex/native-profile-startup"; +import { stopNativeMainCredentialWatchers } from "../../src/codex/native-main-credentials"; +import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { resetOptionalShutdownHooksForTests } from "../../src/lib/optional-shutdown-hooks"; import { saveConfig } from "../../src/config"; import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; import type { OcxConfig } from "../../src/types"; @@ -121,16 +132,10 @@ test("hidden rows and failed or unlisted main rosters cannot retain a prompt", a expect(row.availability_nux).toBeNull(); }); -let home: TempHome | undefined; -let previousCliPath: string | undefined; -let previousFetch: typeof fetch; -let mainPrompt: unknown; -let poolPrompt: unknown; - function writerConfig(): OcxConfig { return { port: 10100, - providers: { openai: { adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward" } }, + providers: { openai: { adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward", upstreamWebsocket: false,} }, defaultProvider: "openai", codexAccounts: [{ id: "synthetic-pool", email: "pool@example.test", alias: "Pool", isMain: false }], codexAccountNamespaces: { desktop: "@main", team: "synthetic-pool" }, @@ -139,12 +144,12 @@ function writerConfig(): OcxConfig { }; } -function writtenRows(): RawEntry[] { - return (JSON.parse(readFileSync(join(home!.codexHome, "opencodex-catalog.json"), "utf8")) as RawCatalog).models ?? []; +function writtenRows(home: TempHome): RawEntry[] { + return (JSON.parse(readFileSync(join(home.codexHome, "opencodex-catalog.json"), "utf8")) as RawCatalog).models ?? []; } -function primeWriter(): void { - const runtimeScript = home!.path("codex-fixture.js"); +function primeWriter(home: TempHome): void { + const runtimeScript = home.path("codex-fixture.js"); const bundled = JSON.stringify({ models: [{ slug: "gpt-5.5", display_name: "GPT-5.5", description: "Fixture native", priority: 1, visibility: "list", base_instructions: "You are Codex.", @@ -155,7 +160,7 @@ function primeWriter(): void { 'if (process.argv.includes("--version")) console.log("codex-cli 0.145.0");', `else process.stdout.write(${JSON.stringify(bundled)});`, ].join("\n")); - const runtimeCommand = home!.path(process.platform === "win32" ? "codex-fixture.cmd" : "codex-fixture"); + const runtimeCommand = home.path(process.platform === "win32" ? "codex-fixture.cmd" : "codex-fixture"); writeFileSync(runtimeCommand, process.platform === "win32" ? `@echo off\r\n"${process.execPath}" "${runtimeScript}" %*\r\n` : `#!/bin/sh\nexec "${process.execPath}" "${runtimeScript}" "$@"\n`); @@ -166,7 +171,7 @@ function primeWriter(): void { expect(loadBundledCodexCatalog()?.models?.[0]?.slug).toBe("gpt-5.5"); } -async function writeWith(writer: "retained" | "convergence", config: OcxConfig): Promise { +async function writeWith(home: TempHome, writer: "retained" | "convergence", config: OcxConfig): Promise { saveConfig(config); if (writer === "retained") await syncCatalogModels(config); else { @@ -175,41 +180,57 @@ async function writeWith(writer: "retained" | "convergence", config: OcxConfig): if (gathered.kind !== "candidate") throw new Error("expected candidate"); expect((await commitCodexCatalogCandidate(gathered.candidate, 1_000)).kind).toBe("committed"); } - return writtenRows(); + return writtenRows(home); } test.each(["retained", "convergence"] as const)("%s writes and later clears main-only NUX in the on-disk catalog", async writer => { - previousCliPath = process.env.CODEX_CLI_PATH; - previousFetch = globalThis.fetch; - home = createTempHome("ocx-native-nux-"); - mkdirSync(home.codexHome); - writeFileSync(join(home.codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: "synthetic-main-token", account_id: "synthetic-main-account" }, - })); - saveCodexAccountCredential("synthetic-pool", { - accessToken: "synthetic-pool-token", refreshToken: "synthetic-pool-refresh", - expiresAt: Date.now() + 300_000, chatgptAccountId: "synthetic-pool-account", - }); - globalThis.fetch = (async (input, init) => { - const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url); - if (url.hostname === "chatgpt.com" && url.pathname.endsWith("/models")) { - const id = new Headers(init?.headers).get("chatgpt-account-id"); - return Response.json({ models: [rosterRow(WRITER_MODEL, - id === "synthetic-main-account" ? mainPrompt : poolPrompt)] }); - } - return previousFetch(input, init); - }) as typeof fetch; + const previousCliPath = process.env.CODEX_CLI_PATH; + const previousFetch = globalThis.fetch; + const home = createTempHome("ocx-native-nux-"); try { - mainPrompt = { message: "Main trial" }; - poolPrompt = { message: "Pool trial" }; - primeWriter(); + expect(readdirSync(home.root)).toEqual([]); + expect(existsSync(home.codexHome)).toBe(false); + mkdirSync(home.codexHome); + expect(readdirSync(home.codexHome)).toEqual([]); + stopNativeMainCredentialWatchers(); + resetOptionalShutdownHooksForTests(); + resetLifecycleDrainStateForTests(); + clearMainAccountInfoCache(); + resetMainCodexAccountIdentityTrackingForTests(); + setMainAccountPlan(null); + const startup = await initializeNativeMainStartupGate(); + expect(startup.status).toBe("ready"); + expect(startup).toEqual(nativeMainStartupGateSnapshot()); + let mainPrompt: unknown = { message: "Main trial" }; + const poolPrompt: unknown = { message: "Pool trial" }; + writeNativeMainAuthFileForTests(join(home.codexHome, "auth.json"), JSON.stringify({ + tokens: { + access_token: "synthetic-main-token", + refresh_token: "synthetic-main-refresh", + account_id: "synthetic-main-account", + }, + })); + saveCodexAccountCredential("synthetic-pool", { + accessToken: "synthetic-pool-token", refreshToken: "synthetic-pool-refresh", + expiresAt: Date.now() + 300_000, chatgptAccountId: "synthetic-pool-account", + }); + globalThis.fetch = (async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url); + if (url.hostname === "chatgpt.com" && url.pathname.endsWith("/models")) { + const id = new Headers(init?.headers).get("chatgpt-account-id"); + return Response.json({ models: [rosterRow(WRITER_MODEL, + id === "synthetic-main-account" ? mainPrompt : poolPrompt)] }); + } + return previousFetch(input, init); + }) as typeof fetch; + primeWriter(home); writeFileSync(join(home.codexHome, "opencodex-catalog.json"), JSON.stringify({ models: [{ slug: WRITER_MODEL, display_name: "Sol", visibility: "list", priority: 1, supported_reasoning_levels: [{ effort: "medium", description: "Medium" }], availability_nux: { message: "Old prompt" } }], })); const config = writerConfig(); - let rows = await writeWith(writer, config); + let rows = await writeWith(home, writer, config); expect(rows.find(row => row.slug === WRITER_MODEL)?.availability_nux).toEqual({ message: "Main trial" }); expect(JSON.stringify(rows)).not.toContain("Pool trial"); for (const slug of [`desktop/${WRITER_MODEL}`, `team/${WRITER_MODEL}`]) { @@ -217,7 +238,7 @@ test.each(["retained", "convergence"] as const)("%s writes and later clears main } mainPrompt = undefined; resetCodexModelEntitlementCacheForTests(); - rows = await writeWith(writer, config); + rows = await writeWith(home, writer, config); expect(rows.find(row => row.slug === WRITER_MODEL)?.availability_nux).toBeNull(); for (const slug of [`desktop/${WRITER_MODEL}`, `team/${WRITER_MODEL}`]) { expect(rows.find(row => row.slug === slug)).not.toHaveProperty("availability_nux"); @@ -226,7 +247,7 @@ test.each(["retained", "convergence"] as const)("%s writes and later clears main alias: WRITER_MODEL, nativeAlias: true, displayName: "Synthetic alias", targets: [{ provider: "openai", model: WRITER_MODEL }], } }; - rows = await writeWith(writer, config); + rows = await writeWith(home, writer, config); const alias = rows.find(row => row.slug === WRITER_MODEL); expect(alias?.opencodex_catalog_kind).toBe(CODEX_NATIVE_ALIAS_CATALOG_KIND); expect(alias).not.toHaveProperty("availability_nux"); @@ -234,9 +255,20 @@ test.each(["retained", "convergence"] as const)("%s writes and later clears main globalThis.fetch = previousFetch; if (previousCliPath === undefined) delete process.env.CODEX_CLI_PATH; else process.env.CODEX_CLI_PATH = previousCliPath; + resetCodexModelEntitlementCacheForTests(); + resetCatalogRuntimeStateForTests(); resetCodexRuntimeResolveCacheForTests(); + stopNativeMainCredentialWatchers(); + resetOptionalShutdownHooksForTests(); + resetLifecycleDrainStateForTests(); + clearMainAccountInfoCache(); + resetMainCodexAccountIdentityTrackingForTests(); + setMainAccountPlan(null); + await initializeNativeMainStartupGate({ + manager: { context: { homeId: null } } as never, + probeRecoveryState: () => "none", + }); home.remove(); - home = undefined; } }); diff --git a/tests/codex-integration/codex-plan.test.ts b/tests/codex-integration/codex-plan.test.ts index df02a8167e0..47d914c9006 100644 --- a/tests/codex-integration/codex-plan.test.ts +++ b/tests/codex-integration/codex-plan.test.ts @@ -117,6 +117,7 @@ describe("getMainAccountPlan JWT fallback", () => { writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: chatgptPlanJwt("pro", "acct-main-jwt"), + refresh_token: "main-plan-refresh", account_id: "acct-main-jwt", }, })); diff --git a/tests/codex-integration/codex-preserve-signed-in.test.ts b/tests/codex-integration/codex-preserve-signed-in.test.ts index d454b031440..7fc241f99c6 100644 --- a/tests/codex-integration/codex-preserve-signed-in.test.ts +++ b/tests/codex-integration/codex-preserve-signed-in.test.ts @@ -20,6 +20,7 @@ import { signedInCodexAccountIds } from "../../src/codex/account-preservation"; import { getEligiblePoolAccounts } from "../../src/codex/routing/selection"; import { rememberActiveCodexAccount } from "../../src/codex/routing/active-account"; import { initializeMainAccountPolicyBinding, reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { observeNativeMainAuthFile } from "../../src/codex/native-main-credentials"; import { acquireNativeMainProfileDrain, resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -30,7 +31,7 @@ let previousHome: string | undefined, previousCodex: string | undefined; const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "", stderr: "" }; function mainLogin(email = "member@example.test", accountId = "workspace-a") { const id_token = `h.${Buffer.from(JSON.stringify({ email, chatgpt_account_id: accountId })).toString("base64url")}.s`; - writeFileSync(authPath, JSON.stringify({ tokens: { access_token: "synthetic-main", id_token, account_id: accountId } })); + writeFileSync(authPath, JSON.stringify({ tokens: { access_token: "synthetic-main", refresh_token: "synthetic-refresh", id_token, account_id: accountId } })); } function config(strategy: OcxConfig["accountPoolStrategy"] = "quota"): OcxConfig { const c: OcxConfig = { ...getDefaultConfig(), autoSwitchThreshold: 80, @@ -118,13 +119,17 @@ test("same workspace different member and same member different workspace are in c.codexAccounts![1]!.email = "another@example.test"; saveCodexAccountCredential("other", { accessToken: "synthetic", refreshToken: "synthetic-r", expiresAt: Date.now() + 300_000, chatgptAccountId: "workspace-a" }); expect([...signedInCodexAccountIds(c)]).toEqual(["__main__", "copy"]); - mainLogin("member@example.test", "workspace-b"); reconcileMainCodexAccountRuntimeState(); + mainLogin("member@example.test", "workspace-b"); + observeNativeMainAuthFile(authPath); + reconcileMainCodexAccountRuntimeState(); expect([...signedInCodexAccountIds(c)]).toEqual(["__main__"]); }); test("owned login replacement and pool credential replacement update protected entries", async () => { const c = config(); expect((await (await api(c)).json()).affectedAccountIds).toEqual(["__main__", "copy"]); - mainLogin("other@example.test", "workspace-b"); reconcileMainCodexAccountRuntimeState(); + mainLogin("other@example.test", "workspace-b"); + observeNativeMainAuthFile(authPath); + reconcileMainCodexAccountRuntimeState(); expect([...signedInCodexAccountIds(c)]).toEqual(["__main__", "other"]); saveCodexAccountCredential("other", { accessToken: "replacement", refreshToken: "replacement-r", expiresAt: Date.now() + 300_000, chatgptAccountId: "workspace-c" }); expect([...signedInCodexAccountIds(c)]).toEqual(["__main__"]); @@ -163,12 +168,14 @@ test("config loads malformed policy safely without discarding providers", () => test("missing member or conflicting tokens never link an ordinary pool entry", async () => { const c = config(); - writeFileSync(authPath, JSON.stringify({ tokens: { access_token: "synthetic-opaque", account_id: "workspace-a" } })); + writeFileSync(authPath, JSON.stringify({ tokens: { access_token: "synthetic-opaque", refresh_token: "synthetic-refresh", account_id: "workspace-a" } })); + observeNativeMainAuthFile(authPath); reconcileMainCodexAccountRuntimeState(); expect([...signedInCodexAccountIds(c)]).toEqual(["__main__"]); expect((await (await api(c)).json()).identityKnown).toBe(false); const conflicting = `h.${Buffer.from(JSON.stringify({ email: "member@example.test", chatgpt_account_id: "workspace-x" })).toString("base64url")}.s`; - writeFileSync(authPath, JSON.stringify({ tokens: { access_token: "synthetic-opaque", id_token: conflicting, account_id: "workspace-a" } })); + writeFileSync(authPath, JSON.stringify({ tokens: { access_token: "synthetic-opaque", refresh_token: "synthetic-refresh", id_token: conflicting, account_id: "workspace-a" } })); + observeNativeMainAuthFile(authPath); reconcileMainCodexAccountRuntimeState(); expect([...signedInCodexAccountIds(c)]).toEqual(["__main__"]); expect((await api(c, { enabled: false })).status).toBe(503); @@ -178,7 +185,9 @@ test("confirmed main replacement retires main bindings before selecting new work const c = config(); c.codexPreserveSignedInAccount = false; c.activeCodexAccountId = "__main__"; setCodexAccountPin(c, "__main__"); reconcileMainCodexAccountRuntimeState(); expect(resolveCodexAccountForThread("native-existing", c)).toBe("__main__"); - mainLogin("other@example.test", "workspace-b"); reconcileMainCodexAccountRuntimeState(); + mainLogin("other@example.test", "workspace-b"); + observeNativeMainAuthFile(authPath); + reconcileMainCodexAccountRuntimeState(); c.codexPreserveSignedInAccount = true; delete c.activeCodexAccountPinned; expect(resolveCodexAccountForThread("native-existing", c)).toBe("copy"); }); @@ -201,6 +210,7 @@ test("healthy binding without a manual pin stays on its account until explicit t test("transient malformed native-login reads retain the last owned identity preference", () => { const c = config(); writeFileSync(authPath, "{"); + observeNativeMainAuthFile(authPath); reconcileMainCodexAccountRuntimeState(); expect([...signedInCodexAccountIds(c)]).toEqual(["__main__", "copy"]); expect(getEligiblePoolAccounts(c, undefined, Date.now(), undefined, { nativeMainSelectionOnly: true })).toEqual(["other"]); @@ -220,7 +230,8 @@ test("failed persistence keeps the previous policy, manual pin and live binding" test("conflicting token claims cannot retract an existing owned identity observation", () => { const c = config(); const id_token = `h.${Buffer.from(JSON.stringify({ email: "member@example.test", chatgpt_account_id: "workspace-x" })).toString("base64url")}.s`; - writeFileSync(authPath, JSON.stringify({ tokens: { access_token: "synthetic", id_token, account_id: "workspace-a" } })); + writeFileSync(authPath, JSON.stringify({ tokens: { access_token: "synthetic", refresh_token: "synthetic-refresh", id_token, account_id: "workspace-a" } })); + observeNativeMainAuthFile(authPath); reconcileMainCodexAccountRuntimeState(); expect([...signedInCodexAccountIds(c)]).toEqual(["__main__", "copy"]); }); diff --git a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts index 7af348306f5..8d71a807bef 100644 --- a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts +++ b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { writeNativeMainAuthFileForTests } from "../helpers/native-main-auth"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -33,6 +34,7 @@ let now: number; function config(): OcxConfig { return { defaultProvider: "openai", codexMainAccountHardLock: true, providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", + upstreamWebsocket: false, } }, codexAccounts: [], codexQuotaAutoRefresh: { [MAIN]: { fiveHour: true, weekly: true } } }; } @@ -43,7 +45,7 @@ function bearer(expired = false): string { } function writeMain(accessToken = bearer(), workspace = accountId): void { - writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { + writeNativeMainAuthFileForTests(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: accessToken, refresh_token: "fixture-refresh", account_id: workspace, } })); } @@ -164,7 +166,7 @@ describe("quota auto-refresh native-main admission", () => { expect(getNativeMainProfileRequestCount()).toBe(0); }); - test.each(["bearer", "workspace", "missing"] as const)( + test.each(["bearer", "workspace", "partial-write"] as const)( "%s replacement during main SSE cannot publish old quota or completion markers", async change => { const cfg = config(); const entered = deferred(); @@ -182,7 +184,7 @@ describe("quota auto-refresh native-main admission", () => { try { await Promise.race([entered.promise, run.then(() => { throw new Error("SSE was never reached"); })]); const workspace = change === "workspace" ? "fixture-replacement-workspace" : accountId; - if (change === "missing") writeFileSync(join(home, "auth.json"), "{}"); + if (change === "partial-write") writeNativeMainAuthFileForTests(join(home, "auth.json"), "{}"); else writeMain("fixture-replacement-token", workspace); reconcileMainCodexAccountRuntimeState(); const writer = captureMainQuotaWriter(workspace); @@ -195,10 +197,28 @@ describe("quota auto-refresh native-main admission", () => { controller.close(); await run; expect(calls).toEqual([responsesUrl]); - expect(getAccountQuota(MAIN)).toEqual(quotaBefore); - expect(getMainPolicyQuota()).toEqual(policyBefore); - expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastFiveHourResetAt).toBeUndefined(); - expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBeUndefined(); + if (change === "partial-write") { + const expectedQuota = { + shortPercent: 0, + shortWindowSeconds: 18_000, + shortResetAt: RESET_SECONDS + 18_000, + weeklyPercent: 0, + weeklyResetAt: RESET_SECONDS, + }; + expect(getAccountQuota(MAIN)).toMatchObject(expectedQuota); + expect(getMainPolicyQuota()).toMatchObject(expectedQuota); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]).toEqual({ + fiveHour: true, + weekly: true, + lastFiveHourResetAt: RESET_MILLISECONDS, + lastWeeklyResetAt: RESET_MILLISECONDS, + }); + } else { + expect(getAccountQuota(MAIN)).toEqual(quotaBefore); + expect(getMainPolicyQuota()).toEqual(policyBefore); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastFiveHourResetAt).toBeUndefined(); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBeUndefined(); + } expect(isAccountNeedsReauth(MAIN)).toBe(false); expect(getNativeMainProfileRequestCount()).toBe(0); } finally { @@ -353,12 +373,12 @@ describe("quota auto-refresh native-main admission", () => { expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(RESET_MILLISECONDS); }); - test.each(["bearer", "workspace", "missing", "policy", "pause", "reauth"] as const)("%s changing at shared-claim acquisition skips inference and markers", async change => { + test.each(["bearer", "workspace", "partial-write", "policy", "pause", "reauth"] as const)("%s changing at shared-claim acquisition respects the current credential state", async change => { const cfg = config(); const restore = interceptShared(() => { if (change === "bearer") writeMain("fixture-replacement-token"); else if (change === "workspace") writeMain(bearer(), "fixture-other-workspace"); - else if (change === "missing") writeFileSync(join(home, "auth.json"), "{}"); + else if (change === "partial-write") writeNativeMainAuthFileForTests(join(home, "auth.json"), "{}"); else if (change === "policy") observe(99); else if (change === "pause") cfg.pausedCodexAccountIds = [MAIN]; else markAccountNeedsReauth(MAIN); @@ -366,8 +386,17 @@ describe("quota auto-refresh native-main admission", () => { const calls = installFetch(async () => completedResponse()); try { await runCodexQuotaAutoRefresh(cfg, now, { persistCompleted: recordMarkers }); - expect(calls).toEqual([]); - expect(cfg.codexQuotaAutoRefresh?.[MAIN]).toEqual({ fiveHour: true, weekly: true }); + expect(calls).toEqual(change === "partial-write" ? [responsesUrl] : []); + if (change === "partial-write") { + expect(cfg.codexQuotaAutoRefresh?.[MAIN]).toEqual({ + fiveHour: true, + weekly: true, + lastFiveHourResetAt: RESET_MILLISECONDS, + lastWeeklyResetAt: RESET_MILLISECONDS, + }); + } else { + expect(cfg.codexQuotaAutoRefresh?.[MAIN]).toEqual({ fiveHour: true, weekly: true }); + } expect(getNativeMainProfileRequestCount()).toBe(0); } finally { restore(); } writeMain(); diff --git a/tests/codex-integration/codex-quota-prime.test.ts b/tests/codex-integration/codex-quota-prime.test.ts index 75bd5fbfd12..7a948fcdbd3 100644 --- a/tests/codex-integration/codex-quota-prime.test.ts +++ b/tests/codex-integration/codex-quota-prime.test.ts @@ -58,6 +58,7 @@ function makeConfig(overrides: Partial = {}): OcxConfig { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", + upstreamWebsocket: false, }, }, defaultProvider: "openai", @@ -89,7 +90,7 @@ function whamResponse(weekly: number) { function seedMainAccount(accountId = "main-account", accessToken = "main-access"): void { writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: accessToken, account_id: accountId }, + tokens: { access_token: accessToken, refresh_token: "main-refresh", account_id: accountId }, })); } @@ -519,9 +520,9 @@ describe("primeCodexPoolQuotas", () => { }; try { for (const providers of [ - { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const, codexAccountMode: "direct" as const } }, + { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const, codexAccountMode: "direct" as const, upstreamWebsocket: false,} }, { "openai-apikey": { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", apiKey: "sk-test" } }, - { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const, codexAccountMode: "pool" as const, disabled: true } }, + { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const, codexAccountMode: "pool" as const, disabled: true, upstreamWebsocket: false,} }, ]) { const cfg = makeConfig({ providers }); seedPoolAccount(cfg, `disabled-${calls}-${Object.keys(providers)[0]}`); diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index ce2f75438bc..97ab42cf36c 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -492,7 +492,7 @@ describe("codex routing", () => { test("missing OpenAI mode defaults to pool and rotates from hot main to a cool added account", () => { writeFileSync(join(TEST_DIR, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-access", account_id: "main-chatgpt-id" }, + tokens: { access_token: "main-access", refresh_token: "main-refresh", account_id: "main-chatgpt-id" }, })); const config = makeConfig({ providers: { @@ -571,7 +571,7 @@ describe("codex routing", () => { test("paused main account is excluded even when it is the active and lowest-usage candidate", () => { writeFileSync(join(TEST_DIR, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-access", account_id: "main-chatgpt-id" }, + tokens: { access_token: "main-access", refresh_token: "main-refresh", account_id: "main-chatgpt-id" }, })); const config = makeConfig({ codexAccounts: [{ id: "a", email: "a@test", isMain: false }], @@ -977,7 +977,7 @@ describe("codex routing", () => { // separate re-insertion branch. That branch is the only place an avoidance window can // exclude it, and it is the case the pool filters above cannot cover. writeFileSync(join(TEST_DIR, "auth.json"), JSON.stringify({ - tokens: { access_token: "main-access", account_id: "main-chatgpt-id" }, + tokens: { access_token: "main-access", refresh_token: "main-refresh", account_id: "main-chatgpt-id" }, })); const config = makeConfig({ codexAccounts: [{ id: "a", email: "a@test", isMain: false }], diff --git a/tests/codex-integration/injection-link-websocket.test.ts b/tests/codex-integration/injection-link-websocket.test.ts index 9b6abd61124..25bc9c0ad6c 100644 --- a/tests/codex-integration/injection-link-websocket.test.ts +++ b/tests/codex-integration/injection-link-websocket.test.ts @@ -11,7 +11,7 @@ function plan(source: string, target: ReturnType) { catalogPathOption: null, journalReadOnly: true, }); - expect(derived.kind).toBe("ok"); + expect(derived.kind === "ok" ? derived.kind : derived.message).toBe("ok"); if (derived.kind !== "ok") throw new Error("plan refused"); return derived; } diff --git a/tests/codex-integration/issue-452-empty-503.test.ts b/tests/codex-integration/issue-452-empty-503.test.ts index d8aa8049535..6a62e0023c8 100644 --- a/tests/codex-integration/issue-452-empty-503.test.ts +++ b/tests/codex-integration/issue-452-empty-503.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync} from "node:fs"; +import { existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { clearAccountNeedsReauth, clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api"; @@ -23,8 +24,36 @@ const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; const previousOpencodexHome = process.env.OPENCODEX_HOME; const previousOcxDebug = process.env.OCX_DEBUG; const originalGlobalFetch = globalThis.fetch; -const TEST_DIR = join(import.meta.dir, ".tmp-issue-452-empty-503"); let isolatedCodexHome: IsolatedCodexHome | null = null; +let opencodexHomes: string[] = []; +type CaseServer = { stop(closeActive?: boolean): void | Promise }; +const acquiredServers = new Set(); + +function trackCaseServer(server: T): T { + acquiredServers.add(server); + return server; +} + +function startTrackedServer(...args: Parameters): ReturnType { + return trackCaseServer(startServer(...args)); +} + +function startTrackedUpstream(options: Parameters[0]): ReturnType { + return trackCaseServer(Bun.serve(options)); +} + +async function stopCaseServer(server: CaseServer): Promise { + if (!acquiredServers.has(server)) return; + await server.stop(true); + acquiredServers.delete(server); +} + +function createOpencodexHome(): string { + const path = mkdtempSync(join(tmpdir(), "ocx-issue-452-")); + opencodexHomes.push(path); + process.env.OPENCODEX_HOME = path; + return path; +} function redirectCanonicalCodexTo(baseUrl: string): void { globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { @@ -40,13 +69,30 @@ function redirectCanonicalCodexTo(baseUrl: string): void { } beforeEach(() => { + if (globalThis.fetch !== originalGlobalFetch) throw new Error("SETUP: fetch must be restored before issue-452 cases"); + if (opencodexHomes.length !== 0) throw new Error("SETUP: issue-452 config homes must be removed before each case"); + if (isolatedCodexHome !== null) throw new Error("SETUP: the isolated Codex home must be restored before each case"); + if (process.env.OPENCODEX_HOME !== previousOpencodexHome + || process.env.OPENCODEX_API_AUTH_TOKEN !== previousApiToken + || process.env.OCX_DEBUG !== previousOcxDebug) { + throw new Error("SETUP: issue-452 environment must be restored before each case"); + } isolatedCodexHome = installIsolatedCodexHome("ocx-issue-452-"); resetDebugSettingsForTests(); resetDebugLogBufferForTests(); setDraining(false); }); -afterEach(() => { +afterEach(async () => { + let stopError: unknown; + for (const server of [...acquiredServers]) { + try { + await server.stop(true); + } catch (error) { + stopError ??= error; + } + } + acquiredServers.clear(); globalThis.fetch = originalGlobalFetch; setDraining(false); if (previousApiToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; @@ -63,7 +109,11 @@ afterEach(() => { clearThreadAccountMap(); clearAccountNeedsReauth("pool-a"); clearAccountQuota(); - if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + for (const home of opencodexHomes) { + if (existsSync(home)) removeTreeWithRetry(home); + } + opencodexHomes = []; + if (stopError) throw stopError; }); describe("formatPassthroughUpstreamError (#452)", () => { @@ -191,16 +241,14 @@ async function withPoolPassthrough( reply: (request: Request) => Response | Promise, run: (serverUrl: string) => Promise, ): Promise { - if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); - mkdirSync(TEST_DIR, { recursive: true }); - process.env.OPENCODEX_HOME = TEST_DIR; + createOpencodexHome(); delete process.env.OPENCODEX_API_AUTH_TOKEN; clearCodexUpstreamHealth(); clearThreadAccountMap(); clearAccountQuota(); clearAccountNeedsReauth("pool-a"); - const upstream = Bun.serve({ + const upstream = startTrackedUpstream({ port: 0, fetch(request) { return reply(request); @@ -218,6 +266,7 @@ async function withPoolPassthrough( baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", + upstreamWebsocket: false, }, }, codexAccounts: [ @@ -234,12 +283,12 @@ async function withPoolPassthrough( }); updateAccountQuota("pool-a", 10); - const server = startServer(0); + const server = startTrackedServer(0); try { await run(server.url.toString()); } finally { - await server.stop(true); - await upstream.stop(true); + await stopCaseServer(server); + await stopCaseServer(upstream); } } @@ -350,9 +399,7 @@ describe("passthrough empty 503 (#452)", () => { describe("drain 503 JSON (#452)", () => { test("POST /v1/responses while draining returns retryable JSON with listener-policy CORS (#6642)", async () => { - if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); - mkdirSync(TEST_DIR, { recursive: true }); - process.env.OPENCODEX_HOME = TEST_DIR; + createOpencodexHome(); process.env.OPENCODEX_API_AUTH_TOKEN = "drain-test-token"; const loopbackPort = await findAvailablePort(0, "127.0.0.1"); const publicPort = await findAvailablePort(0, "0.0.0.0", { reservedPort: loopbackPort }); @@ -371,7 +418,7 @@ describe("drain 503 JSON (#452)", () => { }, } as OcxConfig); - const server = startServer(publicPort); + const server = startTrackedServer(publicPort); try { setDraining(true); for (const port of [server.port, loopbackPort]) { @@ -397,7 +444,7 @@ describe("drain 503 JSON (#452)", () => { } } finally { setDraining(false); - await server.stop(true); + await stopCaseServer(server); } }, SERVER_BUDGET_MS); }); diff --git a/tests/codex-integration/issue-702-expired-replay-state.test.ts b/tests/codex-integration/issue-702-expired-replay-state.test.ts index a682fa6db88..3a100da9a33 100644 --- a/tests/codex-integration/issue-702-expired-replay-state.test.ts +++ b/tests/codex-integration/issue-702-expired-replay-state.test.ts @@ -19,6 +19,7 @@ import { } from "../../src/responses/state"; import { responseSpillDirectory } from "../../src/responses/spill-store"; import { startServer } from "../../src/server"; +import { spendLedgerOwnerSnapshot } from "../../src/lib/spend-ledger-owner"; import type { OcxConfig } from "../../src/types"; import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; @@ -26,8 +27,12 @@ import { INTERNAL_DEADLINE_MS, SERVER_BUDGET_MS } from "../helpers/test-budget"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const originalFetch = globalThis.fetch; +const originalDateNow = Date.now; const previousOpencodexHome = process.env.OPENCODEX_HOME; const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; +type CaseServer = ReturnType | ReturnType; +const caseServers: CaseServer[] = []; +const caseServerStops = new WeakMap>(); const REPLAY_TTL_MS = RESPONSE_TTL_MS; const EXPIRED_AGE_MS = REPLAY_TTL_MS + 60 * 60 * 1_000; const FIRST_RESPONSE_ID = "resp_issue_702_first"; @@ -78,11 +83,38 @@ function forwardConfig(): OcxConfig { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", + upstreamWebsocket: false, }, }, } as OcxConfig; } +function trackCaseServer(server: T): T { + caseServers.push(server); + return server; +} + +function stopCaseServer(server: CaseServer | null | undefined): Promise { + if (!server) return Promise.resolve(); + const existing = caseServerStops.get(server); + if (existing) return existing; + const stopping = Promise.resolve().then(() => server.stop(true)); + caseServerStops.set(server, stopping); + return stopping; +} + +async function stopCaseServers(): Promise { + let stopError: unknown; + for (const server of caseServers.splice(0).reverse()) { + try { + await stopCaseServer(server); + } catch (error) { + stopError ??= error; + } + } + if (stopError) throw stopError; +} + function inputMessage(text: string): Record { return { type: "message", @@ -178,35 +210,46 @@ async function runForwardScenario( let server: ReturnType | null = null; try { - upstream = Bun.serve({ - port: 0, - async fetch(request) { - const path = new URL(request.url).pathname; - const body = await request.json() as Record; - upstreamRequests.push({ path, body }); - const attempt = upstreamRequests.length; - const responseId = attempt === 1 ? FIRST_RESPONSE_ID : "resp_issue_702_resumed"; - const text = attempt === 1 ? HISTORICAL_ASSISTANT_SENTINEL : "resumed without prior context"; - return new Response(completedSse(responseId, text), { - headers: { "content-type": "text/event-stream" }, - }); - }, - }); - const upstreamUrl = upstream.url; + const captureUpstreamRequest = async (request: Request): Promise => { + const path = new URL(request.url).pathname; + const body = await request.json() as Record; + upstreamRequests.push({ path, body }); + const attempt = upstreamRequests.length; + const responseId = attempt === 1 ? FIRST_RESPONSE_ID : "resp_issue_702_resumed"; + const text = attempt === 1 ? HISTORICAL_ASSISTANT_SENTINEL : "resumed without prior context"; + const response = new Response(completedSse(responseId, text), { + headers: { "content-type": "text/event-stream" }, + }); + return response; + }; + if (mode !== "ordinary") upstream = trackCaseServer(Bun.serve({ port: 0, fetch: captureUpstreamRequest })); + const upstreamUrl = upstream?.url; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const raw = input instanceof Request ? input.url : String(input); const url = new URL(raw); const prefix = "/backend-api/codex"; if (url.hostname === "chatgpt.com" && url.pathname.startsWith(prefix)) { - const target = new URL(`${url.pathname.slice(prefix.length)}${url.search}`, upstreamUrl); - return originalFetch(target, init); + const path = url.pathname.slice(prefix.length); + if (mode === "ordinary") { + const body = JSON.parse(String(init?.body)) as Record; + upstreamRequests.push({ path, body }); + const attempt = upstreamRequests.length; + const responseId = attempt === 1 ? FIRST_RESPONSE_ID : "resp_issue_702_resumed"; + const text = attempt === 1 ? HISTORICAL_ASSISTANT_SENTINEL : "resumed without prior context"; + return new Response(completedSse(responseId, text), { + headers: { "content-type": "text/event-stream" }, + }); + } + const target = new URL(`${path}${url.search}`, upstreamUrl!); + const response = await originalFetch(target, init); + return response; } return originalFetch(input, init); }) as typeof fetch; saveConfig(forwardConfig()); - server = startServer(0); + server = trackCaseServer(startServer(0)); const token = fakeChatGptJwt({ chatgpt_account_id: "acct-issue-702" }); const requestHeaders = { "content-type": "application/json", @@ -216,7 +259,6 @@ async function runForwardScenario( let firstStatus = 0; try { - if (mode === "expired") Date.now = () => realNow() - EXPIRED_AGE_MS; const firstResponse = await originalFetch(new URL("/v1/responses", server.url), { method: "POST", headers: requestHeaders, @@ -229,11 +271,14 @@ async function runForwardScenario( }); firstStatus = firstResponse.status; await firstResponse.text(); - if (mode !== "ordinary") await waitForRecordedResponseState(); + if (mode !== "ordinary") { + await waitForRecordedResponseState(); + } } finally { Date.now = realNow; } + if (mode === "expired") Date.now = () => realNow() + EXPIRED_AGE_MS; const stateBeforeResume = responseStateMetrics(); if (mode === "ordinary") { return { @@ -269,15 +314,20 @@ async function runForwardScenario( Date.now = realNow; globalThis.fetch = originalFetch; try { - await server?.stop(true); + await stopCaseServer(server); } finally { - await upstream?.stop(true); + await stopCaseServer(upstream); } } } beforeEach(() => { + expect(globalThis.fetch).toBe(originalFetch); + expect(Date.now).toBe(originalDateNow); + expect(spendLedgerOwnerSnapshot()).toEqual({ ownership: "unheld" }); + expect(responseStateMetrics().count).toBe(0); testHome = mkdtempSync(join(tmpdir(), "ocx-issue-702-")); + expect(readdirSync(testHome)).toEqual([]); process.env.OPENCODEX_HOME = testHome; delete process.env.OPENCODEX_API_AUTH_TOKEN; clearResponseStateMemoryForTests(); @@ -285,19 +335,30 @@ beforeEach(() => { isolatedCodexHome = installIsolatedCodexHome("ocx-issue-702-codex-"); }); -afterEach(() => { +afterEach(async () => { + let stopError: unknown; + try { + await stopCaseServers(); + } catch (error) { + stopError = error; + } globalThis.fetch = originalFetch; + Date.now = originalDateNow; clearResponseStateForTests(); setResponseStateByteCapForTests(null); resetSubagentModelFallbackStateForTests(); isolatedCodexHome?.restore(); isolatedCodexHome = null; + expect(globalThis.fetch).toBe(originalFetch); + expect(Date.now).toBe(originalDateNow); + expect(spendLedgerOwnerSnapshot()).toEqual({ ownership: "unheld" }); if (testHome) removeTreeWithRetry(testHome); testHome = ""; if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOpencodexHome; if (previousApiToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiToken; + if (stopError) throw stopError; }); describe("routed replay recovery", () => { @@ -324,7 +385,7 @@ describe("routed replay recovery", () => { const tools = custom ? [{ type: "custom", name: "exec", description: "Run JavaScript", format: { type: "text" } }] : [{ type: "function", name: "lookup", parameters: { type: "object", properties: {} } }]; - const upstream = Bun.serve({ + const upstream = trackCaseServer(Bun.serve({ port: 0, async fetch(request) { const body = await request.json() as Record; @@ -339,7 +400,7 @@ describe("routed replay recovery", () => { headers: { "content-type": "text/event-stream" }, }); }, - }); + })); let server: ReturnType | null = null; let socket: WebSocket | null = null; try { @@ -364,7 +425,7 @@ describe("routed replay recovery", () => { }, }, } as OcxConfig); - server = startServer(0); + server = trackCaseServer(startServer(0)); const deltaRequest = { model: "routed-test/test-model", previous_response_id: FIRST_RESPONSE_ID, input: [toolResult], tools, store: false, @@ -418,8 +479,8 @@ describe("routed replay recovery", () => { } finally { Date.now = realNow; socket?.close(); - await server?.stop(true); - await upstream.stop(true); + await stopCaseServer(server); + await stopCaseServer(upstream); } }, SERVER_BUDGET_MS); @@ -436,7 +497,7 @@ describe("routed replay recovery", () => { id: "chatcmpl-routed", object: "chat.completion.chunk", created: 1, model: "test-model", choices: [{ index: 0, delta, finish_reason: finish }], })}\n\n`; - const upstream = Bun.serve({ + const upstream = trackCaseServer(Bun.serve({ port: 0, async fetch(request) { upstreamRequests.push(await request.json() as Record); @@ -445,7 +506,7 @@ describe("routed replay recovery", () => { { headers: { "content-type": "text/event-stream" } }, ); }, - }); + })); try { Date.now = () => realNow() - EXPIRED_AGE_MS; @@ -478,7 +539,7 @@ describe("routed replay recovery", () => { }, }, } as OcxConfig); - server = startServer(0); + server = trackCaseServer(startServer(0)); const refused = await originalFetch(new URL("/v1/responses", server.url), { method: "POST", @@ -514,8 +575,8 @@ describe("routed replay recovery", () => { expect(forwarded).toContain(CURRENT_USER_SENTINEL); } finally { Date.now = realNow; - await server?.stop(true); - await upstream.stop(true); + await stopCaseServer(server); + await stopCaseServer(upstream); } }, SERVER_BUDGET_MS); @@ -557,7 +618,7 @@ describe("routed replay recovery", () => { }, }, } as OcxConfig); - server = startServer(0); + server = trackCaseServer(startServer(0)); const response = await originalFetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "content-type": "application/json" }, @@ -576,7 +637,7 @@ describe("routed replay recovery", () => { } finally { Date.now = realNow; globalThis.fetch = originalFetch; - await server?.stop(true); + await stopCaseServer(server); } }, SERVER_BUDGET_MS, @@ -649,7 +710,7 @@ describe("Issue #702 expired forward replay state", () => { try { for (const routeClass of routeClasses) { saveConfig(routeClass.config); - const server = startServer(0); + const server = trackCaseServer(startServer(0)); try { const response = await originalFetch(new URL("/v1/responses", server.url), { method: "POST", @@ -664,7 +725,7 @@ describe("Issue #702 expired forward replay state", () => { expect(response.status).toBe(400); expect(await response.json()).toEqual({ error: LOCAL_REPLAY_UNAVAILABLE }); } finally { - await server.stop(true); + await stopCaseServer(server); } } expect(upstreamCalls).toBe(0); @@ -733,7 +794,7 @@ describe("Issue #702 expired forward replay state", () => { return originalFetch(input, init); }) as typeof fetch; saveConfig({ ...forwardConfig(), websockets: true }); - server = startServer(0); + server = trackCaseServer(startServer(0)); const headers = { authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "acct-issue-702" })}`, "chatgpt-account-id": "acct-issue-702", @@ -767,7 +828,7 @@ describe("Issue #702 expired forward replay state", () => { Date.now = realNow; globalThis.fetch = originalFetch; socket?.close(); - await server?.stop(true); + await stopCaseServer(server); } }, SERVER_BUDGET_MS); @@ -829,7 +890,7 @@ describe("Issue #702 expired forward replay state", () => { throw new Error("upstream must not be called"); }) as typeof fetch; saveConfig(forwardConfig()); - const server = startServer(0); + const server = trackCaseServer(startServer(0)); const token = fakeChatGptJwt({ chatgpt_account_id: "acct-issue-702" }); const ask = async (previousResponseId: string, threadId: string): Promise<{ status: number; body: string }> => { const response = await originalFetch(new URL("/v1/responses", server.url), { @@ -865,7 +926,7 @@ describe("Issue #702 expired forward replay state", () => { expect(mismatch.body).toBe(missing.body); expect(upstreamCalls).toBe(0); } finally { - await server.stop(true); + await stopCaseServer(server); } }); @@ -874,7 +935,7 @@ describe("Issue #702 expired forward replay state", () => { // the third way local replay becomes unusable, and it has to reach the client as the same // frame so Codex reconnects with its full input instead of terminating the task. const upstreamRequests: Record[] = []; - const upstream = Bun.serve({ + const upstream = trackCaseServer(Bun.serve({ port: 0, async fetch(request) { upstreamRequests.push(await request.json() as Record); @@ -882,7 +943,7 @@ describe("Issue #702 expired forward replay state", () => { headers: { "content-type": "text/event-stream" }, }); }, - }); + })); let server: ReturnType | null = null; let socket: WebSocket | null = null; try { @@ -901,7 +962,7 @@ describe("Issue #702 expired forward replay state", () => { }, }, } as OcxConfig); - server = startServer(0); + server = trackCaseServer(startServer(0)); socket = await openResponseSocket(server.url, { "x-codex-parent-thread-id": "task-b" }); const refused = await sendSocketTurn(socket, { model: "routed-test/test-model", previous_response_id: FIRST_RESPONSE_ID, @@ -923,8 +984,8 @@ describe("Issue #702 expired forward replay state", () => { expect(upstreamRequests[0]!.previous_response_id).toBeUndefined(); } finally { socket?.close(); - await server?.stop(true); - await upstream.stop(true); + await stopCaseServer(server); + await stopCaseServer(upstream); } }); @@ -947,7 +1008,7 @@ describe("Issue #702 expired forward replay state", () => { let upstream: ReturnType | null = null; let server: ReturnType | null = null; try { - upstream = Bun.serve({ + upstream = trackCaseServer(Bun.serve({ port: 0, async fetch(request) { upstreamRequests.push(await request.json() as Record); @@ -955,7 +1016,7 @@ describe("Issue #702 expired forward replay state", () => { headers: { "content-type": "text/event-stream" }, }); }, - }); + })); saveConfig({ port: 0, hostname: "127.0.0.1", @@ -971,7 +1032,7 @@ describe("Issue #702 expired forward replay state", () => { }, }, } as OcxConfig); - server = startServer(0); + server = trackCaseServer(startServer(0)); rememberResponseState( { input: [inputMessage(HISTORICAL_USER_SENTINEL)], store: false }, { id: FIRST_RESPONSE_ID, status: "completed", output: [] }, @@ -1004,8 +1065,8 @@ describe("Issue #702 expired forward replay state", () => { expect(upstreamRequests).toHaveLength(1); expect(upstreamRequests[0]!.previous_response_id).toBe("resp_never_seen_here"); } finally { - await server?.stop(true); - await upstream?.stop(true); + await stopCaseServer(server); + await stopCaseServer(upstream); } }); @@ -1016,7 +1077,7 @@ describe("Issue #702 expired forward replay state", () => { let server: ReturnType | null = null; try { - upstream = Bun.serve({ + upstream = trackCaseServer(Bun.serve({ port: 0, async fetch(request) { upstreamRequests.push(await request.json() as Record); @@ -1024,7 +1085,7 @@ describe("Issue #702 expired forward replay state", () => { headers: { "content-type": "text/event-stream" }, }); }, - }); + })); saveConfig({ port: 0, hostname: "127.0.0.1", @@ -1040,7 +1101,7 @@ describe("Issue #702 expired forward replay state", () => { }, }, } as OcxConfig); - server = startServer(0); + server = trackCaseServer(startServer(0)); const response = await originalFetch(new URL("/v1/responses", server.url), { method: "POST", @@ -1069,9 +1130,9 @@ describe("Issue #702 expired forward replay state", () => { Date.now = realNow; globalThis.fetch = originalFetch; try { - await server?.stop(true); + await stopCaseServer(server); } finally { - await upstream?.stop(true); + await stopCaseServer(upstream); } } }); diff --git a/tests/codex-integration/main-account-credit-renewal.test.ts b/tests/codex-integration/main-account-credit-renewal.test.ts index 10a26d01edc..960ce887306 100644 --- a/tests/codex-integration/main-account-credit-renewal.test.ts +++ b/tests/codex-integration/main-account-credit-renewal.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; +import { writeNativeMainAuthFileForTests } from "../helpers/native-main-auth"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -42,7 +43,7 @@ function bearer(id = accountId, expired = false): string { } function writeMain(id = accountId, expired = false): void { - writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { + writeNativeMainAuthFileForTests(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: bearer(id, expired), refresh_token: "fixture-refresh", account_id: id, } })); reconcileMainCodexAccountRuntimeState(); @@ -123,6 +124,7 @@ beforeEach(() => { activeCodexAccountId: MAIN, creditCodexAccountIds: [MAIN], codexAccounts: [], providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", + upstreamWebsocket: false, } } }; seed(); fetchWith(async () => usage()); 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 68c9a37d522..11f3ef7aadc 100644 --- a/tests/codex-integration/main-account-hard-lock-auth.test.ts +++ b/tests/codex-integration/main-account-hard-lock-auth.test.ts @@ -45,6 +45,7 @@ import { setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import type { OcxConfig } from "../../src/types"; import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmModuleGraph } from "../helpers/cold-spawn-warmup"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { writeNativeMainAuthFileForTests } from "../helpers/native-main-auth"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { helperPath, repoRoot } from "../helpers/repo-root"; import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; @@ -76,6 +77,7 @@ function config(): OcxConfig { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", + upstreamWebsocket: false, } }, codexAccounts: [], }; @@ -86,7 +88,7 @@ function accountZeroConfig(): OcxConfig { } function writeMain(token = bearer()): void { - writeFileSync(join(home, "auth.json"), JSON.stringify({ + writeNativeMainAuthFileForTests(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: token, refresh_token: "fixture-refresh", account_id: accountId }, })); reconcileMainCodexAccountRuntimeState(); @@ -240,6 +242,7 @@ describe("main quota policy at native admission", () => { if (scenario.startsWith("invalid-") || scenario === "mismatched-identity") { writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: scenario === "invalid-access-token" ? 17 : bearer(), + refresh_token: "fixture-refresh", account_id: scenario === "invalid-account-id" ? { invalid: true } : scenario === "mismatched-identity" ? "conflicting-physical-account" : accountId, ...(scenario === "invalid-id-token" ? { id_token: 17 } : {}), @@ -247,7 +250,7 @@ describe("main quota policy at native admission", () => { } 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, + access_token: restoredBearer, refresh_token: "fixture-restored-refresh", account_id: accountId, ...(scenario === "conflicting-token-identities" ? { id_token: bearer() } : {}), } })); } @@ -289,9 +292,10 @@ describe("main quota policy at native admission", () => { } 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); + // The central observer is the sole disk reader. A file with no string token never reaches + // the memory accessor; parsed credentials must still be read through the pinned path. + if (scenario === "invalid-access-token") expect(result.after.tokenReads).toBe(0); + else expect(result.after.tokenReads).toBeGreaterThan(0); } if (unowned || unverified) { expect(result.after).toMatchObject({ matched: false, policy: null }); @@ -627,6 +631,7 @@ describe("main quota policy at native admission", () => { cfg.providers["fixture-native"] = { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", + upstreamWebsocket: false, }; cfg.providers.independent = { adapter: "openai-responses", authMode: "key", diff --git a/tests/codex-integration/main-account-hard-lock-recovery.test.ts b/tests/codex-integration/main-account-hard-lock-recovery.test.ts index 72f7bac7a25..6095112c287 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { writeNativeMainAuthFileForTests } from "../helpers/native-main-auth"; import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -37,6 +38,7 @@ function config(): OcxConfig { return { port: 10100, defaultProvider: "openai", providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", + upstreamWebsocket: false, } }, codexMainAccountHardLock: true }; } @@ -51,7 +53,7 @@ function bearer(expired = false): string { /** Write fixture credentials into the isolated home and reconcile the active main identity. */ function writeMain(expired = false): void { - writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { + writeNativeMainAuthFileForTests(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: bearer(expired), refresh_token: "fixture-refresh", account_id: accountId, } })); reconcileMainCodexAccountRuntimeState(); @@ -349,7 +351,7 @@ describe("main hard-lock background recovery", () => { test("a replacement main credential does not inherit recovery backoff", async () => { const calls = fetchWith(async () => usage(99)); await runMainAccountHardLockRecovery(config()); - writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { + writeNativeMainAuthFileForTests(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: bearer() + "replacement", refresh_token: "fixture-new", account_id: accountId, } })); reconcileMainCodexAccountRuntimeState(); @@ -379,7 +381,7 @@ describe("main hard-lock background recovery", () => { const pending = fetchMainAccountInfoAttempt(true, 0); try { await started.promise; - writeFileSync(authPath, JSON.stringify(replacement)); + writeNativeMainAuthFileForTests(authPath, JSON.stringify(replacement)); expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); const info = structuredClone(getMainAccountInfoCache()); finish.resolve(); @@ -425,11 +427,11 @@ describe("main hard-lock background recovery", () => { try { await started.promise; if (transition !== "unchanged") { - writeFileSync(authPath, JSON.stringify(replacement)); + writeNativeMainAuthFileForTests(authPath, JSON.stringify(replacement)); // A newer read observes the bearer but fails, so it cannot advance the publication fence. expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); if (transition === "restored") { - writeFileSync(authPath, originalAuth); + writeNativeMainAuthFileForTests(authPath, originalAuth); expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); } } @@ -490,7 +492,7 @@ describe("main hard-lock background recovery", () => { const pending = listCodexAuthAccounts(config(), true); try { await started.promise; - writeFileSync(authPath, JSON.stringify(replacement)); + writeNativeMainAuthFileForTests(authPath, JSON.stringify(replacement)); expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); const cached = structuredClone(getMainAccountInfoCache()); finish.resolve(); @@ -530,14 +532,14 @@ describe("main hard-lock background recovery", () => { const pending = fetchMainAccountInfoSnapshot(true, config()); try { await started.promise; - if (transition === "replaced") writeFileSync(authPath, JSON.stringify(replacement)); - if (transition === "unreadable") writeFileSync(authPath, "{"); + if (transition === "replaced") writeNativeMainAuthFileForTests(authPath, JSON.stringify(replacement)); + if (transition === "unreadable") writeNativeMainAuthFileForTests(authPath, "{"); const cached = structuredClone(getMainAccountInfoCache()); const policy = getMainPolicyQuota(); finish.resolve(); const snapshot = await pending; expect(reads).toBe(1); - if (transition === "unchanged") { + if (transition !== "replaced") { if (status === 200) { expect(getMainAccountInfoCache()?.quota?.weeklyPercent).toBe(64); expect(getMainAccountHardLockStatus(config()).state).toBe("ready"); @@ -595,10 +597,10 @@ describe("main hard-lock background recovery", () => { try { await Promise.race([started.promise, pending.then(() => { throw new Error("Terminal WHAM never started"); })]); if (transition !== "unchanged") { - writeFileSync(authPath, JSON.stringify(replacement)); + writeNativeMainAuthFileForTests(authPath, JSON.stringify(replacement)); expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); if (transition === "restored") { - writeFileSync(authPath, originalAuth); + writeNativeMainAuthFileForTests(authPath, originalAuth); expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); } } @@ -632,7 +634,7 @@ describe("main hard-lock background recovery", () => { test.each([false, true])(`conflicting main tuple cannot publish ${status}, replacement=%s`, async replaced => { const authPath = join(home, "auth.json"); const valid = JSON.parse(readFileSync(authPath, "utf8")); - writeFileSync(authPath, JSON.stringify({ tokens: { ...valid.tokens, account_id: "fixture-other-header" } })); + writeNativeMainAuthFileForTests(authPath, JSON.stringify({ tokens: { ...valid.tokens, account_id: "fixture-other-header" } })); setMainAccountInfoCache({ email: null, plan: "plus", quota: { shortPercent: 99 }, ts: 1 }); if (status === 200) markAccountNeedsReauth(MAIN); const started = deferred(); @@ -651,7 +653,7 @@ describe("main hard-lock background recovery", () => { await Promise.race([started.promise, pending.then(() => { throw new Error("Conflicting WHAM never started"); })]); if (replaced) { valid.tokens.access_token += "-rotated"; - writeFileSync(authPath, JSON.stringify(valid)); + writeNativeMainAuthFileForTests(authPath, JSON.stringify(valid)); expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); } const info = structuredClone(getMainAccountInfoCache()); @@ -819,9 +821,9 @@ describe("main hard-lock background recovery", () => { } }); - test.each(["http", "transport", "metadata", "negative", "missing-token"] as const)("%s failure retains policy evidence", async kind => { + test.each(["http", "transport", "metadata", "negative", "deleted-during-grace"] as const)("%s failure retains policy evidence", async kind => { const retained = getMainPolicyQuota(); - if (kind === "missing-token") unlinkSync(join(home, "auth.json")); + if (kind === "deleted-during-grace") unlinkSync(join(home, "auth.json")); const calls = fetchWith(async () => { if (kind === "transport") throw new Error("fixture network failure"); if (kind === "http") return new Response(null, { status: 503 }); @@ -829,7 +831,7 @@ describe("main hard-lock background recovery", () => { return usage(-1); }); await runMainAccountHardLockRecovery(config()); - expect(calls).toEqual(kind === "missing-token" ? [] : [whamUrl]); + expect(calls).toEqual([whamUrl]); expect(getMainPolicyQuota()).toEqual(retained); expect(getMainAccountHardLockStatus(config()).state).toBe("blocked"); expect(isAccountNeedsReauth(MAIN)).toBe(false); diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index d8cb4b4fd65..7e893e06572 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -435,7 +435,7 @@ describe("main policy quota durability and lifecycle", () => { test("owned reconciliation publishes identity and confirmed transitions purge policy and equality", () => { writeFileSync(join(testDir, "auth.json"), JSON.stringify({ tokens: { - access_token: "fixture-bearer-a", account_id: "fixture-main-a", + access_token: "fixture-bearer-a", refresh_token: "fixture-refresh-a", account_id: "fixture-main-a", } })); expect(reconcileMainCodexAccountRuntimeState()).toBe(false); const writer = observeMainQuotaCredential("fixture-bearer-a", "fixture-main-a"); diff --git a/tests/codex-integration/main-quota-window-observation.test.ts b/tests/codex-integration/main-quota-window-observation.test.ts index 6bfdcb28c29..1f8ce5d02ec 100644 --- a/tests/codex-integration/main-quota-window-observation.test.ts +++ b/tests/codex-integration/main-quota-window-observation.test.ts @@ -80,7 +80,7 @@ describe("declared short-window producer evidence", () => { setIcaclsRunnerForTests(() => aclOk); setAsyncIcaclsRunnerForTests(async () => aclOk); writeFileSync(join(testDir, "auth.json"), JSON.stringify({ tokens: { - access_token: "fixture-main-token", account_id: "fixture-main-a", + access_token: "fixture-main-token", refresh_token: "fixture-main-refresh", account_id: "fixture-main-a", } })); let calls = 0; let invalid = true; @@ -165,7 +165,7 @@ describe("declared short-window producer evidence", () => { setIcaclsRunnerForTests(() => aclOk); setAsyncIcaclsRunnerForTests(async () => aclOk); writeFileSync(join(testDir, "auth.json"), JSON.stringify({ tokens: { - access_token: "fixture-main-token", account_id: "fixture-main-a", + access_token: "fixture-main-token", refresh_token: "fixture-main-refresh", account_id: "fixture-main-a", } })); const writer = writerFor(); const enabled = { codexMainAccountHardLock: true }; @@ -229,7 +229,7 @@ describe("declared short-window producer evidence", () => { setIcaclsRunnerForTests(() => aclOk); setAsyncIcaclsRunnerForTests(async () => aclOk); writeFileSync(join(testDir, "auth.json"), JSON.stringify({ tokens: { - access_token: "fixture-main-token", account_id: "fixture-main-a", + access_token: "fixture-main-token", refresh_token: "fixture-main-refresh", account_id: "fixture-main-a", } })); let calls = 0; globalThis.fetch = Object.assign(async (input: Parameters[0]) => { @@ -262,7 +262,7 @@ describe("declared short-window producer evidence", () => { setAsyncIcaclsRunnerForTests(async () => aclOk); const accessToken = "test-main"; writeFileSync(join(testDir, "auth.json"), JSON.stringify({ tokens: { - access_token: accessToken, account_id: "fixture-main-a", + access_token: accessToken, refresh_token: "fixture-main-refresh", account_id: "fixture-main-a", } })); let calls = 0; const stubFetch: typeof fetch = Object.assign(async ( diff --git a/tests/codex-integration/native-main-credential-literal-guard.test.ts b/tests/codex-integration/native-main-credential-literal-guard.test.ts new file mode 100644 index 00000000000..51a39da6ee9 --- /dev/null +++ b/tests/codex-integration/native-main-credential-literal-guard.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "bun:test"; +import { repoRoot } from "../helpers/repo-root"; + +const ROOT = repoRoot(); +const ALLOWED: string[] = [ + "src/cli/doctor.ts:325", + "src/codex/auth-collision.ts:36", + "src/codex/desktop-compatibility/runtime.ts:112", + "src/codex/home.ts:170", + "src/codex/main-account.ts:163", + "src/codex/main-account.ts:487", + "src/codex/native-profile-manager.ts:800", + "src/codex/native-profile-manager.ts:1172", + "src/codex/native-profile-store.ts:377", + "src/codex/orca-auth-source.ts:85", + "src/codex/orca-import.ts:145", + "src/codex/orca-import.ts:202", + "src/config.ts:220", + "src/lib/config-ownership.ts:41", + "src/oauth/command-code.ts:40", + "src/oauth/local-token-detect.ts:16", + "src/oauth/meta-muse.ts:33", + "src/oauth/store.ts:80", + "src/server/configured-credentials.ts:186", +]; + +const found: string[] = []; +for (const path of new Bun.Glob("src/**/*.{ts,mts,mjs}").scanSync({ cwd: ROOT, dot: true })) { + const lines = readFileSync(join(ROOT, path), "utf8").split("\n"); + lines.forEach((line, i) => { + if (line.includes('"auth.json"')) found.push(`${path.replaceAll("\\", "/")}:${i + 1}`); + }); +} +test("every auth.json literal line in src matches the file:line allowlist", () => { + expect([...found].sort()).toEqual([...ALLOWED].sort()); +}); diff --git a/tests/codex-integration/native-main-credential-memory.test.ts b/tests/codex-integration/native-main-credential-memory.test.ts new file mode 100644 index 00000000000..13b5fcc2e5f --- /dev/null +++ b/tests/codex-integration/native-main-credential-memory.test.ts @@ -0,0 +1,1548 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { closeSync, lstatSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, statSync, symlinkSync, truncateSync, unlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getMainAccountToken, getValidMainAccountToken, setMainAuthJsonBeforeRenameHookForTests } from "../../src/codex/main-account"; +import { readCodexTokensResult } from "../../src/codex/auth-collision"; +import { getNativeMainAuthMemory, observeNativeMainAuthFile, readNativeMainAuthDescriptorBounded, stopNativeMainCredentialWatchers } from "../../src/codex/native-main-credentials"; +import { MAX_AUTH_BYTES } from "../../src/codex/native-profile-store"; +import { resetOptionalShutdownHooksForTests, runOptionalShutdownHooks } from "../../src/lib/optional-shutdown-hooks"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { captureAtomicWritePublicationEvidence } from "../helpers/native-main-auth"; +import { repoPath } from "../helpers/repo-root"; +import { atomicWriteFile, atomicWriteFileAsync, type AtomicWriteAsyncIO, type AtomicWriteIO } from "../../src/config/atomic-write"; +import { getConfigPath, getDefaultConfig } from "../../src/config"; + +type StoreMemoryRead = { + status: string; + fileStatus?: string; + raw?: Buffer; + digest?: string; + observationError?: string; + graceActive?: boolean; + deletionConfirmed?: boolean; + snapshot?: { + raw: Buffer; + digest: string; + identity: { dev: number; ino: number }; + observationGeneration: number; + isSymlink: boolean; + }; +}; + +type StoreModule = { + NativeMainCredentialStore: new (options: { + authPath: string; + now?: () => number; + graceMs?: number; + fileSystem?: Record unknown>; + writeSyncIO?: { + write: (path: string, content: string) => void; + harden: (path: string) => void; + rename: (source: string, destination: string) => void; + truncate: (path: string) => void; + unlink: (path: string) => void; + }; + writeAsyncIO?: AtomicWriteAsyncIO; + claimExclusive?: (...args: any[]) => Promise; + }) => { + start(): void; + stop(): void; + observeNow(): unknown; + readMemory(): StoreMemoryRead; + canRefreshOrWrite(): boolean; + persistRefresh(raw: string, options: { + expectedSnapshot: { + digest: string; + identity: { dev: number; ino: number }; + observationGeneration: number; + }; + beforeRename?: () => void; + }): Promise; + writeAuthFileAsync(raw: string, options: { expectedSnapshot: ReturnType; beforeRename?: () => void }, writeOverride?: AtomicWriteAsyncIO): Promise; + }; + NATIVE_MAIN_AUTH_DELETE_GRACE_MS: number; + NATIVE_MAIN_AUTH_CHECK_INTERVAL_MS: number; + NATIVE_MAIN_AUTH_RETRY_INITIAL_MS: number; +}; + +let codexHome: string; +let opencodexHome: string; +let authPath: string; +let previousCodexHome: string | undefined; +let previousOcxHome: string | undefined; +let stores: Array<{ stop(): void }>; + +function auth(accessToken: string, refreshToken = "refresh-old", accountId = "account-main"): string { + return JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: accessToken, + refresh_token: refreshToken, + account_id: accountId, + }, + }); +} + +function captureFence(read: StoreMemoryRead) { + const snapshot = read.snapshot; + if (!snapshot) throw new Error("expected a complete native-main snapshot"); + return { + digest: snapshot.digest, + identity: { ...snapshot.identity }, + observationGeneration: snapshot.observationGeneration, + }; +} + +function token(expSeconds = Math.floor(Date.now() / 1000) + 3600, accountId = "account-main"): string { + const payload = Buffer.from(JSON.stringify({ + exp: expSeconds, + "https://api.openai.com/auth": { chatgpt_account_id: accountId }, + })).toString("base64url"); + return "header." + payload + ".signature"; +} + +async function loadStoreModule(): Promise { + return await import("../../src/codex/native-main-credentials") as unknown as StoreModule; +} + +beforeEach(() => { + codexHome = mkdtempSync(join(tmpdir(), "ocx-native-main-memory-codex-")); + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-native-main-memory-ocx-")); + authPath = join(codexHome, "auth.json"); + previousCodexHome = process.env.CODEX_HOME; + previousOcxHome = process.env.OPENCODEX_HOME; + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + stores = []; +}); + +afterEach(() => { + setMainAuthJsonBeforeRenameHookForTests(null); + for (const store of stores) store.stop(); + stopNativeMainCredentialWatchers(); + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOcxHome; + removeTreeWithRetry(codexHome); + removeTreeWithRetry(opencodexHome); +}); + +describe("native-main credential memory", () => { + test("the grace defaults to 60 seconds", async () => { + const store = await loadStoreModule(); + let now = 1_000; + let observe: (() => void) | undefined; + const memory = new store.NativeMainCredentialStore({ authPath, now: () => now, fileSystem: { + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: ((callback: () => void) => { observe = callback; return { unref() {} }; }) as never, + } }); + stores.push(memory); + writeFileSync(authPath, auth("access-good")); + memory.start(); + unlinkSync(authPath); + observe?.(); + expect(store.NATIVE_MAIN_AUTH_DELETE_GRACE_MS).toBe(60_000); + now = 60_999; + observe?.(); + const retained = memory.readMemory(); + expect(retained.raw?.toString()).toContain("access-good"); + retained.raw?.fill(0); + retained.snapshot?.raw.fill(0); + now = 61_000; + observe?.(); + const cleared = memory.readMemory(); + expect(cleared.status).toBe("missing"); + expect(cleared.raw).toBeUndefined(); + expect(cleared.deletionConfirmed).toBe(true); + cleared.snapshot?.raw.fill(0); + }); + + test("a configured grace replaces the default", async () => { + const store = await loadStoreModule(); + let now = 1_000; + let observe: (() => void) | undefined; + const memory = new store.NativeMainCredentialStore({ authPath, now: () => now, graceMs: 5_000, fileSystem: { + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: ((callback: () => void) => { observe = callback; return { unref() {} }; }) as never, + } }); + stores.push(memory); + writeFileSync(authPath, auth("access-good")); + memory.start(); + unlinkSync(authPath); + observe?.(); + now = 5_999; + observe?.(); + const retained = memory.readMemory(); + expect(retained.raw?.toString()).toContain("access-good"); + retained.raw?.fill(0); + retained.snapshot?.raw.fill(0); + now = 6_000; + observe?.(); + const cleared = memory.readMemory(); + expect(cleared.raw).toBeUndefined(); + expect(cleared.deletionConfirmed).toBe(true); + cleared.snapshot?.raw.fill(0); + }); + + test("graceActive reports the running grace for an incomplete file", async () => { + const store = await loadStoreModule(); + let observe: (() => void) | undefined; + const memory = new store.NativeMainCredentialStore({ authPath, fileSystem: { + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: ((callback: () => void) => { observe = callback; return { unref() {} }; }) as never, + } }); + stores.push(memory); + writeFileSync(authPath, auth("access-good")); + memory.start(); + writeFileSync(authPath, ""); + observe?.(); + const incomplete = memory.readMemory(); + expect(incomplete.graceActive).toBe(true); + incomplete.raw?.fill(0); + incomplete.snapshot?.raw.fill(0); + writeFileSync(authPath, auth("access-next")); + observe?.(); + const complete = memory.readMemory(); + expect(complete.graceActive).toBe(false); + expect(complete.raw?.toString()).toContain("access-next"); + complete.raw?.fill(0); + complete.snapshot?.raw.fill(0); + }); + + test("a read error or an incomplete file does not restart the grace", async () => { + const store = await loadStoreModule(); + let now = 1_000; + let failStat = false; + const memory = new store.NativeMainCredentialStore({ authPath, now: () => now, fileSystem: { + stat: ((path: string) => { + if (failStat) throw Object.assign(new Error("stat failure"), { code: "EIO" }); + return statSync(path); + }) as never, + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: (() => ({ unref() {} })) as never, + } }); + stores.push(memory); + writeFileSync(authPath, auth("access-good")); + memory.start(); + unlinkSync(authPath); + memory.observeNow(); + now = 31_000; + failStat = true; + memory.observeNow(); + failStat = false; + const unreadable = memory.readMemory(); + expect(unreadable.fileStatus).toBe("unreadable"); + expect(unreadable.raw?.toString()).toContain("access-good"); + unreadable.raw?.fill(0); + unreadable.snapshot?.raw.fill(0); + now = 46_000; + writeFileSync(authPath, "{\"tokens\":{\"access_token\":\"partial"); + memory.observeNow(); + const invalid = memory.readMemory(); + expect(invalid.fileStatus).toBe("invalid"); + expect(invalid.raw?.toString()).toContain("access-good"); + invalid.raw?.fill(0); + invalid.snapshot?.raw.fill(0); + now = 60_999; + unlinkSync(authPath); + memory.observeNow(); + const retained = memory.readMemory(); + expect(retained.raw?.toString()).toContain("access-good"); + retained.raw?.fill(0); + retained.snapshot?.raw.fill(0); + now = 61_000; + memory.observeNow(); + const cleared = memory.readMemory(); + expect(cleared.status).toBe("missing"); + expect(cleared.raw).toBeUndefined(); + cleared.snapshot?.raw.fill(0); + }); + + test("an empty or partial file past the grace clears memory, the retained grant and its retry", async () => { + const store = await loadStoreModule(); + for (const content of ["", "{\"tokens\":{\"access_token\":\"partial"] as const) { + let now = 1_000; + const timers: Array<() => void> = []; + let onDirectoryEvent: (() => void) | undefined; + let clearTimeoutCalls = 0; + let writeCalls = 0; + const memory = new store.NativeMainCredentialStore({ + authPath, + now: () => now, + fileSystem: { + watchDirectory: ((_: string, onEvent: () => void) => { + onDirectoryEvent = onEvent; + return { close() {}, unref() {} }; + }) as never, + setInterval: (() => ({ unref() {} })) as never, + setTimeout: ((callback: () => void) => { + timers.push(callback); + return { unref() {} }; + }) as never, + clearTimeout: (() => { clearTimeoutCalls++; }) as never, + }, + writeSyncIO: { + write: () => { writeCalls++; throw new Error("invalid file must not be replaced"); }, + harden: () => {}, rename: () => {}, truncate: () => {}, unlink: () => {}, + }, + }); + stores.push(memory); + writeFileSync(authPath, auth("access-old")); + memory.start(); + const initial = memory.readMemory(); + const expectedSnapshot = captureFence(initial); + initial.snapshot?.raw.fill(0); + initial.raw?.fill(0); + writeFileSync(authPath, content); + onDirectoryEvent?.(); + const first = await memory.persistRefresh(auth("rotated-access", "rotated-refresh"), { expectedSnapshot }); + expect(first).toMatchObject({ status: "retained" }); + expect(timers).toHaveLength(1); + now = 60_999; + memory.observeNow(); + const retained = memory.readMemory(); + expect(retained.raw?.toString()).toContain("rotated-refresh"); + retained.raw?.fill(0); + retained.snapshot?.raw.fill(0); + now = 61_000; + memory.observeNow(); + const cleared = memory.readMemory(); + expect(cleared.status).toBe("invalid"); + expect(cleared.fileStatus).toBe("invalid"); + expect(cleared.raw).toBeUndefined(); + expect(cleared.deletionConfirmed).toBe(true); + expect(clearTimeoutCalls).toBe(1); + cleared.snapshot?.raw.fill(0); + timers[0]?.(); + for (let i = 0; i < 12; i++) await Promise.resolve(); + expect(writeCalls).toBe(0); + const afterRetry = memory.readMemory(); + expect(afterRetry.raw).toBeUndefined(); + afterRetry.snapshot?.raw.fill(0); + const second = await memory.persistRefresh(auth("rotated-access", "rotated-refresh"), { expectedSnapshot }); + expect(second).toMatchObject({ status: "deleted" }); + memory.stop(); + } + }); + + test("a complete file inside the grace switches the login at once and ends the grace", async () => { + const store = await loadStoreModule(); + let now = 1_000; + const memory = new store.NativeMainCredentialStore({ authPath, now: () => now, fileSystem: { + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: (() => ({ unref() {} })) as never, + } }); + stores.push(memory); + writeFileSync(authPath, auth("access-a", "refresh-a", "account-a")); + memory.start(); + unlinkSync(authPath); + memory.observeNow(); + now = 60_000; + writeFileSync(authPath, auth("access-b", "refresh-b", "account-b")); + memory.observeNow(); + const switched = memory.readMemory(); + expect(switched.raw?.toString()).toContain("access-b"); + expect(switched.fileStatus).toBe("valid"); + expect(switched.graceActive).toBe(false); + expect(switched.deletionConfirmed).toBe(false); + switched.raw?.fill(0); + switched.snapshot?.raw.fill(0); + now = 100_000; + unlinkSync(authPath); + memory.observeNow(); + const restarted = memory.readMemory(); + expect(restarted.raw?.toString()).toContain("access-b"); + restarted.raw?.fill(0); + restarted.snapshot?.raw.fill(0); + now = 159_999; + memory.observeNow(); + const beforeExpiry = memory.readMemory(); + expect(beforeExpiry.raw?.toString()).toContain("access-b"); + beforeExpiry.raw?.fill(0); + beforeExpiry.snapshot?.raw.fill(0); + now = 160_000; + memory.observeNow(); + const expired = memory.readMemory(); + expect(expired.status).toBe("missing"); + expect(expired.raw).toBeUndefined(); + expired.snapshot?.raw.fill(0); + }); + + test("logout: a deleted auth.json with no new file clears the login after the configured grace", async () => { + writeFileSync(getConfigPath(), JSON.stringify({ ...getDefaultConfig(), nativeMainAuthGraceSeconds: 1 })); + writeFileSync(authPath, auth("access-good")); + expect(getMainAccountToken()?.accessToken).toBe("access-good"); + unlinkSync(authPath); + const first = observeNativeMainAuthFile(authPath); + expect(first.status).toBe("ok"); + first.raw?.fill(0); + first.snapshot?.raw.fill(0); + const retained = getMainAccountToken(); + expect(retained?.accessToken).toBe("access-good"); + await Bun.sleep(1_100); + const second = observeNativeMainAuthFile(authPath); + expect(second.status).toBe("missing"); + expect(second.raw).toBeUndefined(); + second.snapshot?.raw.fill(0); + expect(getMainAccountToken()).toBeNull(); + }); + + test("the native auth IO override stays under central synchronous evidence capture", async () => { + const storeModule = await loadStoreModule(); + const original = auth("access-old"); + const candidate = auth("access-override"); + writeFileSync(authPath, original, { mode: 0o600 }); + const memoryStore = new storeModule.NativeMainCredentialStore({ authPath }); + stores.push(memoryStore); + const initial = memoryStore.observeNow(); + const expectedSnapshot = captureFence(initial); + initial.snapshot?.raw.fill(0); + initial.raw?.fill(0); + let ctimeOnlyRewrite = false; + const writerOverride: AtomicWriteAsyncIO = { + write: (path, content) => writeFileSync(path, content, { mode: 0o600 }), + harden: () => {}, + rename: async (source, target, afterReplace) => { + renameSync(source, target); + await Bun.sleep(30); + const before = statSync(target); + writeFileSync(target, candidate); + utimesSync(target, before.atimeMs / 1000, before.mtimeMs / 1000); + const after = statSync(target); + ctimeOnlyRewrite = after.mtimeMs === before.mtimeMs && after.ctimeMs > before.ctimeMs; + afterReplace?.(); + }, + truncate: path => truncateSync(path, 0), + unlink: path => unlinkSync(path), + }; + + await expect(memoryStore.writeAuthFileAsync(candidate, { expectedSnapshot }, writerOverride)).rejects.toThrow( + "did not capture publication evidence synchronously", + ); + const observed = memoryStore.readMemory(); + expect(ctimeOnlyRewrite).toBe(true); + expect(readFileSync(authPath, "utf8")).toBe(candidate); + expect(observed.status).toBe("ok"); + observed.snapshot?.raw.fill(0); + observed.raw?.fill(0); + }); + + test("a stop between default writer evidence capture and delivery clears publication state", async () => { + const storeModule = await loadStoreModule(); + writeFileSync(authPath, auth("access-before-stop"), { mode: 0o600 }); + const memoryStore = new storeModule.NativeMainCredentialStore({ authPath }); + stores.push(memoryStore); + const initial = memoryStore.observeNow(); + const expectedSnapshot = captureFence(initial); + initial.snapshot?.raw.fill(0); + initial.raw?.fill(0); + + await expect(memoryStore.writeAuthFileAsync(auth("access-after-stop"), { + expectedSnapshot, + beforeRename: () => { + queueMicrotask(() => queueMicrotask(() => queueMicrotask(() => memoryStore.stop()))); + }, + })).rejects.toThrow("stopped"); + const stopped = memoryStore.readMemory(); + expect(stopped.status).toBe("stopped"); + expect(stopped.raw).toBeUndefined(); + expect(stopped.snapshot).toBeUndefined(); + expect(stopped.canRefreshOrWrite).toBe(false); + expect(stopped.canWriteFile).toBe(false); + }); + + test("an injected rename cannot capture evidence after an asynchronous ctime-only rewrite", async () => { + const content = auth("access-injected-rename"); + writeFileSync(authPath, content, { mode: 0o600 }); + let rewriteWasCtimeOnly = false; + let evidenceWasCaptured = false; + const io: AtomicWriteAsyncIO = { + write: (path, value) => writeFileSync(path, value, { mode: 0o600 }), + harden: () => {}, + rename: async (source, target, afterReplace) => { + renameSync(source, target); + await new Promise(resolve => setTimeout(resolve, 30)); + const before = statSync(target); + writeFileSync(target, content); + utimesSync(target, before.atimeMs / 1000, before.mtimeMs / 1000); + const rewritten = statSync(target); + rewriteWasCtimeOnly = rewritten.mtimeMs === before.mtimeMs && rewritten.ctimeMs > before.ctimeMs; + afterReplace?.(); + }, + truncate: path => truncateSync(path, 0), + unlink: path => unlinkSync(path), + }; + + await expect(atomicWriteFileAsync(authPath, content, io, undefined, { + afterRenameEvidence: () => { evidenceWasCaptured = true; }, + })).rejects.toThrow(); + expect(rewriteWasCtimeOnly).toBe(true); + expect(evidenceWasCaptured).toBe(false); + expect(readFileSync(authPath, "utf8")).toBe(content); + }); + + test("the synchronous writer refuses evidence from an injected asynchronous rename", async () => { + const content = auth("access-injected-sync-rename"); + writeFileSync(authPath, auth("access-old"), { mode: 0o600 }); + let evidenceWasCaptured = false; + let rewriteWasCtimeOnly = false; + let renameOperation: Promise | undefined; + const io: AtomicWriteIO = { + write: (path, value) => writeFileSync(path, value, { mode: 0o600 }), + harden: () => {}, + rename: ((source: string, target: string) => { + renameOperation = (async () => { + renameSync(source, target); + await new Promise(resolve => setTimeout(resolve, 30)); + const before = statSync(target); + writeFileSync(target, content); + utimesSync(target, before.atimeMs / 1000, before.mtimeMs / 1000); + const after = statSync(target); + rewriteWasCtimeOnly = after.mtimeMs === before.mtimeMs && after.ctimeMs > before.ctimeMs; + })(); + return renameOperation; + }) as unknown as AtomicWriteIO["rename"], + truncate: path => truncateSync(path, 0), + unlink: path => unlinkSync(path), + }; + + expect(() => atomicWriteFile(authPath, content, io, { + afterRenameEvidence: () => { evidenceWasCaptured = true; }, + })).toThrow("injected atomic rename did not capture publication evidence synchronously"); + await renameOperation; + expect(rewriteWasCtimeOnly).toBe(true); + expect(evidenceWasCaptured).toBe(false); + expect(readFileSync(authPath, "utf8")).toBe(content); + }); + + test("an injected rename cannot capture evidence before replacing the target", async () => { + const content = auth("access-injected-early-evidence"); + writeFileSync(authPath, auth("access-old"), { mode: 0o600 }); + let evidenceWasCaptured = false; + const io: AtomicWriteAsyncIO = { + write: (path, value) => writeFileSync(path, value, { mode: 0o600 }), + harden: () => {}, + rename: (source, target, afterReplace) => { + afterReplace?.(); + renameSync(source, target); + }, + truncate: path => truncateSync(path, 0), + unlink: path => unlinkSync(path), + }; + + await expect(atomicWriteFileAsync(authPath, content, io, undefined, { + afterRenameEvidence: () => { evidenceWasCaptured = true; }, + })).rejects.toThrow("injected atomic rename evidence preceded publication"); + expect(evidenceWasCaptured).toBe(false); + expect(readFileSync(authPath, "utf8")).toBe(content); + }); + + test("an empty Codex rewrite keeps serving the last good credential", () => { + writeFileSync(authPath, auth("access-good")); + expect(getMainAccountToken()?.accessToken).toBe("access-good"); + writeFileSync(authPath, ""); + const observed = observeNativeMainAuthFile(authPath); + expect(observed).toMatchObject({ status: "ok", fileStatus: "invalid" }); + observed.raw?.fill(0); + observed.snapshot?.raw.fill(0); + expect(getMainAccountToken()?.accessToken).toBe("access-good"); + }); + + test("a half-written Codex rewrite keeps serving the last good credential", () => { + writeFileSync(authPath, auth("access-good")); + expect(getMainAccountToken()?.accessToken).toBe("access-good"); + writeFileSync(authPath, "{\"tokens\":{\"access_token\":\"partial"); + const observed = observeNativeMainAuthFile(authPath); + expect(observed).toMatchObject({ status: "ok", fileStatus: "invalid" }); + observed.raw?.fill(0); + observed.snapshot?.raw.fill(0); + expect(getMainAccountToken()?.accessToken).toBe("access-good"); + }); + + test("deletion and restoration inside grace keeps the last good credential available", async () => { + const store = await loadStoreModule(); + const memoryStore = new store.NativeMainCredentialStore({ authPath }); + stores.push(memoryStore); + writeFileSync(authPath, auth("access-good")); + memoryStore.start(); + memoryStore.observeNow(); + expect(getMainAccountToken()?.accessToken).toBe("access-good"); + unlinkSync(authPath); + const missing = memoryStore.observeNow(); + expect(missing.graceActive).toBe(true); + expect(missing.canRefreshOrWrite).toBe(false); + expect(getMainAccountToken()?.accessToken).toBe("access-good"); + writeFileSync(authPath, auth("access-codex-new", "refresh-codex-new")); + memoryStore.observeNow(); + expect(JSON.parse(memoryStore.readMemory().raw!.toString()).tokens.refresh_token).toBe("refresh-codex-new"); + }); + + test("a directory watcher adopts a valid replacement immediately", async () => { + const store = await loadStoreModule(); + let onDirectoryEvent: (() => void) | undefined; + let watchedDirectory = ""; + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + fileSystem: { + watchDirectory: ((directory: string, onEvent: () => void) => { + watchedDirectory = directory; + onDirectoryEvent = onEvent; + return { close() {}, unref() {} }; + }) as never, + }, + }); + stores.push(memoryStore); + writeFileSync(authPath, auth("access-old")); + memoryStore.start(); + writeFileSync(authPath, auth("access-codex-new", "refresh-codex-new")); + onDirectoryEvent?.(); + expect(watchedDirectory).toBe(codexHome); + expect(memoryStore.readMemory().raw?.toString()).toContain("access-codex-new"); + }); + + test("the stat timer adopts a change when the directory event is dropped", async () => { + const store = await loadStoreModule(); + let onTimer: (() => void) | undefined; + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + fileSystem: { + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: ((callback: () => void, delayMs: number) => { + expect(delayMs).toBe(store.NATIVE_MAIN_AUTH_CHECK_INTERVAL_MS); + onTimer = callback; + return { unref() {} }; + }) as never, + }, + }); + stores.push(memoryStore); + writeFileSync(authPath, auth("access-old")); + memoryStore.start(); + writeFileSync(authPath, auth("access-codex-new", "refresh-codex-new")); + onTimer?.(); + expect(memoryStore.readMemory().raw?.toString()).toContain("access-codex-new"); + }); + + test("a pre-write observation adopts a valid newer Codex credential", async () => { + const store = await loadStoreModule(); + writeFileSync(authPath, auth(token(Math.floor(Date.now() / 1000) + 600))); + const memoryStore = new store.NativeMainCredentialStore({ authPath }); + stores.push(memoryStore); + memoryStore.start(); + const expected = memoryStore.readMemory(); + const expectedSnapshot = captureFence(expected); + const external = auth(token(Math.floor(Date.now() / 1000) + 7200), "refresh-codex-new"); + writeFileSync(authPath, external); + await memoryStore.persistRefresh(auth("refreshed-access", "rotated-refresh"), { + expectedSnapshot, + }); + expect(JSON.parse(memoryStore.readMemory().raw!.toString()).tokens.refresh_token).toBe("refresh-codex-new"); + expect(readFileSync(authPath, "utf8")).toBe(external); + }); + + test("a pre-write check hashes even when the stat signature is unchanged", async () => { + const store = await loadStoreModule(); + const original = auth("access-old"); + writeFileSync(authPath, original); + const stableStat = lstatSync(authPath); + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + fileSystem: { stat: (() => stableStat) as never }, + }); + stores.push(memoryStore); + memoryStore.start(); + const expected = memoryStore.readMemory(); + const expectedSnapshot = captureFence(expected); + expected.snapshot?.raw.fill(0); + expected.raw?.fill(0); + const external = auth("access-new", "refresh-new", "account-main"); + writeFileSync(authPath, external); + const outcome = await memoryStore.persistRefresh(auth("rotated-access", "rotated-refresh"), { + expectedSnapshot, + }); + const adopted = memoryStore.readMemory(); + const adoptedRaw = adopted.raw!.toString(); + adopted.snapshot?.raw.fill(0); + adopted.raw?.fill(0); + expect(outcome).toMatchObject({ status: "superseded" }); + expect(adoptedRaw).toContain('"refresh_token":"refresh-new"'); + expect(readFileSync(authPath, "utf8")).toBe(external); + }); + + test("an observed external write at final validation aborts the stale staged bytes", async () => { + const store = await loadStoreModule(); + const original = JSON.stringify({ auth_mode: "chatgpt", tokens: { + access_token: "old-access", + refresh_token: "old-refresh", + account_id: "account-main", + } }); + const external = JSON.stringify({ auth_mode: "chatgpt", tokens: { + access_token: "codex-access", + refresh_token: "codex-refresh", + account_id: "account-main", + }, external_marker: "preserve" }); + let beforeRenameCalls = 0; + let writeAttempts = 0; + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + fileSystem: { + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: (() => ({ unref() {} })) as never, + }, + writeSyncIO: { + write: (path, content) => { + writeAttempts++; + writeFileSync(path, content, { mode: 0o600 }); + }, + harden: () => {}, + rename: (source, target) => renameSync(source, target), + truncate: path => truncateSync(path, 0), + unlink: path => unlinkSync(path), + }, + }); + stores.push(memoryStore); + writeFileSync(authPath, original); + memoryStore.start(); + const expected = memoryStore.readMemory(); + const expectedSnapshot = captureFence(expected); + expected.snapshot?.raw.fill(0); + expected.raw?.fill(0); + + const result = await memoryStore.persistRefresh(auth("ocx-access", "ocx-refresh"), { + expectedSnapshot, + beforeRename: () => { + beforeRenameCalls++; + if (beforeRenameCalls === 1) writeFileSync(authPath, external); + }, + }); + const observed = memoryStore.readMemory(); + const observedRaw = observed.raw!.toString("utf8"); + observed.snapshot?.raw.fill(0); + observed.raw?.fill(0); + + expect(result).toMatchObject({ status: "superseded" }); + expect(beforeRenameCalls).toBe(1); + expect(writeAttempts).toBe(1); + expect(readFileSync(authPath, "utf8")).toBe(external); + expect(observedRaw).toBe(external); + }); + + test("an API-key login supersedes a retained refresh and is adopted without a watcher throw", async () => { + const store = await loadStoreModule(); + let onDirectoryEvent: (() => void) | undefined; + let scheduledDelay = 0; + const original = auth("old-access"); + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + fileSystem: { + watchDirectory: ((_: string, onEvent: () => void) => { + onDirectoryEvent = onEvent; + return { close() {}, unref() {} }; + }) as never, + setInterval: (() => ({ unref() {} })) as never, + setTimeout: ((_: () => void, delay: number) => { + scheduledDelay = delay; + return {}; + }) as never, + }, + writeSyncIO: { + write: () => { throw Object.assign(new Error("disk unavailable"), { code: "EIO" }); }, + harden: () => {}, rename: () => {}, truncate: () => {}, unlink: () => {}, + }, + }); + stores.push(memoryStore); + writeFileSync(authPath, original); + memoryStore.start(); + const expected = memoryStore.readMemory(); + const expectedSnapshot = captureFence(expected); + expected.snapshot?.raw.fill(0); + expected.raw?.fill(0); + await memoryStore.persistRefresh(auth("rotated-access", "rotated-refresh"), { + expectedSnapshot, + }); + expect(scheduledDelay).toBe(store.NATIVE_MAIN_AUTH_RETRY_INITIAL_MS); + + const external = JSON.stringify({ auth_mode: "apikey", OPENAI_API_KEY: "codex-api-key" }); + writeFileSync(authPath, external); + expect(() => onDirectoryEvent?.()).not.toThrow(); + const observed = memoryStore.readMemory(); + const observedRaw = observed.raw!.toString("utf8"); + observed.snapshot?.raw.fill(0); + observed.raw?.fill(0); + expect(observedRaw).toBe(external); + expect(readFileSync(authPath, "utf8")).toBe(external); + }); + + test("an access-only ChatGPT envelope is a hiccup and a later complete login wins", async () => { + const store = await loadStoreModule(); + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + fileSystem: { + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: (() => ({ unref() {} })) as never, + }, + writeSyncIO: { + write: () => { throw Object.assign(new Error("disk unavailable"), { code: "EIO" }); }, + harden: () => {}, rename: () => {}, truncate: () => {}, unlink: () => {}, + }, + }); + stores.push(memoryStore); + const original = auth("access-old", "refresh-old"); + writeFileSync(authPath, original); + memoryStore.start(); + const expected = memoryStore.readMemory(); + const expectedSnapshot = captureFence(expected); + expected.snapshot?.raw.fill(0); + expected.raw?.fill(0); + await memoryStore.persistRefresh(auth("rotated-access", "rotated-refresh"), { expectedSnapshot }); + + const partial = JSON.stringify({ auth_mode: "chatgpt", tokens: { access_token: "other" } }); + writeFileSync(authPath, partial); + memoryStore.observeNow(); + + const hiccup = memoryStore.readMemory(); + const hiccupRaw = hiccup.snapshot?.raw.toString("utf8"); + hiccup.snapshot?.raw.fill(0); + hiccup.raw?.fill(0); + expect(hiccup).toMatchObject({ status: "ok", fileStatus: "invalid", canRefreshOrWrite: false }); + expect(hiccupRaw).toContain("rotated-refresh"); + expect(readFileSync(authPath, "utf8")).toBe(partial); + + const complete = auth("codex-access", "codex-refresh"); + writeFileSync(authPath, complete); + memoryStore.observeNow(); + const adopted = memoryStore.readMemory(); + const adoptedRaw = adopted.snapshot?.raw.toString("utf8"); + adopted.snapshot?.raw.fill(0); + adopted.raw?.fill(0); + expect(adopted).toMatchObject({ status: "ok", fileStatus: "valid", canRefreshOrWrite: true }); + expect(adoptedRaw).toBe(complete); + }); + + test("a same-byte replacement observed during refresh invalidates the original publication snapshot", async () => { + const store = await loadStoreModule(); + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + fileSystem: { + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: (() => ({ unref() {} })) as never, + }, + }); + stores.push(memoryStore); + const original = auth("access-old", "refresh-old"); + writeFileSync(authPath, original); + memoryStore.start(); + const expected = memoryStore.readMemory(); + const expectedSnapshot = captureFence(expected); + const expectedIdentity = expected.identity; + expected.snapshot?.raw.fill(0); + expected.raw?.fill(0); + + const swapPath = join(codexHome, "same-bytes-auth.json"); + writeFileSync(swapPath, original); + unlinkSync(authPath); + renameSync(swapPath, authPath); + memoryStore.observeNow(); + + const outcome = await memoryStore.persistRefresh(auth("rotated-access", "rotated-refresh"), { + expectedSnapshot, + }); + const observed = memoryStore.readMemory(); + const observedRaw = observed.snapshot?.raw.toString("utf8"); + observed.snapshot?.raw.fill(0); + observed.raw?.fill(0); + expect(outcome).toMatchObject({ status: "superseded" }); + expect(observed.identity).not.toEqual(expectedIdentity); + expect(observedRaw).toBe(original); + expect(readFileSync(authPath, "utf8")).toBe(original); + }); + + test("an observer read failure stays visible and the next timer retries adoption", async () => { + const store = await loadStoreModule(); + let onDirectoryEvent: (() => void) | undefined; + let onTimer: (() => void) | undefined; + let failNextRead = false; + let readCalls = 0; + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + fileSystem: { + read: ((path: string) => { + readCalls++; + if (failNextRead) { + failNextRead = false; + throw Object.assign(new Error("temporary read failure"), { code: "EIO" }); + } + return readFileSync(path); + }) as never, + watchDirectory: ((_: string, onEvent: () => void) => { + onDirectoryEvent = onEvent; + return { close() {}, unref() {} }; + }) as never, + setInterval: ((callback: () => void) => { + onTimer = callback; + return { unref() {} }; + }) as never, + }, + }); + stores.push(memoryStore); + writeFileSync(authPath, auth("access-before")); + memoryStore.start(); + writeFileSync(authPath, auth("access-after", "refresh-after")); + failNextRead = true; + expect(() => onDirectoryEvent?.()).not.toThrow(); + expect(memoryStore.readMemory()).toMatchObject({ status: "ok", fileStatus: "unreadable" }); + const failedReadCount = readCalls; + + onTimer?.(); + + expect(readCalls).toBe(failedReadCount + 1); + expect(memoryStore.readMemory()).toMatchObject({ status: "ok", fileStatus: "valid" }); + expect(memoryStore.readMemory().raw?.toString()).toContain("access-after"); + }); + + test("a symlinked auth.json continues to serve and observe its regular target", async () => { + const store = await loadStoreModule(); + const targetPath = join(codexHome, "managed-auth.json"); + writeFileSync(targetPath, auth("symlink-access")); + symlinkSync(targetPath, authPath); + let onDirectoryEvent: (() => void) | undefined; + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + fileSystem: { + watchDirectory: ((_: string, onEvent: () => void) => { + onDirectoryEvent = onEvent; + return { close() {}, unref() {} }; + }) as never, + setInterval: (() => ({ unref() {} })) as never, + }, + }); + stores.push(memoryStore); + memoryStore.start(); + expect(memoryStore.readMemory().raw?.toString()).toContain("symlink-access"); + expect(readCodexTokensResult(authPath).status).toBe("ok"); + expect(readCodexTokensResult(authPath, { bounded: true }).status).toBe("unreadable"); + + writeFileSync(targetPath, auth("symlink-replacement", "symlink-refresh")); + expect(() => onDirectoryEvent?.()).not.toThrow(); + expect(memoryStore.readMemory().raw?.toString()).toContain("symlink-replacement"); + }); + + test("bounded Codex token reads reject a symlinked auth.json", async () => { + const targetPath = join(codexHome, "bounded-managed-auth.json"); + writeFileSync(targetPath, auth("bounded-symlink-access")); + symlinkSync(targetPath, authPath); + + expect(readCodexTokensResult(authPath).status).toBe("ok"); + expect(readCodexTokensResult(authPath, { bounded: true }).status).toBe("unreadable"); + }); + + test("a confirmed deletion clears memory and cannot be written back", async () => { + const store = await loadStoreModule(); + let now = 1_000; + let onTimer: (() => void) | undefined; + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + now: () => now, + fileSystem: { + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: ((callback: () => void) => { + onTimer = callback; + return { unref() {} }; + }) as never, + }, + }); + stores.push(memoryStore); + writeFileSync(authPath, auth("access-good")); + memoryStore.start(); + const previous = memoryStore.readMemory(); + const expectedSnapshot = captureFence(previous); + unlinkSync(authPath); + onTimer?.(); + expect(memoryStore.readMemory().raw?.toString()).toContain("access-good"); + expect(memoryStore.canRefreshOrWrite()).toBe(false); + now += store.NATIVE_MAIN_AUTH_DELETE_GRACE_MS + 1; + onTimer?.(); + expect(memoryStore.readMemory().status).toBe("missing"); + expect(memoryStore.readMemory().raw).toBeUndefined(); + const outcome = await memoryStore.persistRefresh(auth("rotated-access", "rotated-refresh"), { + expectedSnapshot, + }); + expect(outcome).toMatchObject({ status: "deleted" }); + expect(lstatSync(codexHome).isDirectory()).toBe(true); + expect(() => lstatSync(authPath)).toThrow(); + }); + + test("a Codex login at the rename boundary supersedes the rotated grant", async () => { + writeFileSync(authPath, JSON.stringify({ + tokens: { access_token: token(Math.floor(Date.now() / 1000) - 60), refresh_token: "refresh-old", account_id: "account-main" }, + })); + const externalAccess = token(Math.floor(Date.now() / 1000) + 600); + const external = JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: externalAccess, + refresh_token: "refresh-codex-new", + account_id: "account-main", + }, + }); + let writerCalls = 0; + setMainAuthJsonBeforeRenameHookForTests(() => { + writerCalls++; + writeFileSync(authPath, external); + }); + const result = await getValidMainAccountToken({ + refreshToken: async () => ({ + access: "rotated-access", + refresh: "rotated-refresh", + expires: Date.now() + 3600_000, + accountId: "account-main", + }), + }); + expect(writerCalls).toBe(1); + expect(result?.accessToken).toBe("rotated-access"); + expect(readCodexTokensResult()).toMatchObject({ + status: "ok", + tokens: { access_token: externalAccess, account_id: "account-main" }, + }); + expect(readFileSync(authPath, "utf8")).toBe(external); + }); + + test("an empty file retains a rotated grant until the last good file returns", async () => { + const store = await loadStoreModule(); + const original = auth("access-old"); + const timers: Array<() => void> = []; + let onDirectoryEvent: (() => void) | undefined; + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + fileSystem: { + watchDirectory: ((_: string, onEvent: () => void) => { + onDirectoryEvent = onEvent; + return { close() {}, unref() {} }; + }) as never, + setInterval: (() => ({ unref() {} })) as never, + setTimeout: ((callback: () => void) => { + timers.push(callback); + return { unref() {} }; + }) as never, + }, + writeSyncIO: { + write: () => { throw new Error("invalid file must not be replaced"); }, + harden: () => {}, rename: () => {}, truncate: () => {}, unlink: () => {}, + }, + }); + stores.push(memoryStore); + writeFileSync(authPath, original); + memoryStore.start(); + const expected = memoryStore.readMemory(); + const expectedSnapshot = captureFence(expected); + expected.snapshot?.raw.fill(0); + expected.raw?.fill(0); + writeFileSync(authPath, ""); + onDirectoryEvent?.(); + + const result = await memoryStore.persistRefresh(auth("rotated-access", "rotated-refresh"), { + expectedSnapshot, + }); + + const retained = memoryStore.readMemory(); + const retainedRaw = retained.raw?.toString("utf8"); + retained.snapshot?.raw.fill(0); + retained.raw?.fill(0); + expect(result).toMatchObject({ status: "retained" }); + expect(retained).toMatchObject({ status: "ok", fileStatus: "invalid" }); + expect(retainedRaw).toContain("rotated-refresh"); + expect(readFileSync(authPath, "utf8")).toBe(""); + expect(timers).toHaveLength(1); + + writeFileSync(authPath, original); + onDirectoryEvent?.(); + const restored = memoryStore.readMemory(); + const restoredRaw = restored.raw?.toString("utf8"); + restored.snapshot?.raw.fill(0); + restored.raw?.fill(0); + expect(restoredRaw).toBe(original); + }); + + test("a rotated grant stays in memory and retries promptly after a write error", async () => { + const store = await loadStoreModule(); + let scheduledDelay = 0; + let clearedRetry = false; + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + fileSystem: { + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: (() => ({ unref() {} })) as never, + setTimeout: ((_: () => void, delay: number) => { + scheduledDelay = delay; + return {}; + }) as never, + clearTimeout: (() => { clearedRetry = true; }) as never, + }, + writeSyncIO: { + write: () => { throw Object.assign(new Error("disk unavailable"), { code: "EIO" }); }, + harden: () => {}, rename: () => {}, truncate: () => {}, unlink: () => {}, + }, + }); + stores.push(memoryStore); + const original = auth("access-old"); + writeFileSync(authPath, original); + memoryStore.start(); + const expected = memoryStore.readMemory(); + const expectedSnapshot = captureFence(expected); + expected.snapshot?.raw.fill(0); + expected.raw?.fill(0); + const result = await memoryStore.persistRefresh(auth("rotated-access", "rotated-refresh"), { + expectedSnapshot, + }); + const retained = memoryStore.readMemory(); + const retainedRaw = retained.raw!.toString(); + retained.snapshot?.raw.fill(0); + retained.raw?.fill(0); + expect(result).toMatchObject({ status: "retained" }); + expect(JSON.parse(retainedRaw).tokens.refresh_token).toBe("rotated-refresh"); + expect(readFileSync(authPath, "utf8")).toBe(original); + expect(scheduledDelay).toBe(store.NATIVE_MAIN_AUTH_RETRY_INITIAL_MS); + if ("snapshot" in result && result.snapshot) result.snapshot.raw.fill(0); + memoryStore.stop(); + expect(clearedRetry).toBe(true); + }); + + test("a read hiccup on unchanged disk preserves and retries the retained rotated grant", async () => { + const store = await loadStoreModule(); + const timers: Array<{ callback: () => void; delay: number }> = []; + let failNextRead = false; + let writeAttempts = 0; + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + claimExclusive: (async (_context: unknown, operation: () => Promise) => operation()) as never, + fileSystem: { + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: (() => ({ unref() {} })) as never, + setTimeout: ((callback: () => void, delay: number) => { + timers.push({ callback, delay }); + return {}; + }) as never, + clearTimeout: (() => {}) as never, + read: ((path: string) => { + if (failNextRead) { + failNextRead = false; + throw Object.assign(new Error("temporary credential read failure"), { code: "EIO" }); + } + return readFileSync(path); + }) as never, + }, + writeSyncIO: { + write: (path, content) => { + writeAttempts++; + if (writeAttempts === 1) { + failNextRead = true; + throw Object.assign(new Error("first publication failed"), { code: "EIO" }); + } + writeFileSync(path, content, { mode: 0o600 }); + }, + harden: () => {}, + rename: (source, target, afterReplace) => { + renameSync(source, target); + afterReplace?.(); + }, + truncate: path => truncateSync(path, 0), + unlink: path => unlinkSync(path), + }, + }); + stores.push(memoryStore); + const original = auth("access-old", "refresh-old"); + writeFileSync(authPath, original); + memoryStore.start(); + const initial = memoryStore.readMemory(); + const expectedSnapshot = captureFence(initial); + initial.snapshot?.raw.fill(0); + initial.raw?.fill(0); + + const first = await memoryStore.persistRefresh(auth("access-rotated", "refresh-rotated"), { + expectedSnapshot, + }); + expect(first).toMatchObject({ status: "retained" }); + expect(memoryStore.observeNow()).toMatchObject({ fileStatus: "unreadable" }); + const afterReadError = memoryStore.readMemory(); + expect(afterReadError.raw?.toString("utf8")).toContain("refresh-rotated"); + afterReadError.snapshot?.raw.fill(0); + afterReadError.raw?.fill(0); + + expect(memoryStore.observeNow()).toMatchObject({ fileStatus: "valid" }); + expect(timers).toHaveLength(1); + timers[0]!.callback(); + for (let turn = 0; turn < 12; turn++) await Promise.resolve(); + memoryStore.observeNow(); + const afterRetry = memoryStore.readMemory(); + const afterRetryRaw = afterRetry.raw?.toString("utf8"); + afterRetry.snapshot?.raw.fill(0); + afterRetry.raw?.fill(0); + expect(afterRetryRaw).toContain("refresh-rotated"); + expect(JSON.parse(readFileSync(authPath, "utf8")).tokens.refresh_token).toBe("refresh-rotated"); + expect(writeAttempts).toBe(2); + }); + + test("persistent write failures back off exponentially and a later retry publishes the grant", async () => { + const store = await loadStoreModule(); + const timers: Array<{ callback: () => void; delay: number; cleared: boolean; fired: boolean }> = []; + const delays: number[] = []; + let allowWrite = false; + let claimCount = 0; + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + claimExclusive: (async (_context: unknown, operation: () => Promise) => { + claimCount++; + return operation(); + }) as never, + fileSystem: { + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: (() => ({ unref() {} })) as never, + setTimeout: ((callback: () => void, delay: number) => { + delays.push(delay); + const timer = { callback, delay, cleared: false, fired: false }; + timers.push(timer); + return timer; + }) as never, + clearTimeout: ((timer: { cleared: boolean }) => { timer.cleared = true; }) as never, + }, + writeSyncIO: { + write: (path, content) => { + if (!allowWrite) throw Object.assign(new Error("disk unavailable"), { code: "EIO" }); + writeFileSync(path, content, { mode: 0o600 }); + }, + harden: () => {}, + rename: (source, target, afterReplace) => { + renameSync(source, target); + afterReplace?.(); + }, + truncate: path => truncateSync(path, 0), + unlink: path => unlinkSync(path), + }, + }); + stores.push(memoryStore); + const original = auth("access-old"); + writeFileSync(authPath, original); + memoryStore.start(); + const expected = memoryStore.readMemory(); + const expectedSnapshot = captureFence(expected); + expected.snapshot?.raw.fill(0); + expected.raw?.fill(0); + + await memoryStore.persistRefresh(auth("rotated-access", "rotated-refresh"), { + expectedSnapshot, + }); + expect(delays).toEqual([store.NATIVE_MAIN_AUTH_RETRY_INITIAL_MS]); + for (let attempt = 0; attempt < 6; attempt++) { + const timer = timers.at(-1)!; + timer.callback(); + for (let turn = 0; turn < 8; turn++) await Promise.resolve(); + } + expect(delays).toEqual([50, 100, 200, 400, 800, 1_000, 1_000]); + expect(claimCount).toBe(6); + + allowWrite = true; + timers.at(-1)!.callback(); + for (let turn = 0; turn < 8; turn++) await Promise.resolve(); + expect(claimCount).toBe(7); + expect(JSON.parse(readFileSync(authPath, "utf8")).tokens.refresh_token).toBe("rotated-refresh"); + expect(delays).toHaveLength(7); + }); + + test("a blocked retry is single-flight and stop aborts its claim wait", async () => { + const store = await loadStoreModule(); + const timers: Array<{ callback: () => void; delay: number; cleared: boolean }> = []; + let onInterval: (() => void) | undefined; + let claimStarts = 0; + const claimSignals: AbortSignal[] = []; + let markStarted!: () => void; + const started = new Promise(resolve => { markStarted = resolve; }); + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + claimExclusive: (async (_context: unknown, _operation: () => Promise, options: { signal?: AbortSignal }) => { + claimStarts++; + if (options.signal) claimSignals.push(options.signal); + markStarted(); + await new Promise((resolve, reject) => { + const signal = options.signal; + if (!signal) return; + if (signal.aborted) reject(signal.reason); + else signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + }) as never, + fileSystem: { + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: ((callback: () => void) => { + onInterval = callback; + return { unref() {} }; + }) as never, + setTimeout: ((callback: () => void, delay: number) => { + const timer = { callback, delay, cleared: false }; + timers.push(timer); + return timer; + }) as never, + clearTimeout: ((timer: { cleared: boolean }) => { timer.cleared = true; }) as never, + }, + writeSyncIO: { + write: () => { throw Object.assign(new Error("disk unavailable"), { code: "EIO" }); }, + harden: () => {}, rename: () => {}, truncate: () => {}, unlink: () => {}, + }, + }); + stores.push(memoryStore); + writeFileSync(authPath, auth("access-old")); + memoryStore.start(); + const expected = memoryStore.readMemory(); + const expectedSnapshot = captureFence(expected); + expected.snapshot?.raw.fill(0); + expected.raw?.fill(0); + await memoryStore.persistRefresh(auth("rotated-access", "rotated-refresh"), { + expectedSnapshot, + }); + + const firstTimer = timers.at(-1)!; + firstTimer.fired = true; + firstTimer.callback(); + await started; + onInterval?.(); + const queuedTimer = timers.at(-1)!; + if (queuedTimer !== firstTimer) { + queuedTimer.fired = true; + queuedTimer.callback(); + } + onInterval?.(); + expect(claimStarts).toBe(1); + memoryStore.stop(); + for (let turn = 0; turn < 8; turn++) await Promise.resolve(); + expect(claimSignals).toHaveLength(1); + expect(claimSignals[0]?.aborted).toBe(true); + expect(timers.filter(timer => !timer.cleared && !timer.fired)).toHaveLength(0); + }); + + test("transient observations retain memory while a valid changed file is adopted", async () => { + const store = await loadStoreModule(); + let onDirectoryEvent: (() => void) | undefined; + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + fileSystem: { + watchDirectory: ((_: string, onEvent: () => void) => { + onDirectoryEvent = onEvent; + return { close() {}, unref() {} }; + }) as never, + }, + }); + stores.push(memoryStore); + writeFileSync(authPath, auth("access-good")); + memoryStore.start(); + writeFileSync(authPath, ""); + onDirectoryEvent?.(); + expect(memoryStore.readMemory().raw?.toString()).toContain("access-good"); + writeFileSync(authPath, auth("access-restored", "refresh-restored")); + onDirectoryEvent?.(); + expect(memoryStore.readMemory().raw?.toString()).toContain("access-restored"); + }); + + test.each(["apikey", "api_key"] as const)("complete API-key login in %s mode replaces ChatGPT memory", async authMode => { + const store = await loadStoreModule(); + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + fileSystem: { + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: (() => ({ unref() {} })) as never, + }, + }); + stores.push(memoryStore); + writeFileSync(authPath, auth("chatgpt-access", "chatgpt-refresh")); + memoryStore.start(); + writeFileSync(authPath, JSON.stringify({ + auth_mode: authMode, + OPENAI_API_KEY: "codex-api-key", + tokens: null, + })); + memoryStore.observeNow(); + + const adopted = memoryStore.readMemory(); + const adoptedRaw = adopted.raw?.toString("utf8"); + adopted.snapshot?.raw.fill(0); + adopted.raw?.fill(0); + expect(adopted).toMatchObject({ status: "ok", fileStatus: "valid" }); + expect(adoptedRaw).toBe(JSON.stringify({ + auth_mode: authMode, + OPENAI_API_KEY: "codex-api-key", + tokens: null, + })); + }); + + test("the bounded credential reader rejects concurrent growth and oversized files", () => { + const original = Buffer.from("small complete credential"); + let contents = Buffer.from(original); + const unchanged = readNativeMainAuthDescriptorBounded( + BigInt(original.byteLength), + (target, offset, length, position) => { + const count = Math.min(length, contents.byteLength - position); + contents.copy(target, offset, position, position + count); + return count; + }, + () => BigInt(contents.byteLength), + ); + expect(unchanged).toEqual(original); + unchanged.fill(0); + + let reads = 0; + const readGrowing = (target: Buffer, offset: number, length: number, position: number): number => { + const count = Math.min(length, contents.byteLength - position); + contents.copy(target, offset, position, position + count); + if (reads++ === 0) contents = Buffer.concat([contents, Buffer.from("-grew")]); + return count; + }; + expect(() => readNativeMainAuthDescriptorBounded( + BigInt(original.byteLength), + readGrowing, + () => BigInt(contents.byteLength), + )).toThrow("changed while being read"); + + reads = 0; + contents = Buffer.from(original); + const readGrowingPastCap = (target: Buffer, offset: number, length: number, position: number): number => { + const count = Math.min(length, contents.byteLength - position); + contents.copy(target, offset, position, position + count); + if (reads++ === 0) { + contents = Buffer.alloc(MAX_AUTH_BYTES + 1); + original.copy(contents); + } + return count; + }; + expect(() => readNativeMainAuthDescriptorBounded( + BigInt(original.byteLength), + readGrowingPastCap, + () => BigInt(contents.byteLength), + )).toThrow("too large"); + + let oversizedReadCalls = 0; + expect(() => readNativeMainAuthDescriptorBounded( + BigInt(MAX_AUTH_BYTES + 1), + () => { oversizedReadCalls++; return 0; }, + () => 0n, + )).toThrow("too large"); + expect(oversizedReadCalls).toBe(0); + }); + + test("the watcher hashes only after a stat signature changes", async () => { + const store = await loadStoreModule(); + let onTimer: (() => void) | undefined; + let statCalls = 0; + let readCalls = 0; + const realStat = lstatSync; + const realRead = readFileSync; + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + fileSystem: { + stat: ((path: string) => { statCalls++; return realStat(path); }) as never, + read: ((path: string) => { readCalls++; return realRead(path); }) as never, + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: ((callback: () => void) => { + onTimer = callback; + return { unref() {} }; + }) as never, + }, + }); + stores.push(memoryStore); + writeFileSync(authPath, auth("access-good")); + memoryStore.start(); + const initialReads = readCalls; + onTimer?.(); + expect(statCalls).toBeGreaterThan(1); + expect(readCalls).toBe(initialReads); + writeFileSync(authPath, auth("access-next")); + onTimer?.(); + expect(readCalls).toBe(initialReads + 1); + }); + + test("a dropped event converges on same-size bytes with an unchanged stat signature", async () => { + const store = await loadStoreModule(); + let now = 0; + let onTimer: (() => void) | undefined; + let readCalls = 0; + const original = auth("access-old", "refresh-old"); + const replacement = auth("access-new", "refresh-new"); + expect(Buffer.byteLength(original)).toBe(Buffer.byteLength(replacement)); + writeFileSync(authPath, original); + const stableStat = lstatSync(authPath); + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + now: () => now, + fileSystem: { + stat: (() => stableStat) as never, + lstat: (() => stableStat) as never, + read: ((path: string) => { + readCalls++; + return readFileSync(path); + }) as never, + watchDirectory: (() => ({ close() {}, unref() {} })) as never, + setInterval: ((callback: () => void) => { + onTimer = callback; + return { unref() {} }; + }) as never, + }, + }); + stores.push(memoryStore); + memoryStore.start(); + const initialReads = readCalls; + writeFileSync(authPath, replacement); + + now += 999; + onTimer?.(); + expect(readCalls).toBe(initialReads); + expect(memoryStore.readMemory().raw?.toString("utf8")).toBe(original); + + now += 1; + onTimer?.(); + const observed = memoryStore.readMemory(); + const observedRaw = observed.snapshot?.raw.toString("utf8"); + observed.snapshot?.raw.fill(0); + observed.raw?.fill(0); + expect(readCalls).toBe(initialReads + 1); + expect(observedRaw).toBe(replacement); + }); + + test("the watcher and timer stop cleanly", async () => { + const store = await loadStoreModule(); + let watcherClosed = false; + let timerCleared = false; + const memoryStore = new store.NativeMainCredentialStore({ + authPath, + fileSystem: { + watchDirectory: (() => ({ close() { watcherClosed = true; }, unref() {} })) as never, + setInterval: (() => ({ unref() {} })) as never, + clearInterval: (() => { timerCleared = true; }) as never, + }, + }); + stores.push(memoryStore); + writeFileSync(authPath, auth("access-good")); + memoryStore.start(); + memoryStore.stop(); + expect(watcherClosed).toBe(true); + expect(timerCleared).toBe(true); + }); + + test("an accessor after the shutdown sweep returns stopped without restarting observation", () => { + resetOptionalShutdownHooksForTests(); + writeFileSync(authPath, auth("access-good")); + const beforeShutdown = getNativeMainAuthMemory(authPath); + expect(beforeShutdown.status).toBe("ok"); + beforeShutdown.snapshot?.raw.fill(0); + beforeShutdown.raw?.fill(0); + + try { + runOptionalShutdownHooks(); + const afterShutdown = getNativeMainAuthMemory(authPath); + expect(afterShutdown.status).toBe("stopped"); + expect(afterShutdown.canRefreshOrWrite).toBe(false); + afterShutdown.snapshot?.raw.fill(0); + afterShutdown.raw?.fill(0); + } finally { + stopNativeMainCredentialWatchers(); + resetOptionalShutdownHooksForTests(); + } + }); +}); diff --git a/tests/codex-integration/native-main-refresh-mode.test.ts b/tests/codex-integration/native-main-refresh-mode.test.ts index 3c0ad23b015..79dcfbd7891 100644 --- a/tests/codex-integration/native-main-refresh-mode.test.ts +++ b/tests/codex-integration/native-main-refresh-mode.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -234,6 +234,20 @@ describe("main account in codex refresh mode (#2999)", () => { expect(codexCredentialMutationEpoch()).toBe(epochBefore); }); + test("a symlinked auth.json is still refreshed by Codex, and the link target is not written", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const target = join(root, "real-auth.json"); + const original = JSON.stringify({ auth_mode: "chatgpt", tokens: { access_token: expired(), refresh_token: "codex-owned-grant", account_id: "acct-main" } }); + writeFileSync(target, original); + symlinkSync(target, join(home, "auth.json")); + const fresh = fakeChatGptJwt("acct-main"); + const { calls, codexAuthStatus } = seam(() => ({ authMethod: "chatgpt", authToken: fresh, requiresOpenaiAuth: true })); + + expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: fresh, chatgptAccountId: "acct-main" }); + expect(calls).toEqual([{ refresh: true }]); + expect(readFileSync(target, "utf8")).toBe(original); + }); + test("the RPC answer is used even when Codex moved the login to its keyring", async () => { setNativeMainRefreshResolutionForTests(CODEX_MODE); seedAuth(expired()); diff --git a/tests/codex-integration/native-model-toggle.test.ts b/tests/codex-integration/native-model-toggle.test.ts index 15563ffd33a..397f0f3f284 100644 --- a/tests/codex-integration/native-model-toggle.test.ts +++ b/tests/codex-integration/native-model-toggle.test.ts @@ -634,6 +634,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { const canonical = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + upstreamWebsocket: false, } as const; expect(shouldIncludeNativeOpenAi({ providers: {} })).toBe(true); expect(shouldIncludeNativeOpenAi({ providers: { openai: canonical } })).toBe(true); @@ -794,7 +795,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { // A confirmed roster makes the gated rows selectable again; a bare disable still wins. writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: "toggle-token", account_id: "toggle-main" }, + tokens: { access_token: "toggle-token", refresh_token: "toggle-refresh", account_id: "toggle-main" }, })); seedCodexModelEntitlementsForTests( "main", @@ -839,7 +840,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { process.env.OPENCODEX_HOME = join(root, "opencodex"); process.env.CODEX_HOME = codexHome; writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ - tokens: { access_token: "expired-token", account_id: "expired-main" }, + tokens: { access_token: "expired-token", refresh_token: "expired-refresh", account_id: "expired-main" }, })); seedCodexModelEntitlementsForTests( "main", diff --git a/tests/codex-integration/native-profile-drain-server.test.ts b/tests/codex-integration/native-profile-drain-server.test.ts index 34fc9f71c5f..48911f6ab55 100644 --- a/tests/codex-integration/native-profile-drain-server.test.ts +++ b/tests/codex-integration/native-profile-drain-server.test.ts @@ -29,20 +29,34 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; /** These cases sandbox CODEX_HOME/OPENCODEX_HOME, so the installed service is not their evidence. */ const inspectNativeCodexOwnership = ownedServiceHomeInspection("native-profile drain server test"); -const originalFetch = globalThis.fetch; +const initialFetch = globalThis.fetch; const previousOpencodexHome = process.env.OPENCODEX_HOME; const previousCodexHome = process.env.CODEX_HOME; let opencodexHome = ""; let codexHome = ""; +type TestServerHandle = { stop(closeActive?: boolean): void | Promise }; +const acquiredServers = new Set(); + +function trackServer(server: T): T { + acquiredServers.add(server); + return server; +} + +async function stopTrackedServer(server: TestServerHandle): Promise { + if (!acquiredServers.has(server)) return; + await server.stop(true); + acquiredServers.delete(server); +} beforeEach(() => { + if (globalThis.fetch !== initialFetch) throw new Error("SETUP: fetch must be restored before native-profile drain cases"); opencodexHome = mkdtempSync(join(tmpdir(), "ocx-main-drain-server-")); codexHome = mkdtempSync(join(tmpdir(), "ocx-main-drain-codex-")); process.env.OPENCODEX_HOME = opencodexHome; process.env.CODEX_HOME = codexHome; writeFileSync( join(codexHome, "auth.json"), - JSON.stringify({ tokens: { access_token: "main-access", account_id: "main-account" } }), + JSON.stringify({ tokens: { access_token: "main-access", refresh_token: "main-refresh", account_id: "main-account" } }), ); clearAccountQuota(); clearThreadAccountMap(); @@ -51,8 +65,17 @@ beforeEach(() => { resetLifecycleDrainStateForTests(); }); -afterEach(() => { - globalThis.fetch = originalFetch; +afterEach(async () => { + let stopFailure: unknown; + for (const server of [...acquiredServers]) { + try { + await server.stop(true); + } catch (error) { + stopFailure ??= error; + } + } + acquiredServers.clear(); + if (globalThis.fetch !== initialFetch) globalThis.fetch = initialFetch; resetLifecycleDrainStateForTests(); clearAccountQuota(); clearThreadAccountMap(); @@ -64,12 +87,14 @@ afterEach(() => { else process.env.OPENCODEX_HOME = previousOpencodexHome; if (previousCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; + if (stopFailure) throw stopFailure; }); describe("native main profile scoped server admission", () => { test("HTTP and Responses WebSocket keep Direct live while main Pool frames are rejected", async () => { let upstreamRequests = 0; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const originalFetch = globalThis.fetch; + const upstreamFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; if (url.includes("chatgpt.com/backend-api/codex/responses")) { upstreamRequests += 1; @@ -91,6 +116,7 @@ describe("native main profile scoped server admission", () => { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode, + upstreamWebsocket: false, }, }, codexAccounts: [], @@ -98,7 +124,7 @@ describe("native main profile scoped server admission", () => { autoSwitchThreshold: 0, } as OcxConfig); const waitForFrame = (ws: WebSocket, needle: string) => new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`websocket timeout waiting for ${needle}`)), watchdogMs(2_000)); + const timer = setTimeout(() => reject(new Error(`websocket timeout waiting for ${needle}; upstream requests=${upstreamRequests}`)), watchdogMs(2_000)); const onMessage = (event: MessageEvent) => { const text = typeof event.data === "string" ? event.data : ""; if (!text.includes(needle)) return; @@ -109,11 +135,14 @@ describe("native main profile scoped server admission", () => { ws.addEventListener("message", onMessage); }); - saveMode("direct"); - let server: ReturnType | undefined = startServer(0, { inspectNativeCodexOwnership }); - const drain = acquireNativeMainProfileDrain("test-switch"); - expect(drain).not.toBeNull(); + let server: ReturnType | undefined; + let drain: ReturnType | null = null; try { + globalThis.fetch = upstreamFetch; + saveMode("direct"); + server = trackServer(startServer(0, { inspectNativeCodexOwnership })); + drain = acquireNativeMainProfileDrain("test-switch"); + expect(drain).not.toBeNull(); const directHttp = await fetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer caller-token" }, @@ -133,12 +162,12 @@ describe("native main profile scoped server admission", () => { directWs.send(JSON.stringify({ type: "response.create", model: "gpt-test", input: "hello" })); await directTerminal; directWs.close(); - await server.stop(true); + await stopTrackedServer(server); server = undefined; saveMode("pool"); updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 1, 1); - server = startServer(0, { inspectNativeCodexOwnership }); + server = trackServer(startServer(0, { inspectNativeCodexOwnership })); await waitForNativeMainStartupGate(); const mainHttp = await fetch(new URL("/v1/responses", server.url), { method: "POST", @@ -163,14 +192,15 @@ describe("native main profile scoped server admission", () => { expect(upstreamRequests).toBe(2); } finally { drain?.release(); - await server?.stop(true); + if (server) await stopTrackedServer(server); + if (globalThis.fetch === upstreamFetch) globalThis.fetch = originalFetch; } }); test("Live/Realtime sideband retains main ownership while Direct and non-main Pool continue", async () => { let upstreamConnections = 0; let upstreamCloses = 0; - const upstream = Bun.serve({ + const upstream = trackServer(Bun.serve({ port: 0, fetch(req, server) { if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { @@ -184,7 +214,7 @@ describe("native main profile scoped server admission", () => { message(ws, message) { ws.send(`echo:${String(message)}`); }, close() { upstreamCloses += 1; }, }, - }); + })); const waitUntil = async (condition: () => boolean): Promise => { const deadline = Date.now() + 2_000; while (!condition() && Date.now() < deadline) await Bun.sleep(10); @@ -194,6 +224,7 @@ describe("native main profile scoped server admission", () => { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const, codexAccountMode, + upstreamWebsocket: false, }); const saveMode = (codexAccountMode: "direct" | "pool", activeCodexAccountId: string) => saveConfig({ port: 0, @@ -280,7 +311,7 @@ describe("native main profile scoped server admission", () => { try { saveMode("pool", MAIN_CODEX_ACCOUNT_ID); updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 1, 1); - server = startServer(0, { inspectNativeCodexOwnership }); + server = trackServer(startServer(0, { inspectNativeCodexOwnership })); await waitForNativeMainStartupGate(); client = await connectEcho(server, "/v1/live/main-pre-fence"); expect(getNativeMainProfileRequestCount()).toBe(1); @@ -313,11 +344,11 @@ describe("native main profile scoped server admission", () => { expect(await handshakeStatus(server, "/v1/realtime?call_id=main-post-fence")).toBe(503); expect(upstreamConnections).toBe(1); mainDrain?.release(); - await server.stop(true); + await stopTrackedServer(server); server = undefined; saveMode("direct", MAIN_CODEX_ACCOUNT_ID); - server = startServer(0, { inspectNativeCodexOwnership }); + server = trackServer(startServer(0, { inspectNativeCodexOwnership })); const directDrain = acquireNativeMainProfileDrain("direct-sideband"); const directToken = fakeChatGptJwt({ chatgpt_account_id: "direct-account" }); client = await connectEcho(server, "/v1/live/direct", { @@ -328,7 +359,7 @@ describe("native main profile scoped server admission", () => { directDrain?.release(); await closeSocket(client); client = undefined; - await server.stop(true); + await stopTrackedServer(server); server = undefined; saveCodexAccountCredential("pool-a", { @@ -339,7 +370,7 @@ describe("native main profile scoped server admission", () => { }); updateAccountQuota("pool-a", 1, 1); saveMode("pool", "pool-a"); - server = startServer(0, { inspectNativeCodexOwnership }); + server = trackServer(startServer(0, { inspectNativeCodexOwnership })); const poolDrain = acquireNativeMainProfileDrain("pool-sideband"); client = await connectEcho(server, "/v1/realtime/calls/pool"); expect(getNativeMainProfileRequestCount()).toBe(0); @@ -349,8 +380,8 @@ describe("native main profile scoped server admission", () => { expect(upstreamConnections).toBe(3); } finally { if (client) await closeSocket(client).catch(() => {}); - await server?.stop(true); - await upstream.stop(true); + if (server) await stopTrackedServer(server); + await stopTrackedServer(upstream); } }); @@ -404,14 +435,14 @@ describe("native main profile scoped server admission", () => { return { ok: true }; }, } as unknown as NativeProfileManager; - const server = startServer(0, { + const server = trackServer(startServer(0, { inspectNativeCodexOwnership, liveSidebandWebSocketFactory: () => { upstream = new UncooperativeUpstream(); queueMicrotask(() => upstream?.open()); return upstream as unknown as WebSocket; }, - }); + })); await waitForNativeMainStartupGate(); const url = new URL("/v1/live/uncooperative", server.url); url.protocol = "ws:"; @@ -462,7 +493,7 @@ describe("native main profile scoped server admission", () => { expect(getNativeMainProfileRequestCount()).toBe(0); } finally { upstream?.finishClose(); - await server.stop(true); + await stopTrackedServer(server); } }); }); diff --git a/tests/codex-integration/native-profile-manager.test.ts b/tests/codex-integration/native-profile-manager.test.ts index a66e7a3f9d9..4b80d3c449f 100644 --- a/tests/codex-integration/native-profile-manager.test.ts +++ b/tests/codex-integration/native-profile-manager.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeAll, describe, expect, test } from "bun:test"; -import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { closeSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, statSync, symlinkSync, truncateSync, unlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { atomicWriteFileAsync, type AtomicWriteAsyncHooks, type AtomicWriteAsyncIO } from "../../src/config/atomic-write"; import { NativeProfileManager } from "../../src/codex/native-profile-manager"; import { decryptNativeEnvelope, @@ -15,15 +16,18 @@ import { } from "../../src/codex/native-profile-store"; import { NativeProfileError, type NativeProfileKey, type NativeProfileKeyProvider } from "../../src/codex/native-profile-types"; import { codexCredentialMutationEpoch } from "../../src/codex/credential-mutation-epoch"; +import { getNativeMainAuthMemory, observeNativeMainAuthFile, stopNativeMainCredentialWatchers } from "../../src/codex/native-main-credentials"; import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmModuleGraph } from "../helpers/cold-spawn-warmup"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { helperPath, repoRoot } from "../helpers/repo-root"; import { BULK_DURABLE_IO_BUDGET_MS, INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; +import { captureAtomicWritePublicationEvidence, writeNativeMainAuthFileForTests } from "../helpers/native-main-auth"; const nativeProfileLockChildPath = helperPath("native-profile-lock-child.ts"); const roots: string[] = []; afterEach(() => { + stopNativeMainCredentialWatchers(); for (const root of roots.splice(0)) removeTreeWithRetry(root); }); @@ -50,11 +54,54 @@ function envelope(accountId: string, marker: string): string { return ` {\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"opaque-id-${marker}\",\n \"access_token\": \"opaque-access-${marker}\",\n \"refresh_token\": \"opaque-refresh-${marker}\",\n \"account_id\": \"${accountId}\",\n \"future_token_field\": \"preserve-${marker}\"\n },\n \"future_root_field\": { \"marker\": \"${marker}\" }\n}\n`; } -async function atomic(path: string, content: string): Promise { +async function atomic(path: string, content: string, hooks?: AtomicWriteAsyncHooks): Promise { mkdirSync(dirname(path), { recursive: true }); const temp = `${path}.test.tmp`; - writeFileSync(temp, content, { mode: 0o600 }); - renameSync(temp, path); + let publicationDescriptor: number | undefined; + try { + writeFileSync(temp, content, { mode: 0o600 }); + await hooks?.beforeRename?.(path); + const tempStat = lstatSync(temp); + const tempEvidence = { + dev: Number(tempStat.dev), + ino: Number(tempStat.ino), + size: Number(tempStat.size), + mode: Number(tempStat.mode), + mtimeMs: Number(tempStat.mtimeMs), + ctimeMs: Number(tempStat.ctimeMs), + }; + await hooks?.validateBeforeRename?.(path, tempEvidence); + if (hooks?.afterRenameEvidence) publicationDescriptor = openSync(temp, "r"); + renameSync(temp, path); + if (publicationDescriptor !== undefined) { + await hooks?.afterRenameEvidence?.(path, captureAtomicWritePublicationEvidence(publicationDescriptor)); + } + await hooks?.afterRename?.(path, tempEvidence); + } catch (error) { + try { unlinkSync(temp); } catch { /* The rename committed or the temp is already absent. */ } + throw error; + } finally { + if (publicationDescriptor !== undefined) closeSync(publicationDescriptor); + } +} + +function nativeAuthWriteIO( + authPath: string, + writeContent: (content: string) => string | undefined = () => undefined, +): AtomicWriteAsyncIO { + return { + write: (path, content) => { + const output = path.startsWith(`${authPath}.ocx.`) ? writeContent(content) ?? content : content; + writeFileSync(path, output, { mode: 0o600 }); + }, + harden: () => {}, + rename: (source, destination, afterReplace) => { + renameSync(source, destination); + afterReplace?.(); + }, + truncate: path => truncateSync(path, 0), + unlink: path => unlinkSync(path), + }; } function hasJournalPhase(content: string, phase: string): boolean { @@ -114,15 +161,16 @@ async function leavePendingJournal(f: Awaited let authWrites = 0; const interrupted = new NativeProfileManager({ ...f.options, - atomicWrite: async (path, content) => { - if (path === authPath) { - authWrites += 1; - if (authWrites > 1) throw new Error("injected restore failure"); - } + nativeAuthWriteIO: nativeAuthWriteIO(authPath, content => { + authWrites += 1; + if (authWrites > 1) throw new Error("injected restore failure"); + return undefined; + }), + atomicWrite: async (path, content, hooks) => { if (path === journalPath && hasJournalPhase(content, "auth-replaced")) { throw new Error("injected post-replacement failure"); } - return atomic(path, content); + return atomic(path, content, hooks); }, }); let caught: unknown; @@ -369,7 +417,7 @@ describe("native main profile transactions", () => { const originalVault = readFileSync(f.manager.context.vaultPath, "utf8"); const malformed = "{cross-instance-malformed-journal\n"; writeFileSync(f.manager.context.journalPath, malformed, { mode: 0o600 }); - writeFileSync(f.manager.context.authPath, envelope("account-third", "third"), { mode: 0o600 }); + writeNativeMainAuthFileForTests(f.manager.context.authPath, envelope("account-third", "third")); expect(probeNativeProfileRecoveryState(second.context)).toBe("journal"); let caught: unknown; @@ -383,7 +431,7 @@ describe("native main profile transactions", () => { expect(quarantine).toHaveLength(1); expect(readFileSync(join(f.manager.context.rootDir, quarantine[0]!), "utf8")).toBe(malformed); - writeFileSync(f.manager.context.authPath, originalAuth, { mode: 0o600 }); + writeNativeMainAuthFileForTests(f.manager.context.authPath, originalAuth); expect(await f.manager.recover(false)).toMatchObject({ recovered: true, action: "confirm-current-owner" }); expect(probeNativeProfileRecoveryState(second.context)).toBe("none"); }); @@ -581,9 +629,9 @@ describe("native main profile transactions", () => { writeFileSync(join(stage.stagingCodexHome, "auth.json"), f.target); const failing = new NativeProfileManager({ ...f.options, - atomicWrite: async (path, content) => { + atomicWrite: async (path, content, hooks) => { if (path === manager.context.vaultPath) throw new Error("injected vault failure"); - return atomic(path, content); + return atomic(path, content, hooks); }, }); @@ -631,16 +679,16 @@ describe("native main profile transactions", () => { const largeSource = JSON.parse(f.source) as Record; largeSource.padding = "x".repeat(Math.floor(MAX_NATIVE_PROFILE_METADATA_BYTES * 0.85)); const sourceText = JSON.stringify(largeSource, null, 2) + "\n"; - writeFileSync(join(f.codexHome, "auth.json"), sourceText); + writeNativeMainAuthFileForTests(join(f.codexHome, "auth.json"), sourceText); let authWrites = 0; let vaultWrites = 0; let vaultPath: string | undefined; const manager = new NativeProfileManager({ ...f.options, - atomicWrite: async (path, content) => { + atomicWrite: async (path, content, hooks) => { if (path === join(f.codexHome, "auth.json")) authWrites += 1; if (path === vaultPath) vaultWrites += 1; - return atomic(path, content); + return atomic(path, content, hooks); }, }); vaultPath = manager.context.vaultPath; @@ -960,12 +1008,15 @@ describe("native main profile transactions", () => { let authWrites = 0; const interrupted = new NativeProfileManager({ ...f.options, - atomicWrite: async (path, content) => { - if (path === authPath && authWrites++ > 0) throw new Error("injected restore failure"); + nativeAuthWriteIO: nativeAuthWriteIO(authPath, content => { + if (authWrites++ > 0) throw new Error("injected restore failure"); + return undefined; + }), + atomicWrite: async (path, content, hooks) => { if (path === journalPath && hasJournalPhase(content, "auth-replaced")) { throw new Error("injected post-replacement failure"); } - return atomic(path, content); + return atomic(path, content, hooks); }, }); let interruptedError: unknown; @@ -1008,7 +1059,7 @@ describe("native main profile transactions", () => { const f = await enrolledFixture(); const malformed = "{malformed-journal\n"; writeFileSync(f.manager.context.journalPath, malformed); - writeFileSync(f.manager.context.authPath, f.target); + writeNativeMainAuthFileForTests(f.manager.context.authPath, f.target); const authBefore = readFileSync(f.manager.context.authPath); const vaultBefore = readFileSync(f.manager.context.vaultPath); let caught: unknown; @@ -1023,7 +1074,7 @@ describe("native main profile transactions", () => { const quarantine = readdirSync(f.manager.context.rootDir).filter(name => name.includes(".journal.quarantine-")); expect(quarantine).toHaveLength(1); expect(readFileSync(join(f.manager.context.rootDir, quarantine[0]!), "utf8")).toBe(malformed); - writeFileSync(f.manager.context.authPath, f.source); + writeNativeMainAuthFileForTests(f.manager.context.authPath, f.source); expect(await f.manager.recover(false)).toMatchObject({ recovered: true, action: "confirm-current-owner", @@ -1061,7 +1112,7 @@ describe("native main profile transactions", () => { await leavePendingJournal(f); const epochBefore = codexCredentialMutationEpoch(); const refreshed = envelope("account-target", "target-refreshed-auto"); - writeFileSync(f.manager.context.authPath, refreshed); + writeNativeMainAuthFileForTests(f.manager.context.authPath, refreshed); expect(await f.manager.recover(false)).toMatchObject({ recovered: true, action: "commit-target", @@ -1069,7 +1120,7 @@ describe("native main profile transactions", () => { restartRequired: true, }); expect(readFileSync(f.manager.context.authPath, "utf8")).toBe(refreshed); - expect(codexCredentialMutationEpoch()).toBe(epochBefore); + expect(codexCredentialMutationEpoch()).toBe(epochBefore + 1); }); // The manager can spend up to 5 s acquiring its SQLite transaction lock. @@ -1109,46 +1160,50 @@ describe("native main profile transactions", () => { // transaction test above. Hosted macOS can complete the product recovery // successfully after Bun's 5 s default, so the harness must not become the // shorter deadline. - test("a read-back mismatch restores the exact source and removes the journal", async () => { + test("rollback over an invalid auth observation retains the journal and requires recovery", async () => { const f = await enrolledFixture(); const authPath = f.manager.context.authPath; let authWrites = 0; const failing = new NativeProfileManager({ ...f.options, - atomicWrite: async (path, content) => { - if (path === authPath && authWrites++ === 0) return atomic(path, "{}\n"); - return atomic(path, content); + nativeAuthWriteIO: nativeAuthWriteIO(authPath, content => { + if (authWrites++ === 0) return "{}\n"; + return content; + }), + atomicWrite: async (path, content, hooks) => { + return atomic(path, content, hooks); }, }); const epochBefore = codexCredentialMutationEpoch(); let caught: unknown; try { await failing.switch("work", true); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(NativeProfileError); - expect((caught as NativeProfileError).code).toBe("SWITCH_ROLLED_BACK"); - expect(readFileSync(join(f.codexHome, "auth.json"), "utf8")).toBe(f.source); - expect((await failing.doctor()).recoveryPending).toBe(false); + expect((caught as NativeProfileError).code).toBe("RECOVERY_REQUIRED"); + expect(readFileSync(join(f.codexHome, "auth.json"), "utf8")).toBe("{}\n"); + expect((await failing.doctor()).recoveryPending).toBe(true); expect((await failing.list()).activeProfileId).toBe(f.sourceProfile.id); - expect(codexCredentialMutationEpoch()).toBe(epochBefore + 1); + expect(codexCredentialMutationEpoch()).toBe(epochBefore); }, 10_000); - test("rollback verification failure retains the encrypted recovery journal and never claims success", async () => { + test("invalid auth during switch retains the encrypted journal and requires recovery", async () => { const f = await enrolledFixture(); const authPath = f.manager.context.authPath; let authWrites = 0; const failing = new NativeProfileManager({ ...f.options, - atomicWrite: async (path, content) => { - if (path === authPath) { - authWrites += 1; - if (authWrites <= 2) return atomic(path, "{}\n"); - } - return atomic(path, content); + nativeAuthWriteIO: nativeAuthWriteIO(authPath, content => { + if (authWrites++ < 2) return "{}\n"; + return content; + }), + atomicWrite: async (path, content, hooks) => { + return atomic(path, content, hooks); }, }); let caught: unknown; try { await failing.switch("work", true); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(NativeProfileError); - expect((caught as NativeProfileError).code).toBe("AUTH_RESTORE_FAILED"); + expect((caught as NativeProfileError).code).toBe("RECOVERY_REQUIRED"); + expect(readFileSync(authPath, "utf8")).toBe("{}\n"); expect((await failing.doctor()).recoveryPending).toBe(true); const journal = readFileSync(failing.context.journalPath, "utf8"); expect(journal).not.toContain("opaque-refresh-source"); @@ -1174,14 +1229,74 @@ describe("native main profile transactions", () => { expect((await blocked.list()).activeProfileId).toBe(f.sourceProfile.id); }, 10_000); + test("profile registration rejects a symlinked main auth file", async () => { + const f = fixture(); + const authPath = join(f.codexHome, "auth.json"); + const linkedPath = join(f.root, "linked-main-auth.json"); + writeFileSync(linkedPath, f.source); + unlinkSync(authPath); + symlinkSync(linkedPath, authPath); + const manager = new NativeProfileManager(f.options); + let caught: unknown; + try { await manager.register("personal"); } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(NativeProfileError); + expect((caught as NativeProfileError).code).toBe("AUTH_UNREADABLE"); + expect(lstatSync(authPath).isSymbolicLink()).toBe(true); + expect(readFileSync(linkedPath, "utf8")).toBe(f.source); + expect(existsSync(manager.context.vaultPath)).toBe(false); + }); + + test("profile registration rejects a link after retaining a warm regular snapshot", async () => { + const f = fixture(); + const authPath = join(f.codexHome, "auth.json"); + const linkedPath = join(f.root, "linked-invalid-main-auth.json"); + const warm = getNativeMainAuthMemory(authPath); + warm.snapshot?.raw.fill(0); + warm.raw?.fill(0); + writeFileSync(linkedPath, "{\"auth_mode\":\"chatgpt\",\"tokens\":{\"access_token\":\"partial\"}}\n"); + unlinkSync(authPath); + symlinkSync(linkedPath, authPath); + + const observation = observeNativeMainAuthFile(authPath); + expect(observation.fileStatus).toBe("invalid"); + expect(observation.isSymlink).toBe(true); + expect(observation.snapshot?.isSymlink).toBe(false); + observation.raw?.fill(0); + observation.snapshot?.raw.fill(0); + + const manager = new NativeProfileManager(f.options); + let caught: unknown; + try { await manager.register("personal"); } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(NativeProfileError); + expect((caught as NativeProfileError).code).toBe("AUTH_UNREADABLE"); + expect(existsSync(manager.context.vaultPath)).toBe(false); + }); + + test("profile writability check rejects a symlinked main auth file before journaling", async () => { + const f = await enrolledFixture(); + const authPath = f.manager.context.authPath; + const linkedPath = join(f.root, "linked-main-auth.json"); + writeFileSync(linkedPath, f.source); + unlinkSync(authPath); + symlinkSync(linkedPath, authPath); + const manager = new NativeProfileManager(f.options); + let caught: unknown; + try { await manager.switch("work", true); } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(NativeProfileError); + expect((caught as NativeProfileError).code).toBe("AUTH_UNREADABLE"); + expect(lstatSync(authPath).isSymbolicLink()).toBe(true); + expect(readFileSync(linkedPath, "utf8")).toBe(f.source); + expect(existsSync(manager.context.journalPath)).toBe(false); + }, 10_000); + test("vault-write failure after auth replacement restores exact source and removes the journal", async () => { const f = await enrolledFixture(); let vaultWrites = 0; const failing = new NativeProfileManager({ ...f.options, - atomicWrite: async (path, content) => { + atomicWrite: async (path, content, hooks) => { if (path === f.manager.context.vaultPath && vaultWrites++ === 0) throw new Error("injected switch vault failure"); - return atomic(path, content); + return atomic(path, content, hooks); }, }); let caught: unknown; @@ -1193,6 +1308,111 @@ describe("native main profile transactions", () => { expect((await failing.list()).activeProfileId).toBe(f.sourceProfile.id); }); + test("an observed identical-byte replacement after target publication keeps recovery journal", async () => { + const f = await enrolledFixture(); + const authPath = f.manager.context.authPath; + let externalIdentity: { dev: number; ino: number } | undefined; + let replacementObserved = false; + const failing = new NativeProfileManager({ + ...f.options, + atomicWrite: async (path, content, hooks) => { + await atomic(path, content, hooks); + }, + onSwitchBoundary: boundary => { + if (boundary !== "auth-replaced" || replacementObserved) return; + const replacementPath = `${authPath}.codex-replacement`; + writeFileSync(replacementPath, f.target, { mode: 0o600 }); + renameSync(replacementPath, authPath); + const replacementStat = lstatSync(authPath); + externalIdentity = { dev: Number(replacementStat.dev), ino: Number(replacementStat.ino) }; + const observation = observeNativeMainAuthFile(authPath); + observation.raw?.fill(0); + observation.snapshot?.raw.fill(0); + replacementObserved = true; + throw new Error("injected later transaction failure"); + }, + }); + + let caught: unknown; + try { await failing.switch("work", true); } catch (error) { caught = error; } + + expect(replacementObserved).toBe(true); + expect(caught).toBeInstanceOf(NativeProfileError); + expect((caught as NativeProfileError).code).toBe("RECOVERY_REQUIRED"); + const currentStat = lstatSync(authPath); + expect({ dev: Number(currentStat.dev), ino: Number(currentStat.ino) }).toEqual(externalIdentity); + expect(readFileSync(authPath, "utf8")).toBe(f.target); + expect(existsSync(failing.context.journalPath)).toBe(true); + expect((await failing.list()).activeProfileId).toBe(f.sourceProfile.id); + }); + + test("a ctime-only rewrite after the first descriptor sample keeps the valid target and recovery journal", async () => { + const f = await enrolledFixture(); + const authPath = f.manager.context.authPath; + let authWrites = 0; + let successfulAuthReceipts = 0; + let rollbackAttempts = 0; + let rewrittenAfterRename = false; + let ctimeOnlyRewriteObserved = false; + let switchBoundaryReached = false; + const stagedAuthBytes = new Map(); + const fixedMtime = new Date(1_700_000_000_000); + const ctimeAfterSampleIO: AtomicWriteAsyncIO = { + write: (tempPath, content) => { + if (tempPath.startsWith(`${authPath}.ocx.`)) { + authWrites++; + if (authWrites > 1) rollbackAttempts++; + stagedAuthBytes.set(tempPath, content); + } + writeFileSync(tempPath, content, { mode: 0o600 }); + if (authWrites === 1) utimesSync(tempPath, fixedMtime, fixedMtime); + }, + harden: () => {}, + rename: (source, destination, afterReplace) => { + renameSync(source, destination); + afterReplace?.(); + if (destination !== authPath || authWrites !== 1) return; + const content = stagedAuthBytes.get(source); + if (content === undefined) throw new Error("native auth staging payload was missing"); + const renamedStat = statSync(destination); + writeFileSync(destination, content); + utimesSync(destination, fixedMtime, fixedMtime); + const rewrittenStat = statSync(destination); + ctimeOnlyRewriteObserved = rewrittenStat.mtimeMs === renamedStat.mtimeMs + && rewrittenStat.ctimeMs > renamedStat.ctimeMs; + rewrittenAfterRename = true; + }, + truncate: path => truncateSync(path, 0), + unlink: path => unlinkSync(path), + }; + const manager = new NativeProfileManager({ + ...f.options, + nativeAuthWriteIO: ctimeAfterSampleIO, + onSwitchBoundary: boundary => { + if (boundary === "auth-replaced") { + switchBoundaryReached = true; + successfulAuthReceipts++; + throw new Error("injected failure after the target receipt"); + } + }, + }); + + const epochBefore = codexCredentialMutationEpoch(); + let caught: unknown; + try { await manager.switch("work", true); } catch (error) { caught = error; } + + expect(rewrittenAfterRename).toBe(true); + expect(ctimeOnlyRewriteObserved).toBe(true); + expect(caught).toBeInstanceOf(NativeProfileError); + expect((caught as NativeProfileError).code).toBe("RECOVERY_REQUIRED"); + expect(switchBoundaryReached).toBe(false); + expect(successfulAuthReceipts).toBe(0); + expect(rollbackAttempts).toBe(0); + expect(codexCredentialMutationEpoch()).toBeGreaterThanOrEqual(epochBefore + 1); + expect(readFileSync(authPath, "utf8")).toBe(f.target); + expect(existsSync(manager.context.journalPath)).toBe(true); + }); + test("explicit rollback recovery applies the native Codex process guard", async () => { const f = await enrolledFixture(); const authPath = f.manager.context.authPath; @@ -1200,15 +1420,16 @@ describe("native main profile transactions", () => { let authWrites = 0; const interrupted = new NativeProfileManager({ ...f.options, - atomicWrite: async (path, content) => { - if (path === authPath) { - authWrites += 1; - if (authWrites > 1) throw new Error("injected restore failure"); - } + nativeAuthWriteIO: nativeAuthWriteIO(authPath, content => { + authWrites += 1; + if (authWrites > 1) throw new Error("injected restore failure"); + return undefined; + }), + atomicWrite: async (path, content, hooks) => { if (path === journalPath && hasJournalPhase(content, "auth-replaced")) { throw new Error("injected post-replacement failure"); } - return atomic(path, content); + return atomic(path, content, hooks); }, }); let switchError: unknown; @@ -1292,8 +1513,8 @@ describe("native main profile transactions", () => { const f = await enrolledFixture(); await leavePendingJournal(f); const refreshedTarget = envelope("account-target", "target-refreshed"); - writeFileSync(f.manager.context.authPath, refreshedTarget); const epochBefore = codexCredentialMutationEpoch(); + writeNativeMainAuthFileForTests(f.manager.context.authPath, refreshedTarget); const result = await f.manager.recover(true, true); @@ -1319,20 +1540,20 @@ describe("native main profile transactions", () => { ); try { expect(decrypted.text).toBe(refreshedTarget); } finally { decrypted.raw.fill(0); key!.key.fill(0); } expect(f.transitions).toEqual(["account-target->account-source"]); - expect(codexCredentialMutationEpoch()).toBe(epochBefore + 1); + expect(codexCredentialMutationEpoch()).toBe(epochBefore + 2); }); test("rollback preservation failure leaves refreshed target auth untouched and journaled", async () => { const f = await enrolledFixture(); await leavePendingJournal(f); const refreshedTarget = envelope("account-target", "target-refreshed-failure"); - writeFileSync(f.manager.context.authPath, refreshedTarget); + writeNativeMainAuthFileForTests(f.manager.context.authPath, refreshedTarget); const vaultBefore = readFileSync(f.manager.context.vaultPath, "utf8"); const failing = new NativeProfileManager({ ...f.options, - atomicWrite: async (path, content) => { + atomicWrite: async (path, content, hooks) => { if (path === f.manager.context.vaultPath) throw new Error("injected rollback vault failure"); - return atomic(path, content); + return atomic(path, content, hooks); }, }); @@ -1370,7 +1591,7 @@ describe("native main profile transactions", () => { const f = await enrolledFixture(); const captured: NativeEnvelopeSnapshot[] = []; const manager = new NativeProfileManager({ ...f.options, readEnvelope: captureEnvelopes(captured) }); - writeFileSync(f.manager.context.authPath, f.target); + writeNativeMainAuthFileForTests(f.manager.context.authPath, f.target); let caught: unknown; try { await manager.prepareStage(); } catch (error) { caught = error; } @@ -1401,7 +1622,7 @@ describe("native main profile transactions", () => { const f = await enrolledFixture(); await leavePendingJournal(f); const issuedBefore = f.keyProvider.issuedKeys.length; - writeFileSync(f.manager.context.authPath, "{}\n"); + writeNativeMainAuthFileForTests(f.manager.context.authPath, "{}\n"); let caught: unknown; try { await f.manager.recover(true, true); } catch (error) { caught = error; } diff --git a/tests/codex-integration/native-profile-startup.test.ts b/tests/codex-integration/native-profile-startup.test.ts index 89e871c0bbf..e1473584163 100644 --- a/tests/codex-integration/native-profile-startup.test.ts +++ b/tests/codex-integration/native-profile-startup.test.ts @@ -20,6 +20,7 @@ import { readNativeEnvelope, readNativeProfileVault, inspectNativeProfileJournal, + resolveNativeProfileContext, serializeNativeProfileJournal, } from "../../src/codex/native-profile-store"; import type { @@ -506,12 +507,20 @@ describe("native-main startup journal gate", () => { test("journal recovery opens only after the post-recovery probe reaches none", async () => { const states = ["journal", "none"] as const; let recoverCalls = 0; - const homeId = "home-journal-recovered"; + const root = mkdtempSync(join(tmpdir(), "ocx-startup-journal-recovered-")); + roots.push(root); + const codexHome = join(root, "codex"); + const configDir = join(root, "opencodex"); + mkdirSync(codexHome, { recursive: true }); + mkdirSync(configDir, { recursive: true }); + const context = resolveNativeProfileContext({ codexHome, configDir }); + const homeId = context.homeId; + const manager = { + context, + recover: async () => { recoverCalls += 1; return { recovered: true }; }, + } as unknown as NativeProfileManager; const gate = await initializeNativeMainStartupGate({ - manager: { - context: { homeId }, - recover: async () => { recoverCalls += 1; return { recovered: true }; }, - } as unknown as NativeProfileManager, + manager, probeRecoveryState: () => states.shift() ?? "none", }); expect(gate).toEqual({ status: "ready", homeId }); diff --git a/tests/codex-integration/native-ultrafast-picker.test.ts b/tests/codex-integration/native-ultrafast-picker.test.ts index c7e03e887c0..a363f736b3f 100644 --- a/tests/codex-integration/native-ultrafast-picker.test.ts +++ b/tests/codex-integration/native-ultrafast-picker.test.ts @@ -235,7 +235,7 @@ describe("an Ultrafast request on the native route", () => { async function outboundTier(fastMode: boolean | undefined, callerTier: string | undefined): Promise { const token = `header.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1_000) + 86_400 })).toString("base64url")}.signature`; - writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: token, account_id: "ultrafast-main" } })); + writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: token, refresh_token: "synthetic-refresh", account_id: "ultrafast-main" } })); const sent: Record[] = []; const upstream = { id: "resp_ultrafast", object: "response", status: "completed", model: "gpt-6.1-sol", diff --git a/tests/codex-integration/orca-import.test.ts b/tests/codex-integration/orca-import.test.ts index b2a122ed011..4f8b48b8ce2 100644 --- a/tests/codex-integration/orca-import.test.ts +++ b/tests/codex-integration/orca-import.test.ts @@ -1,11 +1,14 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { writeNativeMainAuthFileForTests } from "../helpers/native-main-auth"; import { randomUUID } from "node:crypto"; -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { loadConfig, saveConfig, setPersistedConfigMutationBeforeCommitForTests } from "../../src/config"; import { parseOrcaAuth, readBoundedLocalFile, assertPlainLocalPath } from "../../src/codex/orca-auth-source"; -import { importOrcaAccounts } from "../../src/codex/orca-import"; +import { importOrcaAccounts, observeOrcaMainAuthMemory } from "../../src/codex/orca-import"; +import { getNativeMainAuthMemory, stopNativeMainCredentialWatchers } from "../../src/codex/native-main-credentials"; +import { resetOptionalShutdownHooksForTests, runOptionalShutdownHooks } from "../../src/lib/optional-shutdown-hooks"; import { capturePoolQuotaWriter, forceRefreshCodexPoolToken, getCodexAccountCredential, getValidCodexToken, isPoolQuotaWriterLive, markCodexAccountValidated, poolQuotaHistoryIdentity, readCodexAccountRecord } from "../../src/codex/account-store"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import { flushConfigDirHardeningForTests } from "../../src/config/paths"; @@ -40,6 +43,7 @@ function store() { return JSON.parse(readFileSync(join(target, "codex-accounts.j function importedId() { return Object.keys(store())[0]!; } beforeEach(() => { + resetOptionalShutdownHooksForTests(); originalHome = process.env.OPENCODEX_HOME; originalCodexHome = process.env.CODEX_HOME; const ok = { success: true, exitCode: 0, timedOut: false, stdout: "" }; @@ -53,8 +57,11 @@ beforeEach(() => { writeFileSync(registry, JSON.stringify({ settings: { codexManagedAccounts: [] } })); process.env.OPENCODEX_HOME = target; process.env.CODEX_HOME = main; saveConfig(loadConfig()); + writeNativeMainAuthFileForTests(join(main, "auth.json"), auth("main-existing")); }); afterEach(async () => { + stopNativeMainCredentialWatchers(); + resetOptionalShutdownHooksForTests(); await flushConfigDirHardeningForTests(); setIcaclsRunnerForTests(null); setAsyncIcaclsRunnerForTests(null); setPersistedConfigMutationBeforeCommitForTests(null); @@ -89,11 +96,64 @@ describe("offline Orca import", () => { }); test("deduplicates main and orphan credential records", () => { addSource(auth("main-identity")); addSource(auth("orphan-identity")); - writeFileSync(join(main, "auth.json"), auth("main-identity")); + writeNativeMainAuthFileForTests(join(main, "auth.json"), auth("main-identity")); writeFileSync(join(target, "codex-accounts.json"), JSON.stringify({ orphan: { accessToken: "fixture", refreshToken: "fixture", expiresAt: 0, chatgptAccountId: "orphan-identity" } })); expect(importOrcaAccounts({ sourceDir: source, registryPath: registry, apply: true })).toMatchObject({ eligible: 0, duplicates: 2, imported: 0 }); }); + test("main identity reads reject a symlinked auth.json", () => { + addSource(); + const authPath = join(main, "auth.json"); + const linkedPath = join(main, "linked-main-auth.json"); + const external = auth("main-identity"); + writeFileSync(linkedPath, external); + unlinkSync(authPath); + symlinkSync(linkedPath, authPath); + + expect(() => importOrcaAccounts({ sourceDir: source, registryPath: registry })).toThrow("Main Codex identity is unknown; import refused."); + expect(lstatSync(authPath).isSymbolicLink()).toBe(true); + expect(readFileSync(linkedPath, "utf8")).toBe(external); + }); + test("main identity reads reject a resolved parent replaced by a symlink", () => { + const authPath = join(main, "auth.json"); + writeNativeMainAuthFileForTests(authPath, auth("main-identity")); + const warm = getNativeMainAuthMemory(authPath); + warm.snapshot?.raw.fill(0); + warm.raw?.fill(0); + + const moved = join(scratch, "moved-main"); + renameSync(main, moved); + symlinkSync(moved, main, process.platform === "win32" ? "junction" : "dir"); + + expect(() => observeOrcaMainAuthMemory(authPath)) + .toThrow("Main Codex identity is unknown; import refused."); + expect(lstatSync(main).isSymbolicLink()).toBe(true); + expect(readFileSync(join(moved, "auth.json"), "utf8")).toBe(auth("main-identity")); + }); + test("stopped main-auth memory refuses Orca preparation", () => { + addSource(); + runOptionalShutdownHooks(); + expect(() => importOrcaAccounts({ sourceDir: source, registryPath: registry })) + .toThrow("Main Codex identity is unknown; import refused."); + }); + test("no main login at all lets preview and apply continue", () => { + addSource(); + const emptyHome = join(scratch, "empty-main"); + mkdirSync(emptyHome); + process.env.CODEX_HOME = emptyHome; + expect(importOrcaAccounts({ sourceDir: source, registryPath: registry })).toMatchObject({ mode: "preview", eligible: 1, imported: 0 }); + expect(importOrcaAccounts({ sourceDir: source, registryPath: registry, apply: true })).toMatchObject({ imported: 1 }); + }); + test("a main login retained through the deletion grace still refuses the import", () => { + addSource(); + const authPath = join(main, "auth.json"); + const warm = getNativeMainAuthMemory(authPath); + warm.snapshot?.raw.fill(0); + warm.raw?.fill(0); + unlinkSync(authPath); + expect(() => importOrcaAccounts({ sourceDir: source, registryPath: registry })) + .toThrow("Main Codex identity is unknown; import refused."); + }); test("retry completes interrupted registration using the same untouched credential record", () => { const path = addSource(); const options = { sourceDir: source, registryPath: registry }; @@ -126,7 +186,7 @@ describe("offline Orca import", () => { if (scenario === "path") record.credential.sourceAuthPath = join(source, "other", "auth.json"); if (scenario === "refresh-grant") record.credential.refreshToken = "synthetic-owned-grant"; if (scenario === "another-orphan") records.ordinary = { ...record }; - if (scenario === "main") writeFileSync(join(main, "auth.json"), auth()); + if (scenario === "main") writeNativeMainAuthFileForTests(join(main, "auth.json"), auth()); const before = JSON.stringify(records, null, 2) + "\n"; writeFileSync(join(target, "codex-accounts.json"), before); expect(importOrcaAccounts(options)).toMatchObject({ imported: 0, eligible: 0, duplicates: 1 }); @@ -138,7 +198,7 @@ describe("offline Orca import", () => { expect(importOrcaAccounts({ sourceDir: source, registryPath: registry })).toMatchObject({ invalid: 2, eligible: 0, invalidReasons: { source_invalid: 2 }, }); - writeFileSync(join(main, "auth.json"), "not json"); + writeNativeMainAuthFileForTests(join(main, "auth.json"), "not json"); expect(() => importOrcaAccounts({ sourceDir: source, registryPath: registry, apply: true })).toThrow(); }); test("reports fixed reason codes without source details", () => { diff --git a/tests/codex-integration/reserve-passive-revocation.test.ts b/tests/codex-integration/reserve-passive-revocation.test.ts index 47fe15ffc07..d32e43dc847 100644 --- a/tests/codex-integration/reserve-passive-revocation.test.ts +++ b/tests/codex-integration/reserve-passive-revocation.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fetchMainAccountInfo } from "../../src/codex/auth-api"; @@ -18,7 +18,14 @@ import { } from "../../src/codex/reserve-availability"; import { flushConfigDirHardeningForTests } from "../../src/config/paths"; import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { + initializeNativeMainStartupGate, + nativeMainStartupGateSnapshot, +} from "../../src/codex/native-profile-startup"; +import { stopNativeMainCredentialWatchers } from "../../src/codex/native-main-credentials"; +import { resetOptionalShutdownHooksForTests } from "../../src/lib/optional-shutdown-hooks"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { writeNativeMainAuthFileForTests } from "../helpers/native-main-auth"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const ACCOUNT = "fixture-passive-reserve-main"; @@ -35,9 +42,9 @@ function deferred() { return { promise, resolve }; } -function writeCredential(accessToken: string): void { - writeFileSync(join(directory, "auth.json"), JSON.stringify({ tokens: { - access_token: accessToken, account_id: ACCOUNT, +function writeCredential(directory: string, accessToken: string): void { + writeNativeMainAuthFileForTests(join(directory, "auth.json"), JSON.stringify({ tokens: { + access_token: accessToken, refresh_token: `${accessToken}-refresh`, account_id: ACCOUNT, } })); } @@ -48,11 +55,15 @@ function ordinaryResponse(): Response { }); } -beforeEach(() => { +beforeEach(async () => { + stopNativeMainCredentialWatchers(); + resetOptionalShutdownHooksForTests(); + resetLifecycleDrainStateForTests(); previousHome = process.env.OPENCODEX_HOME; previousCodexHome = process.env.CODEX_HOME; previousFetch = globalThis.fetch; directory = mkdtempSync(join(tmpdir(), "ocx-reserve-passive-")); + expect(readdirSync(directory)).toEqual([]); process.env.OPENCODEX_HOME = directory; process.env.CODEX_HOME = directory; clearAccountQuota(); @@ -63,6 +74,9 @@ beforeEach(() => { const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; setIcaclsRunnerForTests(() => aclOk); setAsyncIcaclsRunnerForTests(async () => aclOk); + const startup = await initializeNativeMainStartupGate(); + expect(startup.status).toBe("ready"); + expect(startup).toEqual(nativeMainStartupGateSnapshot()); }); afterEach(async () => { @@ -72,6 +86,8 @@ afterEach(async () => { clearMainAccountInfoCache(); resetMainCodexAccountIdentityTrackingForTests(); resetLifecycleDrainStateForTests(); + stopNativeMainCredentialWatchers(); + resetOptionalShutdownHooksForTests(); setMainAccountPlan(null); try { await flushConfigDirHardeningForTests(); @@ -83,12 +99,16 @@ afterEach(async () => { if (previousCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; removeTreeWithRetry(directory); + await initializeNativeMainStartupGate({ + manager: { context: { homeId: null } } as never, + probeRecoveryState: () => "none", + }); } }); describe("passive WHAM Reserve revocation producer", () => { test.each([false, true])("late A cannot revoke the new grant after token replacement, return to A=%s", async returnToA => { - writeCredential(TOKEN_A); + writeCredential(directory, TOKEN_A); const started = deferred(); const delayed = deferred(); let passiveCalls = 0; @@ -123,9 +143,9 @@ describe("passive WHAM Reserve revocation producer", () => { const oldWriter = captureMainQuotaWriter(ACCOUNT); const oldEpoch = getMainQuotaCredentialGeneration(); expect(oldWriter).toBeDefined(); - writeCredential(TOKEN_B); + writeCredential(directory, TOKEN_B); observeMainQuotaCredential(TOKEN_B, ACCOUNT); - if (returnToA) writeCredential(TOKEN_A); + if (returnToA) writeCredential(directory, TOKEN_A); const writer = observeMainQuotaCredential(currentToken, ACCOUNT); expect(writer).toEqual(oldWriter); // Workspace identity did not change. expect(getMainQuotaCredentialGeneration()).toBeGreaterThan(oldEpoch); diff --git a/tests/codex-integration/token-guardian.test.ts b/tests/codex-integration/token-guardian.test.ts index 8edae37188e..f6aceb357cd 100644 --- a/tests/codex-integration/token-guardian.test.ts +++ b/tests/codex-integration/token-guardian.test.ts @@ -170,7 +170,7 @@ describe("token guardian", () => { const mock = mockFetchOk(OK_TOKEN); writeConfig({ tokenGuardian: { enabled: true, tickSeconds: 60, leadSeconds: 60 }, - providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive" } }, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive", upstreamWebsocket: false,} }, }); saveCodexAccountCredential("acct-1", { accessToken: "old", refreshToken: "rt", expiresAt: Date.now() + 5_000, chatgptAccountId: "cg-1", @@ -183,7 +183,7 @@ describe("token guardian", () => { test.each(["response.completed", "response.failed"])("a late guardian probe cannot change a replacement pending credential (%s)", async terminal => { writeConfig({ tokenGuardian: { enabled: true, codexWarmupEnabled: true, tickSeconds: 60, leadSeconds: 60 }, - providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive" } }, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive", upstreamWebsocket: false,} }, }); const credential = { accessToken: "original", refreshToken: "rt-original", expiresAt: Date.now() + 3600_000, chatgptAccountId: "cg-pending", @@ -204,7 +204,7 @@ describe("token guardian", () => { const mock = mockWarmupFetch(); writeConfig({ tokenGuardian: { enabled: true, codexWarmupEnabled: true, tickSeconds: 60, leadSeconds: 60 }, - providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive" } }, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive", upstreamWebsocket: false,} }, }); const credential = { accessToken: "original", refreshToken: "rt-original", expiresAt: Date.now() + 3600_000, chatgptAccountId: "cg-pending", @@ -222,7 +222,7 @@ describe("token guardian", () => { const mock = mockWarmupFetch(); writeConfig({ tokenGuardian: { enabled: true, codexWarmupEnabled: true, tickSeconds: 60, leadSeconds: 60 }, - providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive" } }, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive", upstreamWebsocket: false,} }, }); saveCodexAccountCredential("acct-pending", { accessToken: "pending", refreshToken: "rt-pending", expiresAt: Date.now() + 3600_000, chatgptAccountId: "cg-pending", @@ -237,7 +237,7 @@ describe("token guardian", () => { const mock = mockWarmupFetch(); writeConfig({ tokenGuardian: { enabled: true, tickSeconds: 60, leadSeconds: 60 }, - providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive" } }, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive", upstreamWebsocket: false,} }, }); saveCodexAccountCredential("acct-stale", { accessToken: "old", refreshToken: "rt", expiresAt: Date.now() + 3600_000, chatgptAccountId: "cg-1", @@ -257,7 +257,7 @@ describe("token guardian", () => { codexWarmupEnabled: true, codexWarmupMaxAgeSeconds: 60, }, - providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive" } }, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive", upstreamWebsocket: false,} }, }); saveCodexAccountCredential("acct-warm", { accessToken: "old", refreshToken: "rt", expiresAt: Date.now() + 3600_000, chatgptAccountId: "cg-1", @@ -279,7 +279,7 @@ describe("token guardian", () => { test("a revoked refresh grant persists a terminal verdict even with warmup disabled", async () => { writeConfig({ tokenGuardian: { enabled: true, tickSeconds: 60, leadSeconds: 60 }, - providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive" } }, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive", upstreamWebsocket: false,} }, }); saveCodexAccountCredential("acct-revoked", { accessToken: "old", refreshToken: "rt", expiresAt: Date.now() + 5_000, chatgptAccountId: "cg-1", @@ -301,7 +301,7 @@ describe("token guardian", () => { test("a transient refresh failure leaves the stored verdict untouched", async () => { writeConfig({ tokenGuardian: { enabled: true, tickSeconds: 60, leadSeconds: 60 }, - providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive" } }, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive", upstreamWebsocket: false,} }, }); saveCodexAccountCredential("acct-transient", { accessToken: "old", refreshToken: "rt", expiresAt: Date.now() + 5_000, chatgptAccountId: "cg-1", @@ -324,7 +324,7 @@ describe("token guardian", () => { test("a credential replaced mid-refresh is not branded by the previous credential's failure", async () => { writeConfig({ tokenGuardian: { enabled: true, tickSeconds: 60, leadSeconds: 60 }, - providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive" } }, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive", upstreamWebsocket: false,} }, }); saveCodexAccountCredential("acct-replaced", { accessToken: "old", refreshToken: "rt", expiresAt: Date.now() + 5_000, chatgptAccountId: "cg-1", @@ -356,7 +356,7 @@ describe("token guardian", () => { const accountStore = join(tmp, "ocx", "codex-accounts.json"); writeFileSync(accountStore, "invalid-added-store"); writeFileSync(join(tmp, "codex", "auth.json"), JSON.stringify({ - tokens: { access_token: "main-access", account_id: "main-chatgpt-id" }, + tokens: { access_token: "main-access", refresh_token: "main-refresh", account_id: "main-chatgpt-id" }, })); const mock = mockWarmupFetch(); writeConfig({ @@ -366,7 +366,7 @@ describe("token guardian", () => { leadSeconds: 60, codexWarmupEnabled: true, }, - providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", refreshPolicy: "proactive" } }, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", refreshPolicy: "proactive", upstreamWebsocket: false,} }, }); const res = await guardianSweep(Date.now()); @@ -378,7 +378,7 @@ describe("token guardian", () => { test("main warmup owns native main through async work and defers while a switch fence is active", async () => { writeFileSync(join(tmp, "codex", "auth.json"), JSON.stringify({ - tokens: { access_token: "main-owned", account_id: "main-owned-account" }, + tokens: { access_token: "main-owned", refresh_token: "main-owned-refresh", account_id: "main-owned-account" }, })); writeConfig({ tokenGuardian: { @@ -394,6 +394,7 @@ describe("token guardian", () => { authMode: "forward", codexAccountMode: "direct", refreshPolicy: "proactive", + upstreamWebsocket: false, }, }, }); diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index 5e7d4dd2f99..693f8611a9e 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -12,6 +12,7 @@ import { validateConfigCandidate, } from "../../src/config"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { resolveNativeMainAuthGraceMs } from "../../src/config/schema/native-main-auth-grace"; let home = ""; let previousHome: string | undefined; @@ -550,3 +551,34 @@ test("a malformed Aside profile sync policy warns once without echoing it and ke } } finally { warn.mockRestore(); } }); + +test("load keeps a valid nativeMainAuthGraceSeconds and ignores an invalid one with a warning", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + writeFileSync(getConfigPath(), JSON.stringify({ ...getDefaultConfig(), nativeMainAuthGraceSeconds: 5 }), "utf8"); + const loaded = loadConfig(); + expect(loaded.nativeMainAuthGraceSeconds).toBe(5); + expect(resolveNativeMainAuthGraceMs(loaded)).toBe(5_000); + writeFileSync(getConfigPath(), JSON.stringify(getDefaultConfig()), "utf8"); + expect(resolveNativeMainAuthGraceMs(loadConfig())).toBe(60_000); + for (const invalid of [0, -1, 1.5, 86_401, "60"]) { + warn.mockClear(); + writeFileSync(getConfigPath(), JSON.stringify({ ...getDefaultConfig(), nativeMainAuthGraceSeconds: invalid }), "utf8"); + const degraded = loadConfig(); + expect(degraded.nativeMainAuthGraceSeconds).toBeUndefined(); + expect(warn.mock.calls.flat().join("\n")).toContain("nativeMainAuthGraceSeconds"); + expect(resolveNativeMainAuthGraceMs(degraded)).toBe(60_000); + } + } finally { warn.mockRestore(); } +}); + +test("config validation rejects nativeMainAuthGraceSeconds outside integers from 1 to 86400", () => { + for (const valid of [1, 60, 86_400]) { + expect(validateConfigCandidate({ ...getDefaultConfig(), nativeMainAuthGraceSeconds: valid }).ok).toBe(true); + } + for (const invalid of [0, -1, 1.5, 86_401, "60", NaN]) { + const result = validateConfigCandidate({ ...getDefaultConfig(), nativeMainAuthGraceSeconds: invalid }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("schema_invalid: nativeMainAuthGraceSeconds"); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3a82672e452..8702c2d9818 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -531,6 +531,8 @@ "codex-log-guard-processes.test.ts": "codex-integration", "codex-log-guard-protection.test.ts": "codex-integration", "codex-log-guard-status-zero-write.test.ts": "codex-integration", "codex-main-account-refresh.test.ts": "codex-integration", "codex-main-rotation.test.ts": "codex-integration", + "native-main-credential-memory.test.ts": "codex-integration", + "native-main-credential-literal-guard.test.ts": "codex-integration", "codex-management-convergence.test.ts": "codex-integration", "codex-metadata-integrity.test.ts": "codex-integration", "codex-model-availability-error.test.ts": "codex-integration", "codex-model-denial-evidence.test.ts": "codex-integration", diff --git a/tests/helpers/codex-pool-retry.ts b/tests/helpers/codex-pool-retry.ts index 3b8dddc3512..0ef5c24349c 100644 --- a/tests/helpers/codex-pool-retry.ts +++ b/tests/helpers/codex-pool-retry.ts @@ -18,10 +18,11 @@ export function createPoolRetryHarness(context: { testDir: string; originalFetch: typeof fetch; redirectCanonicalCodexTo: (baseUrl: string) => void; + trackServer: (server: ReturnType | ReturnType) => void; canonicalDirect: OcxProviderConfig; }) { const { testDir: TEST_DIR, originalFetch: originalGlobalFetch, - redirectCanonicalCodexTo, canonicalDirect } = context; + redirectCanonicalCodexTo, trackServer, canonicalDirect } = context; function unsupportedModelBody(model = POOL_RETRY_MODEL): string { return JSON.stringify({ @@ -118,6 +119,7 @@ export function createPoolRetryHarness(context: { return reply(accountId, request); }, }); + trackServer(upstream); redirectCanonicalCodexTo(upstream.url.toString()); const redirectedFetch = globalThis.fetch; @@ -129,6 +131,7 @@ export function createPoolRetryHarness(context: { providers: { openai: { ...canonicalDirect, + upstreamWebsocket: false, codexAccountMode: options.accountMode ?? "pool", ...(options.noVisionModels ? { noVisionModels: options.noVisionModels } : {}), ...(options.forwardApiKey ? { apiKey: options.forwardApiKey } : {}), @@ -173,6 +176,7 @@ export function createPoolRetryHarness(context: { for (const accountId of options.reauthAccountIds ?? []) markAccountNeedsReauth(accountId); const server = startServer(0); + trackServer(server); return { config, dispatches, @@ -205,9 +209,9 @@ export function createPoolRetryHarness(context: { } async function stopPoolRetryHarness(harness: PoolRetryHarness): Promise { - harness.restoreFetch(); await harness.server.stop(true); await harness.upstream.stop(true); + harness.restoreFetch(); } function rejectionResponse(body: BodyInit, headers: Record = {}): Response { diff --git a/tests/helpers/main-account-policy-startup-child.ts b/tests/helpers/main-account-policy-startup-child.ts index 0b1a48cc98e..5b48b201932 100644 --- a/tests/helpers/main-account-policy-startup-child.ts +++ b/tests/helpers/main-account-policy-startup-child.ts @@ -57,6 +57,16 @@ const { handleResponses } = await import("../../src/server/responses/core"); const { loadConfig, saveConfig } = await import("../../src/config"); let config = loadConfig(); +if (config.providers.openai?.adapter === "openai-responses" && config.providers.openai.authMode === "forward") { + config = { + ...config, + providers: { + ...config.providers, + openai: { ...config.providers.openai, upstreamWebsocket: false }, + }, + }; + saveConfig(config); +} const observe = () => ({ matched: matchesMainQuotaCredential(fixture.bearer, fixture.accountId), policy: getMainPolicyQuota(), @@ -275,7 +285,9 @@ try { 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 } })); + writeFileSync(manager.context.authPath, JSON.stringify({ tokens: { + access_token: otherBearer, refresh_token: "hard-lock-other-refresh", account_id: otherAccountId, + } })); servers.push(start()); await waitForNativeMainStartupGate(); validReplacement = { oldMatched: matchesMainQuotaCredential(fixture.bearer, fixture.accountId), diff --git a/tests/helpers/native-main-auth.ts b/tests/helpers/native-main-auth.ts new file mode 100644 index 00000000000..be976b4d4a0 --- /dev/null +++ b/tests/helpers/native-main-auth.ts @@ -0,0 +1,53 @@ +import { createHash } from "node:crypto"; +import { fstatSync, readFileSync, writeFileSync } from "node:fs"; +import type { AtomicWritePublicationEvidence, AtomicWriteTargetIdentity } from "../../src/config/atomic-write"; +import { observeNativeMainAuthFile } from "../../src/codex/native-main-credentials"; + +export function observeNativeMainAuthFileForTests(authPath: string): void { + const observation = observeNativeMainAuthFile(authPath); + observation.raw?.fill(0); + observation.snapshot?.raw.fill(0); +} + +export function writeNativeMainAuthFileForTests(authPath: string, raw: string): void { + writeFileSync(authPath, raw); + observeNativeMainAuthFileForTests(authPath); +} + +export function writeNativeMainAuthTokensForTests(authPath: string, tokens: Record): void { + writeNativeMainAuthFileForTests(authPath, JSON.stringify({ tokens })); +} + +export function captureAtomicWritePublicationEvidence(descriptor: number): AtomicWritePublicationEvidence { + const before = identityFromDescriptor(descriptor); + const bytes = readFileSync(descriptor); + const digest = createHash("sha256").update(bytes).digest("hex"); + const after = identityFromDescriptor(descriptor); + const size = bytes.byteLength; + bytes.fill(0); + if (!sameIdentity(before, after) || after.size !== size) { + throw new Error("test atomic publication changed while evidence was captured"); + } + return { identity: after, digest }; +} + +function identityFromDescriptor(descriptor: number): AtomicWriteTargetIdentity { + const stat = fstatSync(descriptor); + return { + dev: Number(stat.dev), + ino: Number(stat.ino), + size: Number(stat.size), + mode: Number(stat.mode), + mtimeMs: Number(stat.mtimeMs), + ctimeMs: Number(stat.ctimeMs), + }; +} + +function sameIdentity(left: AtomicWriteTargetIdentity, right: AtomicWriteTargetIdentity): boolean { + return left.dev === right.dev + && left.ino === right.ino + && left.size === right.size + && left.mode === right.mode + && left.mtimeMs === right.mtimeMs + && left.ctimeMs === right.ctimeMs; +} diff --git a/tests/helpers/native-main-owner-child.ts b/tests/helpers/native-main-owner-child.ts index 250a4529d75..1f3312dcec2 100644 --- a/tests/helpers/native-main-owner-child.ts +++ b/tests/helpers/native-main-owner-child.ts @@ -1,11 +1,24 @@ -import { appendFileSync, realpathSync } from "node:fs"; +import { + appendFileSync, + chmodSync, + closeSync, + fchmodSync, + fsyncSync, + openSync, + realpathSync, + renameSync, + truncateSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { basename, join, resolve } from "node:path"; import { createInterface } from "node:readline"; -import { atomicWriteFileAsync } from "../../src/config"; import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; import { nativeMainOwnerSnapshot } from "../../src/codex/native-main-owner"; import { NativeProfileManager } from "../../src/codex/native-profile-manager"; +import { renameAtomicFile } from "../../src/lib/windows-atomic-replace"; +import { hardenSecretPathAsync } from "../../src/lib/windows-secret-acl"; import { nativeMainStartupGateSnapshot, } from "../../src/codex/native-profile-startup"; @@ -52,15 +65,43 @@ function managerFor(home = codexHome): NativeProfileManager { keyProvider: new EnvKeyProvider(), hardenPath: async () => {}, processProbe: async () => ({ status: "clear", count: 0 }), - atomicWrite: holdAuthTemp - ? (path, content) => atomicWriteFileAsync(path, content, undefined, { - afterTempWrite: async tempPath => { - if (resolve(path) !== resolve(canonicalAuthPath)) return; - emit({ event: "auth-temp-written", name: basename(tempPath) }); - await authTempBarrier; + nativeAuthWriteIO: holdAuthTemp ? { + write: async (tempPath, content) => { + const descriptor = openSync(tempPath, "wx", 0o600); + try { + if (process.platform !== "win32") fchmodSync(descriptor, 0o600); + writeFileSync(descriptor, content, { encoding: "utf8" }); + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } + if (!resolve(tempPath).startsWith(`${resolve(canonicalAuthPath)}.ocx.`)) return; + emit({ event: "auth-temp-written", name: basename(tempPath) }); + await authTempBarrier; + }, + harden: async tempPath => { + if (process.platform === "win32") { + await hardenSecretPathAsync(tempPath, { required: true, timeoutMemoKey: canonicalAuthPath }); + return; + } + chmodSync(tempPath, 0o600); + }, + rename: (source, destination, afterReplace) => renameAtomicFile( + source, + destination, + { + platform: process.platform, + rename: (from, to) => { + renameSync(from, to); + afterReplace?.(); }, - }) - : undefined, + sleep: Bun.sleepSync, + }, + "config", + ), + truncate: target => truncateSync(target, 0), + unlink: unlinkSync, + } : undefined, onSwitchBoundary: async boundary => { if (boundary !== holdSwitchBoundary) return; emit({ event: "switch-boundary", boundary }); diff --git a/tests/helpers/stored-direct-identity.ts b/tests/helpers/stored-direct-identity.ts index 92f5e166986..3b4977f62dc 100644 --- a/tests/helpers/stored-direct-identity.ts +++ b/tests/helpers/stored-direct-identity.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; -import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { materializeCodexUpstreamAuth, materializeCodexUpstreamAuthAsync } from "../../src/codex/auth-context"; +import { writeNativeMainAuthFileForTests } from "./native-main-auth"; export function registerStoredDirectIdentityTests(getTestDir: () => string, liveJwt: () => string): void { test("an admission bearer on main substitutes the stored credential, never forwards it (#1686)", () => { @@ -9,8 +9,8 @@ export function registerStoredDirectIdentityTests(getTestDir: () => string, live // process, so the only acceptable outcome is the stored main credential in its place. const admissionSecret = "ocx_data_localsecret"; const storedCredential = liveJwt(); - writeFileSync(join(getTestDir(), "auth.json"), JSON.stringify({ - tokens: { access_token: storedCredential, account_id: "stored_main_acc" }, + writeNativeMainAuthFileForTests(join(getTestDir(), "auth.json"), JSON.stringify({ + tokens: { access_token: storedCredential, refresh_token: "stored-main-refresh", account_id: "stored_main_acc" }, })); const headers = materializeCodexUpstreamAuth( @@ -28,8 +28,8 @@ export function registerStoredDirectIdentityTests(getTestDir: () => string, live test("sync stored Direct substitution clears a missing account ID without changing inbound headers", () => { const storedCredential = liveJwt(); - writeFileSync(join(getTestDir(), "auth.json"), JSON.stringify({ - tokens: { access_token: storedCredential }, + writeNativeMainAuthFileForTests(join(getTestDir(), "auth.json"), JSON.stringify({ + tokens: { access_token: storedCredential, refresh_token: "stored-main-refresh" }, })); const inbound = new Headers({ authorization: "Bearer ocx_data_localsecret", @@ -55,8 +55,8 @@ export function registerStoredDirectIdentityTests(getTestDir: () => string, live ["present", "stored_main_acc", "stored_main_acc"], ])("async stored Direct substitution owns account identity when %s", async (_label, accountId, expectedAccountId) => { const storedCredential = liveJwt(); - writeFileSync(join(getTestDir(), "auth.json"), JSON.stringify({ - tokens: { access_token: storedCredential, account_id: accountId }, + writeNativeMainAuthFileForTests(join(getTestDir(), "auth.json"), JSON.stringify({ + tokens: { access_token: storedCredential, refresh_token: "stored-main-refresh", account_id: accountId }, })); const inbound = new Headers({ authorization: "Bearer ocx_data_localsecret", diff --git a/tests/responses/responses-preview-main-read-fence.test.ts b/tests/responses/responses-preview-main-read-fence.test.ts index bb3a95a7024..cbbadbb9189 100644 --- a/tests/responses/responses-preview-main-read-fence.test.ts +++ b/tests/responses/responses-preview-main-read-fence.test.ts @@ -1,6 +1,5 @@ import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import * as fs from "node:fs"; import { mkdtempSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -11,6 +10,7 @@ import { type CodexAuthContext, } from "../../src/codex/auth-context"; import { getMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; +import * as nativeMainCredentials from "../../src/codex/native-main-credentials"; import { clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; import { resetCodexModelEntitlementCacheForTests, @@ -72,9 +72,9 @@ const originalNow = Date.now; let testDir = ""; let previousOpenCodexHome: string | undefined; let previousCodexHome: string | undefined; -let authJsonReads = 0; -let authJsonReadStacks: string[] = []; -let readSpy: ReturnType | undefined; +let nativeMainMemoryReads = 0; +let nativeMainMemoryReadStacks: string[] = []; +let nativeMainReadSpy: ReturnType | undefined; let blockedHomeId: string | null = null; function providerConfig(overrides: Partial = {}): OcxConfig { @@ -90,6 +90,7 @@ function providerConfig(overrides: Partial = {}): OcxConfig { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", + upstreamWebsocket: false, }, xai: { adapter: "openai-chat", @@ -137,13 +138,13 @@ function calibrateMainReadCounter(): void { accessToken: "physical-main-token", chatgptAccountId: "physical-main-account", }); - expect(authJsonReads).toBeGreaterThan(0); + expect(nativeMainMemoryReads).toBeGreaterThan(0); resetMainReadObservations(); } function resetMainReadObservations(): void { - authJsonReads = 0; - authJsonReadStacks = []; + nativeMainMemoryReads = 0; + nativeMainMemoryReadStacks = []; } /** @@ -158,7 +159,7 @@ function resetMainReadObservations(): void { * say which fence failed. Every stack is kept for diagnostics either way. */ function denialCacheMainReadStacks(): string[] { - return authJsonReadStacks.filter(stack => + return nativeMainMemoryReadStacks.filter(stack => stack.replaceAll("\\", "/").includes("/src/codex/model-entitlements.ts")); } @@ -246,16 +247,15 @@ beforeEach(() => { resetAgentTaskRecoveryState(); installCredentials(); - const originalReadFileSync = fs.readFileSync as (...args: unknown[]) => unknown; - readSpy = spyOn(fs, "readFileSync"); - readSpy.mockImplementation(((...args: unknown[]) => { - const target = args[0]; - if (typeof target === "string" && target.endsWith("auth.json")) { - authJsonReads += 1; - authJsonReadStacks.push(new Error("auth.json read").stack ?? "stack unavailable"); + const originalGetNativeMainAuthMemory = nativeMainCredentials.getNativeMainAuthMemory; + nativeMainReadSpy = spyOn(nativeMainCredentials, "getNativeMainAuthMemory"); + nativeMainReadSpy.mockImplementation(((...args: Parameters) => { + if (typeof args[0] === "string" && args[0].endsWith("auth.json")) { + nativeMainMemoryReads += 1; + nativeMainMemoryReadStacks.push(new Error("native-main memory read").stack ?? "stack unavailable"); } - return originalReadFileSync(...args); - }) as unknown as typeof fs.readFileSync); + return originalGetNativeMainAuthMemory(...args); + }) as typeof originalGetNativeMainAuthMemory); resetMainReadObservations(); blockedHomeId = null; }); @@ -263,8 +263,8 @@ beforeEach(() => { afterEach(() => { globalThis.fetch = originalFetch; Date.now = originalNow; - readSpy?.mockRestore(); - readSpy = undefined; + nativeMainReadSpy?.mockRestore(); + nativeMainReadSpy = undefined; if (blockedHomeId !== null) completeNativeMainRecovery(blockedHomeId); blockedHomeId = null; clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); @@ -302,7 +302,7 @@ describe("preview and final authentication agree on the native-main read fence", primeCodexPoolQuotas: async () => {}, }); expect(owned).toMatchObject({ kind: "pool", accountId: "pool-a" }); - expect(authJsonReads).toBe(0); + expect(nativeMainMemoryReads).toBe(0); // The option is only a claim by the caller. Without the bearer final auth must reject that // claim, leave the ownership fence open, and perform the ordinary physical-main reads. @@ -311,7 +311,7 @@ describe("preview and final authentication agree on the native-main read fence", modelId: PREFERRED_MODEL, primeCodexPoolQuotas: async () => {}, }); - expect(authJsonReads).toBeGreaterThan(0); + expect(nativeMainMemoryReads).toBeGreaterThan(0); }); test("the initial preview excludes physical main from denial-cache credential validation", async () => { @@ -336,7 +336,7 @@ describe("preview and final authentication agree on the native-main read fence", /** * #4850. Pool eligibility was the last part of request preview outside the fence: with no * `isMainAccountTokenLive` in the preview options, `codexAccountUnusableReason` fell through to - * `isMainAccountCredentialUsable()` and opened the physical file, twice per spawn because + * `isMainAccountCredentialUsable()` and read the native-main memory store twice per spawn because * subagent fallback re-enters the preview through its callback. * * Asserted on the unfiltered counter on purpose. "The right credential was eventually sent" @@ -361,8 +361,8 @@ describe("preview and final authentication agree on the native-main read fence", expect(response.status).toBe(200); expect(upstreamAuth).toEqual([`Bearer ${POOL_ACCESS_TOKEN}`]); - expect(authJsonReadStacks).toEqual([]); - expect(authJsonReads).toBe(0); + expect(nativeMainMemoryReadStacks).toEqual([]); + expect(nativeMainMemoryReads).toBe(0); }); test("the initial preview also fences main for recovery blocking and selector drain", async () => { @@ -375,7 +375,7 @@ describe("preview and final authentication agree on the native-main read fence", expect(blockNativeMainRecovery(blockedHomeId)).toBe(true); const blockedResponse = await postSpawn(providerConfig(), {}, new Headers()); expect(blockedResponse.status).toBe(200); - expect(authJsonReads).toBe(0); + expect(nativeMainMemoryReads).toBe(0); expect(completeNativeMainRecovery(blockedHomeId)).toBe(true); blockedHomeId = null; @@ -394,7 +394,7 @@ describe("preview and final authentication agree on the native-main read fence", resetMainReadObservations(); await postSpawn(providerConfig(), { turnAdmissionLease }, new Headers()); expect(selectionStarts).toBeGreaterThan(0); - expect(authJsonReads).toBe(0); + expect(nativeMainMemoryReads).toBe(0); }); /** @@ -414,6 +414,7 @@ describe("preview and final authentication agree on the native-main read fence", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", + upstreamWebsocket: false, }, backup: { adapter: "openai-responses", @@ -468,7 +469,7 @@ describe("preview and final authentication agree on the native-main read fence", expect(recoveryCalls).toBe(1); expect(backupBodies).toHaveLength(1); expect(backupBodies[0]).toContain("Use the recovered assignment."); - expect(authJsonReads).toBe(0); + expect(nativeMainMemoryReads).toBe(0); }); /** @@ -572,7 +573,7 @@ describe("preview and final authentication agree on the native-main read fence", const response = await postSpawn(providerConfig(), {}, new Headers()); expect(response.status).toBe(200); - expect(authJsonReads).toBeGreaterThan(0); + expect(nativeMainMemoryReads).toBeGreaterThan(0); }); /** diff --git a/tests/server/server-auth.test.ts b/tests/server/server-auth.test.ts index 6fa2f8d87d6..06e671e7a44 100644 --- a/tests/server/server-auth.test.ts +++ b/tests/server/server-auth.test.ts @@ -38,7 +38,7 @@ import { resolveGuiFilePath, rootFallbackPayload, safeConfigDTO, - startServer, + startServer as startServerCore, } from "../../src/server"; import { clearRequestLogsForTests, getRequestLogEntries } from "../../src/server/request-log"; import { setRelayPlatformForTests } from "../../src/server/responses/passthrough-delivery"; @@ -46,6 +46,7 @@ import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { readUsageEntries } from "../../src/usage/log"; import { handleManagementAPI } from "../../src/server/management-api"; import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; +import { clearResponseStateForTests } from "../../src/responses/state"; import type { OcxConfig } from "../../src/types"; import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; @@ -79,6 +80,46 @@ const originalGlobalWebSocket = globalThis.WebSocket; const TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-server-auth-")); let isolatedCodexHome: IsolatedCodexHome | null = null; let managementFixture: ManagementServerFixture | null = null; +type CaseServer = ReturnType | ReturnType; +const caseServerCleanups: Array<() => Promise> = []; +const caseServerStops = new WeakMap>(); + +function trackCaseServer(server: T): T { + caseServerCleanups.push(() => stopCaseServer(server)); + return server; +} + +function trackCaseServerCleanup(cleanup: () => Promise): void { + caseServerCleanups.push(cleanup); +} + +function stopCaseServer(server: CaseServer): Promise { + const existing = caseServerStops.get(server); + if (existing) return existing; + const stopping = Promise.resolve().then(() => server.stop(true)); + caseServerStops.set(server, stopping); + return stopping; +} + +async function stopCaseServers(): Promise { + let stopError: unknown; + for (const cleanup of caseServerCleanups.splice(0).reverse()) { + try { + await cleanup(); + } catch (error) { + stopError ??= error; + } + } + if (stopError) throw stopError; +} + +function startServer(...args: Parameters): ReturnType { + return trackCaseServer(startServerCore(...args)); +} + +function startUpstream(options: Parameters[0]): ReturnType { + return trackCaseServer(Bun.serve(options)); +} const REMOTE_CATALOG_BYTES = '{"models":[{"slug":"fixture/model","display_name":"Fixture Model","priority":1,"visibility":"list","base_instructions":"Fixture instructions","input_modalities":["text"]}]}'; const REMOTE_DATA_KEY = "ocx_data_remote_catalog"; @@ -101,6 +142,7 @@ const canonicalDirect = { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", + upstreamWebsocket: false, } as const; function poolProviders(): OcxConfig["providers"] { @@ -126,10 +168,21 @@ beforeEach(() => { }); afterEach(async () => { + let cleanupError: unknown; if (managementFixture) { - await managementFixture.close(); + try { + await managementFixture.close(); + } catch (error) { + cleanupError ??= error; + } managementFixture = null; } + try { + await stopCaseServers(); + } catch (error) { + cleanupError ??= error; + } + clearResponseStateForTests(); globalThis.fetch = originalGlobalFetch; globalThis.WebSocket = originalGlobalWebSocket; if (previousApiToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; @@ -139,13 +192,34 @@ afterEach(async () => { else process.env.OPENCODEX_HOME = previousOpencodexHome; isolatedCodexHome?.restore(); isolatedCodexHome = null; + expect(spendLedgerOwnerSnapshot().ownership).toBe("unheld"); if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); if (existsSync(POOL_RETRY_TEST_DIR)) removeTreeWithRetry(POOL_RETRY_TEST_DIR); + if (cleanupError) throw cleanupError; }); -const { startPoolRetryHarness, stopPoolRetryHarness, rejectionResponse, expectOriginal400, unsupportedModelBody } = +const { + startPoolRetryHarness: startPoolRetryHarnessCore, + rejectionResponse, + expectOriginal400, + unsupportedModelBody, +} = createPoolRetryHarness({ testDir: POOL_RETRY_TEST_DIR, originalFetch: originalGlobalFetch, - redirectCanonicalCodexTo, canonicalDirect }); + redirectCanonicalCodexTo, trackServer: server => trackCaseServer(server), canonicalDirect }); +type PoolRetryHarness = Awaited>; + +function startPoolRetryHarness(...args: Parameters): Promise { + return startPoolRetryHarnessCore(...args); +} + +function stopPoolRetryHarness(harness: PoolRetryHarness): Promise { + return Promise.all([ + stopCaseServer(harness.server), + stopCaseServer(harness.upstream), + ]).then(() => { + harness.restoreFetch(); + }); +} describe("Responses request identity handoff", () => { test("returns the generated request id and overwrites an upstream value", async () => { @@ -308,7 +382,7 @@ describe("Responses request identity handoff", () => { expect(getRequestLogEntries()).toHaveLength(0); expect(readUsageEntries()).toHaveLength(0); } finally { - await server.stop(true); + await stopCaseServer(server); } }, { timeout: SERVER_BUDGET_MS }); }); @@ -482,7 +556,7 @@ describe("server local API auth", () => { test("responses handler accepts a combo body exactly once across failover children", async () => { const upstreamModels: string[] = []; - const upstream = Bun.serve({ + const upstream = startUpstream({ hostname: "127.0.0.1", port: 0, async fetch(request) { @@ -531,7 +605,7 @@ describe("server local API auth", () => { expect(upstreamModels).toEqual(["first-model", "second-model"]); await response.text(); } finally { - await upstream.stop(true); + await stopCaseServer(upstream); releaseSpendHome(); } }); @@ -625,7 +699,7 @@ describe("server local API auth", () => { expect((rejected.headers.get("access-control-allow-headers") ?? "").toLowerCase()) .not.toContain("x-stainless-lang"); } finally { - await server.stop(true); + await stopCaseServer(server); } }); @@ -825,6 +899,7 @@ describe("server local API auth", () => { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", + upstreamWebsocket: false, }, }, } as OcxConfig); @@ -851,7 +926,7 @@ describe("server local API auth", () => { }); expect(sameOrigin.status).toBe(200); } finally { - await server.stop(true); + await stopCaseServer(server); } }, 15_000); @@ -891,7 +966,7 @@ describe("server local API auth", () => { }); expect(ok.status).toBe(200); } finally { - await server.stop(true); + await stopCaseServer(server); } }); @@ -940,7 +1015,7 @@ describe("server local API auth", () => { expect(healthBody.guiPairCapability).toBe(GUI_PAIR_CAPABILITY_VERSION); expect("rss" in healthBody).toBe(false); } finally { - await server.stop(true); + await stopCaseServer(server); } }); @@ -976,7 +1051,7 @@ describe("server local API auth", () => { expect(allowedHeaders).toContain("X-OpenCodex-CSRF-Token"); expect(allowedHeaders).not.toContain("X-Unrelated-Custom-Header"); } finally { - await server.stop(true); + await stopCaseServer(server); } }); @@ -1062,7 +1137,7 @@ describe("server local API auth", () => { "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ); } finally { - await server.stop(true); + await stopCaseServer(server); } }); @@ -1085,13 +1160,15 @@ describe("server local API auth", () => { expect(response.status).toBe(403); expect(await response.json()).toMatchObject({ error: "cross-origin request blocked" }); } finally { - await server.stop(true); + await stopCaseServer(server); } }); describe("management CORS fixture", () => { beforeEach(async () => { - managementFixture = await startManagementServerFixture(TEST_DIR, config("127.0.0.1")); + const fixture = await startManagementServerFixture(TEST_DIR, config("127.0.0.1")); + managementFixture = fixture; + trackCaseServerCleanup(() => fixture.close()); }); test("management CORS echoes validated loopback Origin and covers delegated codex-auth responses", async () => { @@ -1141,7 +1218,9 @@ describe("server local API auth", () => { describe("non-loopback management fixture", () => { beforeEach(async () => { process.env.OPENCODEX_API_AUTH_TOKEN = "local-secret"; - managementFixture = await startManagementServerFixture(TEST_DIR, { ...config("0.0.0.0"), port: 0 }); + const fixture = await startManagementServerFixture(TEST_DIR, { ...config("0.0.0.0"), port: 0 }); + managementFixture = fixture; + trackCaseServerCleanup(() => fixture.close()); }); test("non-loopback management API allows same-origin GUI requests with API token", async () => { @@ -1220,7 +1299,7 @@ describe("server local API auth", () => { error: { code: "origin_rejected" }, }); } finally { - await server.stop(true); + await stopCaseServer(server); } }); @@ -1247,7 +1326,7 @@ describe("server local API auth", () => { error: { type: "upgrade_required" }, }); } finally { - await server.stop(true); + await stopCaseServer(server); } }); @@ -1257,7 +1336,7 @@ describe("server local API auth", () => { process.env.OPENCODEX_HOME = TEST_DIR; delete process.env.OPENCODEX_API_AUTH_TOKEN; - const upstream = Bun.serve({ + const upstream = startUpstream({ port: 0, fetch() { return Response.json({ @@ -1291,8 +1370,8 @@ describe("server local API auth", () => { const json = await post.json() as { output?: { type: string; content?: { text?: string }[] }[] }; expect(json.output?.find(o => o.type === "message")?.content?.[0]?.text).toBe("http fallback ok"); } finally { - await server.stop(true); - upstream.stop(true); + await stopCaseServer(server); + await stopCaseServer(upstream); } }); @@ -1302,7 +1381,7 @@ describe("server local API auth", () => { process.env.OPENCODEX_HOME = TEST_DIR; delete process.env.OPENCODEX_API_AUTH_TOKEN; - const upstream = Bun.serve({ + const upstream = startUpstream({ port: 0, fetch() { return new Response(JSON.stringify({ error: { message: "summarizer exploded" } }), { @@ -1331,8 +1410,8 @@ describe("server local API auth", () => { const body = await response.json() as { error?: { message?: string } }; expect(body.error?.message ?? "").toContain("500"); } finally { - await server.stop(true); - upstream.stop(true); + await stopCaseServer(server); + await stopCaseServer(upstream); } }); @@ -1354,7 +1433,7 @@ describe("server local API auth", () => { expect(response.headers.get("content-type")).toContain("application/json"); } } finally { - await server.stop(true); + await stopCaseServer(server); } }); @@ -1364,7 +1443,7 @@ describe("server local API auth", () => { process.env.OPENCODEX_HOME = TEST_DIR; delete process.env.OPENCODEX_API_AUTH_TOKEN; - const upstream = Bun.serve({ + const upstream = startUpstream({ port: 0, fetch() { // Anthropic non-stream response carrying the summarizer's text. @@ -1414,8 +1493,8 @@ describe("server local API auth", () => { // No ocx1 envelope may leak into v1 output. expect(JSON.stringify(json)).not.toContain("ocx1:"); } finally { - await server.stop(true); - await upstream.stop(true); + await stopCaseServer(server); + await stopCaseServer(upstream); } }); @@ -1438,7 +1517,7 @@ describe("server local API auth", () => { process.env.OPENCODEX_API_AUTH_TOKEN = "local-secret"; const seen: Array<{ host: string; authorization: string | null; chatgptAccountId: string | null }> = []; - const upstream = Bun.serve({ + const upstream = startUpstream({ port: 0, fetch(req) { const observed = { @@ -1582,7 +1661,7 @@ describe("server local API auth", () => { expect(loadConfig().activeCodexAccountId).toBe(directBaseline.active); expect(whamRequests).toBe(directBaseline.whamRequests); } finally { - await direct.stop(true); + await stopCaseServer(direct); } const mainOnlyConfig = (): OcxConfig => ({ @@ -1616,7 +1695,7 @@ describe("server local API auth", () => { expect(await wsTurn(unusableMain)).toContain("401"); expect(seen).toHaveLength(before); } finally { - await unusableMain.stop(true); + await stopCaseServer(unusableMain); } } clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); @@ -1694,7 +1773,7 @@ describe("server local API auth", () => { }))); expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true); } finally { - await nativeCaller.stop(true); + await stopCaseServer(nativeCaller); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); rmSync(join(isolatedCodexHome!.path, "auth.json"), { force: true }); } @@ -1718,7 +1797,7 @@ describe("server local API auth", () => { expect(await wsTurn(missingPool)).toContain("401"); expect(seen).toHaveLength(beforeMissingPool); } finally { - await missingPool.stop(true); + await stopCaseServer(missingPool); } clearAccountNeedsReauth("pool-a"); saveCodexAccountCredential("pool-a", { @@ -1741,7 +1820,7 @@ describe("server local API auth", () => { expect((await compact(cooledMulti)).status).toBe(429); expect(seen).toHaveLength(beforeCooldown); } finally { - await cooledMulti.stop(true); + await stopCaseServer(cooledMulti); } clearCodexUpstreamHealth(); const multi = startServer(0, { inspectNativeCodexOwnership }); @@ -1755,7 +1834,7 @@ describe("server local API auth", () => { chatgptAccountId: "acct-pool-a", }); } finally { - await multi.stop(true); + await stopCaseServer(multi); } saveConfig({ @@ -1780,7 +1859,7 @@ describe("server local API auth", () => { chatgptAccountId: null, }); } finally { - await api.stop(true); + await stopCaseServer(api); } saveCodexAccountCredential("pool-b", { @@ -1863,13 +1942,13 @@ describe("server local API auth", () => { expect(getTrackedCodexWebSocketCountForAccount("pool-b")).toBe(1); } finally { ws.close(); - await sequential.stop(true); + await stopCaseServer(sequential); } } finally { // Only clear our mock if a later/timeout race has not already replaced it. // afterEach always restores originalGlobalFetch once this test settles. if (globalThis.fetch === matrixFetch) globalThis.fetch = originalGlobalFetch; - await upstream.stop(true); + await stopCaseServer(upstream); } }, { timeout: SERVER_BUDGET_MS }); @@ -1931,7 +2010,7 @@ describe("server local API auth", () => { expect(outbound.every(row => row.authorization === "Bearer routed-key")).toBe(true); } finally { globalThis.fetch = originalGlobalFetch; - await server.stop(true); + await stopCaseServer(server); } }); @@ -2002,7 +2081,7 @@ describe("server local API auth", () => { expect(chatgptCalls.every(row => row.accountId === "acct-direct")).toBe(true); } finally { globalThis.fetch = originalGlobalFetch; - await server.stop(true); + await stopCaseServer(server); } }); @@ -2016,7 +2095,7 @@ describe("server local API auth", () => { clearAccountQuota(); let upstreamRequests = 0; - const upstream = Bun.serve({ + const upstream = startUpstream({ port: 0, fetch() { upstreamRequests += 1; @@ -2119,8 +2198,8 @@ describe("server local API auth", () => { expect(upstreamRequests).toBe(3); } finally { Date.now = originalNow; - await server.stop(true); - await upstream.stop(true); + await stopCaseServer(server); + await stopCaseServer(upstream); } }); @@ -2133,7 +2212,7 @@ describe("server local API auth", () => { clearAccountNeedsReauth("pool-a"); const seenAuth: Array = []; - const upstream = Bun.serve({ + const upstream = startUpstream({ port: 0, fetch(req) { seenAuth.push(req.headers.get("authorization")); @@ -2232,8 +2311,8 @@ describe("server local API auth", () => { } finally { Date.now = originalNow; globalThis.fetch = originalFetch; - await server.stop(true); - await upstream.stop(true); + await stopCaseServer(server); + await stopCaseServer(upstream); } }); @@ -2242,7 +2321,7 @@ describe("server local API auth", () => { mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; - const upstream = Bun.serve({ + const upstream = startUpstream({ port: 0, fetch() { return new Response([ @@ -2315,8 +2394,8 @@ describe("server local API auth", () => { }, }); } finally { - await server.stop(true); - await upstream.stop(true); + await stopCaseServer(server); + await stopCaseServer(upstream); } }); @@ -3609,7 +3688,7 @@ describe("server local API auth", () => { }); harness.config.websockets = true; saveConfig(harness.config); - await harness.server.stop(true); + await stopCaseServer(harness.server); harness.server = startServer(0); const wsUrl = new URL("/v1/responses", harness.server.url); wsUrl.protocol = "ws:"; @@ -3694,7 +3773,7 @@ describe("server local API auth", () => { expect(getUpstreamHostHealth(upstreamHostHealthKey("openai", "https://chatgpt.com"))) .toMatchObject({ consecutiveFailures: 1, lastFailureCode: expect.stringMatching(/^(?:ConnectionRefused|ECONNREFUSED)$/) }); } finally { - await server.stop(true); + await stopCaseServer(server); } }); @@ -3768,7 +3847,7 @@ describe("server local API auth", () => { expect(isCodexAccountSoftAvoided("pool-a")).toBe(false); expect(getUpstreamHostHealth(hostKey)).toBeNull(); } finally { - await server.stop(true); + await stopCaseServer(server); } }); @@ -3780,7 +3859,7 @@ describe("server local API auth", () => { clearThreadAccountMap(); clearAccountNeedsReauth("pool-a"); - const upstream = Bun.serve({ + const upstream = startUpstream({ port: 0, fetch() { return new Response( @@ -3838,8 +3917,8 @@ describe("server local API auth", () => { closeReason: "terminal", }); } finally { - await server.stop(true); - await upstream.stop(true); + await stopCaseServer(server); + await stopCaseServer(upstream); } }, { timeout: SERVER_BUDGET_MS }); @@ -3850,7 +3929,7 @@ describe("server local API auth", () => { clearRequestLogsForTests(); const message = "This content was flagged for possible cybersecurity risk. To get authorized for security work, join the Trusted Access for Cyber program."; - const upstream = Bun.serve({ + const upstream = startUpstream({ port: 0, fetch() { return new Response([ @@ -3909,8 +3988,8 @@ describe("server local API auth", () => { attempts: [expect.objectContaining({ status: 400, errorCode: "cyber_policy" })], }); } finally { - await server.stop(true); - await upstream.stop(true); + await stopCaseServer(server); + await stopCaseServer(upstream); clearRequestLogsForTests(); } }); @@ -3920,7 +3999,7 @@ describe("server local API auth", () => { mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; - const upstream = Bun.serve({ + const upstream = startUpstream({ port: 0, fetch() { return new Response( @@ -3994,8 +4073,8 @@ describe("server local API auth", () => { expect(claudeUsage.surface).toBe("claude"); expect(claudeUsage.summary.requests).toBe(0); } finally { - await server.stop(true); - await upstream.stop(true); + await stopCaseServer(server); + await stopCaseServer(upstream); } }); @@ -4056,7 +4135,7 @@ describe("server local API auth", () => { expect(getRequestLogEntries().find(entry => entry.requestId === requestId)).toMatchObject({ status: 499, closeReason: "client_cancel" }); } finally { globalThis.fetch = originalFetch; - await server.stop(true); + await stopCaseServer(server); } }); @@ -4134,7 +4213,7 @@ describe("server local API auth", () => { clearThreadAccountMap(); clearAccountNeedsReauth("pool-a"); - const upstream = Bun.serve({ + const upstream = startUpstream({ port: 0, fetch() { return new Response( @@ -4188,8 +4267,8 @@ describe("server local API auth", () => { expect(text).toContain("response.failed"); expect(getCodexUpstreamHealth("pool-a")).toBeNull(); } finally { - await server.stop(true); - await upstream.stop(true); + await stopCaseServer(server); + await stopCaseServer(upstream); } }); }); @@ -4223,7 +4302,7 @@ describe("GET /v1/catalog remote data plane", () => { expect(remote.headers.get("cache-control")).toBe("no-store"); expect(remote.headers.get("x-opencodex-key-id")).toBe("remote-key"); } finally { - await server.stop(true); + await stopCaseServer(server); } }); @@ -4253,7 +4332,7 @@ describe("GET /v1/catalog remote data plane", () => { expect(response.headers.get("x-opencodex-key-id")).toBe(keyId); } } finally { - await server.stop(true); + await stopCaseServer(server); } }); @@ -4269,7 +4348,7 @@ describe("GET /v1/catalog remote data plane", () => { expect(response.status).toBe(200); expect(response.headers.get("x-opencodex-key-id")).toBeNull(); } finally { - await remote.stop(true); + await stopCaseServer(remote); } const loopbackConfig = remoteCatalogConfig(); @@ -4281,7 +4360,7 @@ describe("GET /v1/catalog remote data plane", () => { expect(response.status).toBe(200); expect(response.headers.get("x-opencodex-key-id")).toBeNull(); } finally { - await loopback.stop(true); + await stopCaseServer(loopback); } }); @@ -4307,7 +4386,7 @@ describe("GET /v1/catalog remote data plane", () => { expect(remoteCatalogWarns[0]).not.toContain(unsafeId); expect(warnSpy.mock.calls.flat().map(String).join(" ")).not.toContain(unsafeId); } finally { - await server.stop(true); + await stopCaseServer(server); warnSpy.mockRestore(); } }); @@ -4339,7 +4418,7 @@ describe("GET /v1/catalog remote data plane", () => { expect(response.headers.get("cache-control")).toBe("no-store"); } } finally { - await server.stop(true); + await stopCaseServer(server); } }); @@ -4363,7 +4442,7 @@ describe("GET /v1/catalog remote data plane", () => { expect(response.headers.get("x-opencodex-key-id")).toBeNull(); } } finally { - await server.stop(true); + await stopCaseServer(server); } }); }); @@ -4408,7 +4487,7 @@ describe("POST /opencodex-session pairing body bound", () => { // The bound is what stopped it, not the peer running out of data. expect(pulled).toBeLessThan(chunkCount); } finally { - await server.stop(true); + await stopCaseServer(server); } }); @@ -4431,7 +4510,7 @@ describe("POST /opencodex-session pairing body bound", () => { // 401, not 413: the body was read and parsed, and the grant simply does not exist. expect(response.status).toBe(401); } finally { - await server.stop(true); + await stopCaseServer(server); } }); }); diff --git a/tests/server/spend-ledger-owner-startup.test.ts b/tests/server/spend-ledger-owner-startup.test.ts index f8649f7e2f3..9fbf3c44586 100644 --- a/tests/server/spend-ledger-owner-startup.test.ts +++ b/tests/server/spend-ledger-owner-startup.test.ts @@ -20,6 +20,34 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; let home = ""; let previousHome: string | undefined; let codexHome: IsolatedCodexHome | null = null; +type CaseServer = ReturnType | ReturnType; +const caseServers: CaseServer[] = []; +const caseServerStops = new WeakMap>(); + +function trackCaseServer(server: T): T { + caseServers.push(server); + return server; +} + +function stopCaseServer(server: CaseServer): Promise { + const existing = caseServerStops.get(server); + if (existing) return existing; + const stopping = Promise.resolve().then(() => server.stop(true)); + caseServerStops.set(server, stopping); + return stopping; +} + +async function stopCaseServers(): Promise { + let stopError: unknown; + for (const server of caseServers.splice(0).reverse()) { + try { + await stopCaseServer(server); + } catch (error) { + stopError ??= error; + } + } + if (stopError) throw stopError; +} function config(): OcxConfig { return { @@ -40,18 +68,26 @@ beforeEach(() => { }); afterEach(async () => { + let stopError: unknown; + try { + await stopCaseServers(); + } catch (error) { + stopError = error; + } await flushNativeMainStartupReleases(); await flushConfigDirHardeningForTests(); resetSharedSpendLedgerForTest(); + expect(spendLedgerOwnerSnapshot().ownership).toBe("unheld"); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; codexHome?.restore(); codexHome = null; removeTreeWithRetry(home); + if (stopError) throw stopError; }); test("startServer acquires before serving and final stop releases", async () => { - const server = startServer(0); + const server = trackCaseServer(startServer(0)); expect(spendLedgerOwnerSnapshot().ownership).toBe("held"); try { const adminToken = readFileSync(join(home, "admin-api-token"), "utf8").trim(); @@ -73,7 +109,7 @@ test("startServer acquires before serving and final stop releases", async () => expect(existsSync(join(home, SPEND_LEDGER_JOURNAL_FILENAME))).toBe(false); expect(existsSync(join(home, SPEND_LEDGER_SALT_FILENAME))).toBe(false); } finally { - await server.stop(true); + await stopCaseServer(server); } expect(spendLedgerOwnerSnapshot().ownership).toBe("unheld"); }); @@ -88,7 +124,7 @@ async function waitForOwnership(expected: "held" | "unheld"): Promise { } test("a partial start that bound public before an auxiliary failure releases ownership", async () => { - const blocker = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("blocked") }); + const blocker = trackCaseServer(Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("blocked") })); try { const candidate = config(); candidate.unauthenticatedLoopbackListener = { enabled: true, port: blocker.port }; @@ -101,6 +137,6 @@ test("a partial start that bound public before an auxiliary failure releases own const next = acquireSpendLedgerOwner(); next.release(); } finally { - await blocker.stop(true); + await stopCaseServer(blocker); } });