From ce87094e26b72bec5c81cdee0f1e0e492c61f6b2 Mon Sep 17 00:00:00 2001 From: MarcTCruz <58499846+MarcTCruz@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:14:35 +0000 Subject: [PATCH 1/8] fix(codex): harden native main token refresh --- src/codex/main-account.ts | 183 +++++++++++++------ src/codex/native-main-refresh-publication.ts | 152 +++++++++++++++ src/lib/atomic-file-preserving-replace.ts | 80 ++++++++ src/oauth/chatgpt.ts | 19 +- src/server/responses/codex-auth-error.ts | 4 + tests/atomic-file-preserving-replace.test.ts | 29 +++ tests/codex-main-account-refresh.test.ts | 55 ++++++ tests/native-main-refresh-process.test.ts | 47 +++++ 8 files changed, 507 insertions(+), 62 deletions(-) create mode 100644 src/codex/native-main-refresh-publication.ts create mode 100644 src/lib/atomic-file-preserving-replace.ts create mode 100644 tests/atomic-file-preserving-replace.test.ts create mode 100644 tests/native-main-refresh-process.test.ts diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index b0b7ebb328..42493c1d86 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -6,18 +6,22 @@ import { decodeJwtPayload, extractAccountId, refreshChatGPTToken, + ChatGPTTokenRefreshError, } from "../oauth/chatgpt"; import type { OAuthCredentials } from "../oauth/types"; import { extractChatgptPlanType } from "./plan"; import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; -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 { clearAccountNeedsReauth } from "./account-runtime-state"; +import { withNativeMainExclusiveClaim } from "./native-main-claim"; +import { withNativeMainOwnerOperation } from "./native-main-owner"; +import { resolveNativeProfileContext, type NativeProfileContext } from "./native-profile-store"; +import { + NativeMainRefreshPublicationError, + publishNativeMainRefresh, + recoverNativeMainRefreshPublication, +} from "./native-main-refresh-publication"; export { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; @@ -29,10 +33,13 @@ export { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; let mainAccountPlan: string | null = null; let jwtPlanAttempted = false; const MAIN_TOKEN_REFRESH_SKEW_MS = 60_000; +const NATIVE_MAIN_REFRESH_WAIT_MS = 30_000; +const MAX_NATIVE_MAIN_REFRESH_FLIGHTS = 32; let beforeMainAuthJsonRenameForTests: (() => void) | null = null; type MainAuthJsonCredential = { path: string; + raw: string; rawSha256: string; root: Record; tokens: Record; @@ -46,6 +53,15 @@ export interface NativeMainRefreshDependencies { signal?: AbortSignal; } +type NativeMainRefreshFlight = { + controller: AbortController; + deadline: ReturnType; + waiters: number; + promise: Promise<{ accessToken: string; chatgptAccountId: string }>; +}; + +const nativeMainRefreshFlights = new Map(); + export class MainAuthJsonChangedDuringRefreshError extends Error { constructor() { super("Codex auth.json changed while its token was refreshing"); @@ -62,16 +78,19 @@ export class MainAccountTokenRefreshError extends Error { } } -function nonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; +export class MainAccountRefreshCancelledError extends Error { + constructor() { + super("Native credential refresh was cancelled."); + this.name = "MainAccountRefreshCancelledError"; + } } -function sha256(value: string): string { - return createHash("sha256").update(value).digest("hex"); +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; } function readMainAuthJsonCredential(): MainAuthJsonCredential | null { - const path = resolveWriteTarget(join(resolveCodexHomeDir(), "auth.json")); + const path = join(resolveCodexHomeDir(), "auth.json"); let raw: string; try { raw = readFileSync(path, "utf8"); @@ -94,7 +113,8 @@ function readMainAuthJsonCredential(): MainAuthJsonCredential | null { ?? ""; return { path, - rawSha256: sha256(raw), + raw, + rawSha256: createHash("sha256").update(raw).digest("hex"), root, tokens, ...(accessToken ? { accessToken } : {}), @@ -131,6 +151,7 @@ function assertMainAuthJsonSnapshotUnchanged(expected: MainAuthJsonCredential): } function persistRefreshedMainAuthJson( + context: NativeProfileContext, expected: MainAuthJsonCredential, refreshed: OAuthCredentials, ): { accessToken: string; chatgptAccountId: string } { @@ -146,20 +167,12 @@ function persistRefreshedMainAuthJson( refresh_token: refreshToken, account_id: chatgptAccountId, }; - atomicWriteFile( - expected.path, - JSON.stringify({ ...expected.root, tokens }, null, 2) + "\n", - undefined, - { - beforeRename: () => { - assertMainAuthJsonSnapshotUnchanged(expected); - const hook = beforeMainAuthJsonRenameForTests; - beforeMainAuthJsonRenameForTests = null; - hook?.(); - }, - validateBeforeRename: () => assertMainAuthJsonSnapshotUnchanged(expected), - }, - ); + assertMainAuthJsonSnapshotUnchanged(expected); + const hook = beforeMainAuthJsonRenameForTests; + beforeMainAuthJsonRenameForTests = null; + hook?.(); + assertMainAuthJsonSnapshotUnchanged(expected); + publishNativeMainRefresh(context, expected.raw, JSON.stringify({ ...expected.root, tokens }, null, 2) + "\n"); return { accessToken, chatgptAccountId }; } @@ -185,41 +198,91 @@ async function resolveMainAccountToken( : null; } - const signal = dependencies.signal - ? AbortSignal.any([dependencies.signal, AbortSignal.timeout(30_000)]) - : AbortSignal.timeout(30_000); - const lockKey = refreshGrantFingerprintForToken(initial.refreshToken); - return withCodexRefreshFileLock(lockKey, signal, async () => { - const locked = readMainAuthJsonCredential(); - if (!locked) throw new MainAuthJsonChangedDuringRefreshError(); - if (!locked.refreshToken - || refreshGrantFingerprintForToken(locked.refreshToken) !== lockKey) { - if (locked.accessToken !== rejectedAccessToken - && mainAccessTokenFresh(locked.accessToken, Date.now(), 0)) { - return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId }; - } - throw new MainAuthJsonChangedDuringRefreshError(); - } - if (locked.accessToken !== rejectedAccessToken - && mainAccessTokenFresh(locked.accessToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) { - return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId }; - } - const refresh = dependencies.refreshToken - ?? ((refreshToken: string, options: { signal: AbortSignal }) => refreshChatGPTToken(refreshToken, options)); - let refreshed: OAuthCredentials; - try { - refreshed = await refresh(locked.refreshToken, { signal }); - } catch (cause) { - const message = cause instanceof Error ? cause.message.toLowerCase() : ""; - const reason = /invalid_grant|invalidated|revoked|expired/.test(message) - ? "reauth" as const - : "transient" as const; - throw new MainAccountTokenRefreshError(reason, { cause }); - } - const result = persistRefreshedMainAuthJson(locked, refreshed); - clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); - return result; - }); + const context = resolveNativeProfileContext(); + const current = nativeMainRefreshFlights.get(context.homeId); + if (current) return await waitForNativeMainRefresh(current, dependencies.signal); + if (nativeMainRefreshFlights.size >= MAX_NATIVE_MAIN_REFRESH_FLIGHTS) { + throw new MainAccountTokenRefreshError("transient"); + } + const controller = new AbortController(); + const deadline = setTimeout(() => controller.abort(new Error("Native credential refresh timed out")), NATIVE_MAIN_REFRESH_WAIT_MS); + const flight: NativeMainRefreshFlight = { + controller, + deadline, + waiters: 0, + promise: runNativeMainRefreshFlight(context, dependencies, rejectedAccessToken, controller.signal), + }; + nativeMainRefreshFlights.set(context.homeId, flight); + flight.promise.finally(() => { + clearTimeout(flight.deadline); + if (nativeMainRefreshFlights.get(context.homeId) === flight) nativeMainRefreshFlights.delete(context.homeId); + }).catch(() => undefined); + return await waitForNativeMainRefresh(flight, dependencies.signal); +} + +function abortError(_signal: AbortSignal): MainAccountRefreshCancelledError { + return new MainAccountRefreshCancelledError(); +} + +async function waitForNativeMainRefresh( + flight: NativeMainRefreshFlight, + signal: AbortSignal | undefined, +): Promise<{ accessToken: string; chatgptAccountId: string }> { + flight.waiters += 1; + try { + if (!signal) return await flight.promise; + if (signal.aborted) throw abortError(signal); + return await Promise.race([ + flight.promise, + new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(abortError(signal)), { once: true })), + ]); + } finally { + flight.waiters -= 1; + if (flight.waiters === 0) flight.controller.abort(); + } +} + +async function runNativeMainRefreshFlight( + context: NativeProfileContext, + dependencies: NativeMainRefreshDependencies, + rejectedAccessToken: string | undefined, + signal: AbortSignal, +): Promise<{ accessToken: string; chatgptAccountId: string }> { + try { + return await withNativeMainOwnerOperation(context, async () => await withNativeMainExclusiveClaim( + context, + async () => { + recoverNativeMainRefreshPublication(context); + const locked = readMainAuthJsonCredential(); + if (!locked) throw new MainAuthJsonChangedDuringRefreshError(); + if (locked.accessToken !== rejectedAccessToken + && mainAccessTokenFresh(locked.accessToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) { + return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId }; + } + if (!locked.refreshToken) throw new MainAuthJsonChangedDuringRefreshError(); + const refresh = dependencies.refreshToken + ?? ((token: string, options: { signal: AbortSignal }) => refreshChatGPTToken(token, options)); + let refreshed: OAuthCredentials; + try { + refreshed = await refresh(locked.refreshToken, { signal }); + } catch (cause) { + const terminal = cause instanceof ChatGPTTokenRefreshError + && cause.code === "invalid_grant" + && (cause.status === 400 || cause.status === 401); + throw new MainAccountTokenRefreshError(terminal ? "reauth" : "transient", { cause }); + } + if (signal.aborted) throw new MainAccountTokenRefreshError("transient"); + const result = persistRefreshedMainAuthJson(context, locked, refreshed); + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + return result; + }, + { waitMs: NATIVE_MAIN_REFRESH_WAIT_MS }, + )); + } catch (cause) { + if (cause instanceof MainAccountTokenRefreshError || cause instanceof MainAuthJsonChangedDuringRefreshError) throw cause; + if (cause instanceof NativeMainRefreshPublicationError) throw new MainAccountTokenRefreshError("transient", { cause }); + throw new MainAccountTokenRefreshError("transient", { cause }); + } } /** Refresh the CLI-owned native credential before upstream I/O and publish it atomically. */ diff --git a/src/codex/native-main-refresh-publication.ts b/src/codex/native-main-refresh-publication.ts new file mode 100644 index 0000000000..e895553245 --- /dev/null +++ b/src/codex/native-main-refresh-publication.ts @@ -0,0 +1,152 @@ +import { createHash, randomUUID } from "node:crypto"; +import { closeSync, existsSync, fsyncSync, openSync, readFileSync, unlinkSync } from "node:fs"; +import { basename, join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { PreservingReplaceError, replaceFilePreservingTarget, restoreFilePreservingTarget } from "../lib/atomic-file-preserving-replace"; +import type { NativeProfileContext } from "./native-profile-store"; + +const JOURNAL = ".opencodex-native-main-refresh.json"; +const NEW = /^\.opencodex-native-main-refresh\.[0-9a-f-]+\.new$/; +const PREVIOUS = /^\.opencodex-native-main-refresh\.[0-9a-f-]+\.previous$/; + +type Journal = { + version: 1; + transactionId: string; + stagedBasename: string; + previousBasename: string; + phase: "prepared" | "replaced"; + expectedSha256: string; + replacementSha256: string; +}; + +export class NativeMainRefreshPublicationError extends Error { + constructor() { + super("Native credential refresh could not be published."); + this.name = "NativeMainRefreshPublicationError"; + } +} + +function digest(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} + +function fsync(path: string): void { + const fd = openSync(path, "r"); + try { fsyncSync(fd); } finally { closeSync(fd); } +} + +function journalPath(context: NativeProfileContext): string { + return join(context.codexHome, JOURNAL); +} + +function validJournal(value: unknown): value is Journal { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const item = value as Record; + return item.version === 1 + && typeof item.transactionId === "string" + && NEW.test(String(item.stagedBasename)) + && PREVIOUS.test(String(item.previousBasename)) + && (item.phase === "prepared" || item.phase === "replaced") + && /^[0-9a-f]{64}$/.test(String(item.expectedSha256)) + && /^[0-9a-f]{64}$/.test(String(item.replacementSha256)); +} + +function readExact(path: string): Buffer | null { + try { return readFileSync(path); } catch { return null; } +} + +function removeExact(path: string): void { + try { unlinkSync(path); } catch { throw new NativeMainRefreshPublicationError(); } +} + +function journalPaths(context: NativeProfileContext, journal: Journal): { staged: string; previous: string } { + return { + staged: join(context.codexHome, journal.stagedBasename), + previous: join(context.codexHome, journal.previousBasename), + }; +} + +function cleanup(context: NativeProfileContext, journal: Journal): void { + const { staged, previous } = journalPaths(context, journal); + for (const path of [staged, previous, journalPath(context)]) { + if (existsSync(path)) removeExact(path); + } +} + +/** Recover only a transaction whose exact hashes prove one deterministic outcome. */ +export function recoverNativeMainRefreshPublication(context: NativeProfileContext): void { + const path = journalPath(context); + if (!existsSync(path)) return; + let journal: Journal; + try { journal = JSON.parse(readFileSync(path, "utf8")) as Journal; } catch { throw new NativeMainRefreshPublicationError(); } + if (!validJournal(journal)) throw new NativeMainRefreshPublicationError(); + const { staged, previous } = journalPaths(context, journal); + const canonical = readExact(context.authPath); + const stagedBytes = readExact(staged); + const previousBytes = readExact(previous); + if (!canonical) throw new NativeMainRefreshPublicationError(); + if (digest(canonical) === journal.expectedSha256 && stagedBytes && digest(stagedBytes) === journal.replacementSha256) { + replaceFilePreservingTarget(staged, context.authPath, previous); + cleanup(context, { ...journal, phase: "replaced" }); + return; + } + if (digest(canonical) === journal.replacementSha256 + && ((stagedBytes && digest(stagedBytes) === journal.expectedSha256) + || (previousBytes && digest(previousBytes) === journal.expectedSha256))) { + cleanup(context, journal); + return; + } + throw new NativeMainRefreshPublicationError(); +} + +/** The sole native-main auth.json publisher. */ +export function publishNativeMainRefresh( + context: NativeProfileContext, + expected: string, + replacement: string, +): void { + const tx = randomUUID(); + const stagedBasename = `.opencodex-native-main-refresh.${tx}.new`; + const previousBasename = `.opencodex-native-main-refresh.${tx}.previous`; + const journal: Journal = { + version: 1, + transactionId: tx, + stagedBasename, + previousBasename, + phase: "prepared", + expectedSha256: digest(expected), + replacementSha256: digest(replacement), + }; + const staged = join(context.codexHome, stagedBasename); + const previous = join(context.codexHome, previousBasename); + try { + if (digest(readFileSync(context.authPath)) !== journal.expectedSha256) throw new NativeMainRefreshPublicationError(); + atomicWriteFile(staged, replacement); + fsync(staged); + atomicWriteFile(journalPath(context), `${JSON.stringify(journal)}\n`); + replaceFilePreservingTarget(staged, context.authPath, previous); + const displaced = readExact(process.platform === "win32" ? previous : staged); + if (!displaced || digest(displaced) !== journal.expectedSha256) { + const canonical = readExact(context.authPath); + if (canonical && digest(canonical) === journal.replacementSha256) { + restoreFilePreservingTarget(process.platform === "win32" ? previous : staged, context.authPath, previous); + } + throw new NativeMainRefreshPublicationError(); + } + const canonical = readExact(context.authPath); + if (!canonical || digest(canonical) !== journal.replacementSha256) { + throw new NativeMainRefreshPublicationError(); + } + atomicWriteFile(journalPath(context), `${JSON.stringify({ ...journal, phase: "replaced" })}\n`); + cleanup(context, journal); + } catch (error) { + if (error instanceof NativeMainRefreshPublicationError || error instanceof PreservingReplaceError) { + throw new NativeMainRefreshPublicationError(); + } + throw new NativeMainRefreshPublicationError(); + } +} + +export function nativeMainRefreshJournalBasename(): string { + return basename(JOURNAL); +} diff --git a/src/lib/atomic-file-preserving-replace.ts b/src/lib/atomic-file-preserving-replace.ts new file mode 100644 index 0000000000..76915f939d --- /dev/null +++ b/src/lib/atomic-file-preserving-replace.ts @@ -0,0 +1,80 @@ +import { dlopen, ptr } from "bun:ffi"; + +const AT_FDCWD = -100; +const RENAME_EXCHANGE = 2; +const RENAME_SWAP = 0x00000002; + +export class PreservingReplaceError extends Error { + constructor() { + super("Native credential publication could not complete."); + this.name = "PreservingReplaceError"; + } +} + +type Exchange = (source: string, target: string) => boolean; + +function cString(value: string): Buffer { + return Buffer.from(`${value}\0`); +} + +function unixExchange(symbol: "renameat2" | "renamex_np"): Exchange | null { + try { + const library = dlopen(process.platform === "darwin" ? "/usr/lib/libSystem.B.dylib" : "libc.so.6", { + [symbol]: { + args: symbol === "renameat2" ? ["i32", "cstring", "i32", "cstring", "u32"] : ["cstring", "cstring", "u32"], + returns: "i32", + }, + }); + const call = library.symbols[symbol] as (...args: unknown[]) => number; + return (source, target) => symbol === "renameat2" + ? call(AT_FDCWD, cString(source), AT_FDCWD, cString(target), RENAME_EXCHANGE) === 0 + : call(cString(source), cString(target), RENAME_SWAP) === 0; + } catch { + return null; + } +} + +function wide(value: string): Buffer { + return Buffer.from(`${value}\0`, "utf16le"); +} + +function windowsExchange(source: string, target: string, backup: string): boolean { + try { + const library = dlopen("kernel32.dll", { + ReplaceFileW: { args: ["ptr", "ptr", "ptr", "u32", "ptr", "ptr"], returns: "i32" }, + }); + const replacement = wide(source); + const replaced = wide(target); + const privateBackup = wide(backup); + return (library.symbols.ReplaceFileW as (...args: unknown[]) => number)( + ptr(replaced), ptr(replacement), ptr(privateBackup), 0, null, null, + ) !== 0; + } catch { + return false; + } +} + +/** + * Exchange a staged file with an existing canonical file without a missing-target window. + * On Unix the displaced entry remains at `source`; Windows places it at `backup`. + */ +export function replaceFilePreservingTarget(source: string, target: string, backup: string): void { + if (process.platform === "linux") { + const exchange = unixExchange("renameat2"); + if (exchange?.(source, target)) return; + throw new PreservingReplaceError(); + } + if (process.platform === "darwin") { + const exchange = unixExchange("renamex_np"); + if (exchange?.(source, target)) return; + throw new PreservingReplaceError(); + } + if (process.platform === "win32" && windowsExchange(source, target, backup)) return; + // No rename fallback is safe: it can make auth.json absent between operations. + throw new PreservingReplaceError(); +} + +/** Used only to restore a verified displaced Unix entry. */ +export function restoreFilePreservingTarget(source: string, target: string, backup: string): void { + replaceFilePreservingTarget(source, target, backup); +} diff --git a/src/oauth/chatgpt.ts b/src/oauth/chatgpt.ts index 5dd01497db..2b7e34e494 100644 --- a/src/oauth/chatgpt.ts +++ b/src/oauth/chatgpt.ts @@ -10,6 +10,13 @@ const CALLBACK_PORT = 1455; const CALLBACK_PATH = "/auth/callback"; const ORIGINATOR = "opencodex"; +export class ChatGPTTokenRefreshError extends Error { + constructor(readonly status: number, readonly code: string | undefined) { + super("ChatGPT token refresh failed."); + this.name = "ChatGPTTokenRefreshError"; + } +} + export function decodeJwtPayload(token: string): Record | undefined { const parts = token.split("."); if (parts.length !== 3 || !parts[1]) return undefined; @@ -135,6 +142,15 @@ function safeErrorDescription(resp: Response): Promise { }); } +async function oauthErrorCode(resp: Response): Promise { + try { + const parsed = await resp.json() as { error?: unknown }; + return typeof parsed.error === "string" ? parsed.error : undefined; + } catch { + return undefined; + } +} + export async function loginChatGPT(ctrl: OAuthController, opts?: { forceLogin?: boolean }): Promise { const flow = new ChatGPTOAuthFlow(ctrl); if (opts?.forceLogin) flow.forceLogin = true; @@ -158,8 +174,7 @@ export async function refreshChatGPTToken( signal: options.signal, }); if (!resp.ok) { - const errDesc = await safeErrorDescription(resp); - throw new Error(`ChatGPT refresh failed: ${resp.status} ${errDesc}`); + throw new ChatGPTTokenRefreshError(resp.status, await oauthErrorCode(resp)); } return credsFromToken((await resp.json()) as Record); } diff --git a/src/server/responses/codex-auth-error.ts b/src/server/responses/codex-auth-error.ts index 63da982922..d16342a168 100644 --- a/src/server/responses/codex-auth-error.ts +++ b/src/server/responses/codex-auth-error.ts @@ -12,6 +12,7 @@ import { } from "../../codex/auth-context"; import { MAIN_CODEX_ACCOUNT_ID, + MainAccountRefreshCancelledError, MainAccountTokenRefreshError, MainAuthJsonChangedDuringRefreshError, } from "../../codex/main-account"; @@ -22,6 +23,9 @@ export interface CodexAuthContextErrorResponseOptions { } export function nativeMainRefreshFailureResponse(error: unknown): Response { + if (error instanceof MainAccountRefreshCancelledError) { + return formatErrorResponse(499, "client_cancelled", "Client cancelled native credential refresh"); + } if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth") { return formatErrorResponse(401, "authentication_error", "Codex main account needs reauthentication"); } diff --git a/tests/atomic-file-preserving-replace.test.ts b/tests/atomic-file-preserving-replace.test.ts new file mode 100644 index 0000000000..d2a789dc86 --- /dev/null +++ b/tests/atomic-file-preserving-replace.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { replaceFilePreservingTarget } from "../src/lib/atomic-file-preserving-replace"; + +let directory = ""; + +afterEach(() => { + if (directory) rmSync(directory, { recursive: true, force: true }); + directory = ""; +}); + +describe("preserving file replacement", () => { + test("exchanges a staged credential without removing the canonical target", () => { + directory = mkdtempSync(join(tmpdir(), "ocx-preserving-replace-")); + const staged = join(directory, ".refresh.new"); + const canonical = join(directory, "auth.json"); + const backup = join(directory, ".refresh.previous"); + writeFileSync(staged, "replacement"); + writeFileSync(canonical, "external-before"); + + replaceFilePreservingTarget(staged, canonical, backup); + + expect(existsSync(canonical)).toBe(true); + expect(readFileSync(canonical, "utf8")).toBe("replacement"); + expect(readFileSync(process.platform === "win32" ? backup : staged, "utf8")).toBe("external-before"); + }); +}); diff --git a/tests/codex-main-account-refresh.test.ts b/tests/codex-main-account-refresh.test.ts index 45db2a670e..6500c25b4e 100644 --- a/tests/codex-main-account-refresh.test.ts +++ b/tests/codex-main-account-refresh.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getValidMainAccountToken, + MainAccountTokenRefreshError, setMainAuthJsonBeforeRenameHookForTests, } from "../src/codex/main-account"; @@ -117,4 +118,58 @@ describe("native main token refresh", () => { expect(readFileSync(authPath)).toEqual(original); expect(readdirSync(home).filter(name => name.includes(".tmp"))).toEqual([]); }); + + test("does not classify an unstructured invalid_grant description as terminal", async () => { + writeFileSync(join(home, "auth.json"), JSON.stringify({ + tokens: { + access_token: expiredJwt(), + refresh_token: "old-refresh", + account_id: "account-main", + }, + })); + + const failure = await getValidMainAccountToken({ + refreshToken: async () => { throw new Error("invalid_grant"); }, + }).catch(error => error); + + expect(failure).toBeInstanceOf(MainAccountTokenRefreshError); + expect((failure as MainAccountTokenRefreshError).reason).toBe("transient"); + }); + + test("keeps a joiner alive when the refresh owner cancels", async () => { + writeFileSync(join(home, "auth.json"), JSON.stringify({ + tokens: { + access_token: expiredJwt(), + refresh_token: "old-refresh", + account_id: "account-main", + }, + })); + const owner = new AbortController(); + let attempts = 0; + let entered!: () => void; + const enteredRefresh = new Promise(resolve => { entered = resolve; }); + let complete!: (value: { access: string; refresh: string; expires: number; accountId: string }) => void; + const remoteResult = new Promise<{ access: string; refresh: string; expires: number; accountId: string }>(resolve => { + complete = resolve; + }); + const refreshToken = async (_refresh: string, options: { signal: AbortSignal }) => { + attempts += 1; + entered(); + return await Promise.race([ + remoteResult, + new Promise((_resolve, reject) => { + options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true }); + }), + ]); + }; + const cancelled = getValidMainAccountToken({ signal: owner.signal, refreshToken }); + await enteredRefresh; + const joined = getValidMainAccountToken({ refreshToken }); + owner.abort(new Error("caller cancelled")); + complete({ access: "new-access", refresh: "new-refresh", expires: Date.now() + 3_600_000, accountId: "account-main" }); + + await expect(cancelled).rejects.toBeDefined(); + await expect(joined).resolves.toEqual({ accessToken: "new-access", chatgptAccountId: "account-main" }); + expect(attempts).toBe(1); + }); }); diff --git a/tests/native-main-refresh-process.test.ts b/tests/native-main-refresh-process.test.ts new file mode 100644 index 0000000000..a372baa686 --- /dev/null +++ b/tests/native-main-refresh-process.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getValidMainAccountToken } from "../src/codex/main-account"; + +let home = ""; +let previousCodexHome: string | undefined; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-native-main-flight-")); + previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + writeFileSync(join(home, "auth.json"), JSON.stringify({ + tokens: { refresh_token: "refresh-grant", account_id: "account-main" }, + })); +}); + +afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + rmSync(home, { recursive: true, force: true }); +}); + +describe("native-main refresh process coordination", () => { + test("same-home callers join exactly one refresh flight", async () => { + let attempts = 0; + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + let finish!: () => void; + const completed = new Promise(resolve => { finish = resolve; }); + const refreshToken = async () => { + attempts += 1; + entered(); + await completed; + return { access: "fresh-access", refresh: "rotated-grant", expires: Date.now() + 3_600_000, accountId: "account-main" }; + }; + const first = getValidMainAccountToken({ refreshToken }); + await started; + const second = getValidMainAccountToken({ refreshToken }); + finish(); + + await expect(first).resolves.toEqual({ accessToken: "fresh-access", chatgptAccountId: "account-main" }); + await expect(second).resolves.toEqual({ accessToken: "fresh-access", chatgptAccountId: "account-main" }); + expect(attempts).toBe(1); + }); +}); From 128a7eddc9edf509640e74b4b03d41e7e77b034a Mon Sep 17 00:00:00 2001 From: Codex Finisher Date: Sun, 30 Aug 2026 10:08:04 +0000 Subject: [PATCH 2/8] fix(codex): validate native refresh recovery journals --- src/codex/native-main-refresh-publication.ts | 19 +++--- src/lib/atomic-file-preserving-replace.ts | 2 +- tests/codex-main-account-refresh.test.ts | 65 +++++++++++++++++++- 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/codex/native-main-refresh-publication.ts b/src/codex/native-main-refresh-publication.ts index e895553245..582e1b30c9 100644 --- a/src/codex/native-main-refresh-publication.ts +++ b/src/codex/native-main-refresh-publication.ts @@ -6,8 +6,7 @@ import { PreservingReplaceError, replaceFilePreservingTarget, restoreFilePreserv import type { NativeProfileContext } from "./native-profile-store"; const JOURNAL = ".opencodex-native-main-refresh.json"; -const NEW = /^\.opencodex-native-main-refresh\.[0-9a-f-]+\.new$/; -const PREVIOUS = /^\.opencodex-native-main-refresh\.[0-9a-f-]+\.previous$/; +const TRANSACTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; type Journal = { version: 1; @@ -42,13 +41,15 @@ function journalPath(context: NativeProfileContext): string { function validJournal(value: unknown): value is Journal { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const item = value as Record; + const transactionId = typeof item.transactionId === "string" ? item.transactionId : ""; return item.version === 1 - && typeof item.transactionId === "string" - && NEW.test(String(item.stagedBasename)) - && PREVIOUS.test(String(item.previousBasename)) + && TRANSACTION_ID.test(transactionId) + && item.stagedBasename === `.opencodex-native-main-refresh.${transactionId}.new` + && item.previousBasename === `.opencodex-native-main-refresh.${transactionId}.previous` && (item.phase === "prepared" || item.phase === "replaced") && /^[0-9a-f]{64}$/.test(String(item.expectedSha256)) - && /^[0-9a-f]{64}$/.test(String(item.replacementSha256)); + && /^[0-9a-f]{64}$/.test(String(item.replacementSha256)) + && item.expectedSha256 !== item.replacementSha256; } function readExact(path: string): Buffer | null { @@ -129,7 +130,11 @@ export function publishNativeMainRefresh( if (!displaced || digest(displaced) !== journal.expectedSha256) { const canonical = readExact(context.authPath); if (canonical && digest(canonical) === journal.replacementSha256) { - restoreFilePreservingTarget(process.platform === "win32" ? previous : staged, context.authPath, previous); + restoreFilePreservingTarget( + process.platform === "win32" ? previous : staged, + context.authPath, + process.platform === "win32" ? staged : previous, + ); } throw new NativeMainRefreshPublicationError(); } diff --git a/src/lib/atomic-file-preserving-replace.ts b/src/lib/atomic-file-preserving-replace.ts index 76915f939d..eaffc5d804 100644 --- a/src/lib/atomic-file-preserving-replace.ts +++ b/src/lib/atomic-file-preserving-replace.ts @@ -74,7 +74,7 @@ export function replaceFilePreservingTarget(source: string, target: string, back throw new PreservingReplaceError(); } -/** Used only to restore a verified displaced Unix entry. */ +/** Restore a verified displaced entry while preserving the canonical target. */ export function restoreFilePreservingTarget(source: string, target: string, backup: string): void { replaceFilePreservingTarget(source, target, backup); } diff --git a/tests/codex-main-account-refresh.test.ts b/tests/codex-main-account-refresh.test.ts index 6500c25b4e..d13fb54b61 100644 --- a/tests/codex-main-account-refresh.test.ts +++ b/tests/codex-main-account-refresh.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -136,6 +137,68 @@ describe("native main token refresh", () => { expect((failure as MainAccountTokenRefreshError).reason).toBe("transient"); }); + test("rejects a recovery journal whose basenames do not belong to its transaction", async () => { + const authPath = join(home, "auth.json"); + const original = JSON.stringify({ tokens: { refresh_token: "old-refresh", account_id: "account-main" } }); + const replacement = JSON.stringify({ tokens: { access_token: "new-access", refresh_token: "new-refresh" } }); + const fileTransactionId = "11111111-1111-4111-8111-111111111111"; + const stagedBasename = `.opencodex-native-main-refresh.${fileTransactionId}.new`; + const journalPath = join(home, ".opencodex-native-main-refresh.json"); + const digest = (value: string) => createHash("sha256").update(value).digest("hex"); + writeFileSync(authPath, original); + writeFileSync(join(home, stagedBasename), replacement); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + transactionId: "22222222-2222-4222-8222-222222222222", + stagedBasename, + previousBasename: `.opencodex-native-main-refresh.${fileTransactionId}.previous`, + phase: "prepared", + expectedSha256: digest(original), + replacementSha256: digest(replacement), + })); + let attempts = 0; + + const failure = await getValidMainAccountToken({ + refreshToken: async () => { + attempts += 1; + throw new Error("must not refresh through malformed recovery state"); + }, + }).catch(error => error); + + expect(failure).toBeInstanceOf(MainAccountTokenRefreshError); + expect((failure as MainAccountTokenRefreshError).reason).toBe("transient"); + expect(attempts).toBe(0); + expect(readFileSync(authPath, "utf8")).toBe(original); + expect(existsSync(journalPath)).toBe(true); + }); + + test("rejects hash-ambiguous recovery state without consuming it", async () => { + const authPath = join(home, "auth.json"); + const original = JSON.stringify({ tokens: { refresh_token: "old-refresh", account_id: "account-main" } }); + const transactionId = "11111111-1111-4111-8111-111111111111"; + const stagedBasename = `.opencodex-native-main-refresh.${transactionId}.new`; + const journalPath = join(home, ".opencodex-native-main-refresh.json"); + const hash = createHash("sha256").update(original).digest("hex"); + writeFileSync(authPath, original); + writeFileSync(join(home, stagedBasename), original); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + transactionId, + stagedBasename, + previousBasename: `.opencodex-native-main-refresh.${transactionId}.previous`, + phase: "prepared", + expectedSha256: hash, + replacementSha256: hash, + })); + + const failure = await getValidMainAccountToken().catch(error => error); + + expect(failure).toBeInstanceOf(MainAccountTokenRefreshError); + expect((failure as MainAccountTokenRefreshError).reason).toBe("transient"); + expect(readFileSync(authPath, "utf8")).toBe(original); + expect(existsSync(journalPath)).toBe(true); + }); + test("keeps a joiner alive when the refresh owner cancels", async () => { writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { From acf6b6039801fc0b428fcebe3b897a3659b6c1ed Mon Sep 17 00:00:00 2001 From: MarcTCruz <58499846+MarcTCruz@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:31:19 +0000 Subject: [PATCH 3/8] fix(codex): harden native main refresh recovery --- src/codex/main-account.ts | 46 +++-- src/codex/native-main-refresh-publication.ts | 34 ++-- src/lib/atomic-file-preserving-replace.ts | 106 +++++++++--- src/oauth/index.ts | 5 +- tests/atomic-file-preserving-replace.test.ts | 26 ++- tests/codex-main-account-refresh.test.ts | 158 ++++++++++++++++++ .../native-main-refresh-process-worker.ts | 32 ++++ tests/native-main-refresh-process.test.ts | 101 ++++++++++- tests/oauth-refresh.test.ts | 38 ++++- 9 files changed, 485 insertions(+), 61 deletions(-) create mode 100644 tests/helpers/native-main-refresh-process-worker.ts diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 42493c1d86..fd4e33bbad 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -56,10 +56,15 @@ export interface NativeMainRefreshDependencies { type NativeMainRefreshFlight = { controller: AbortController; deadline: ReturnType; - waiters: number; promise: Promise<{ accessToken: string; chatgptAccountId: string }>; }; +type NativeMainRefreshResolution = { + dependencies: NativeMainRefreshDependencies; + rejectedAccessToken: string | undefined; + replacementAttempted: boolean; +}; + const nativeMainRefreshFlights = new Map(); export class MainAuthJsonChangedDuringRefreshError extends Error { @@ -183,6 +188,7 @@ export function setMainAuthJsonBeforeRenameHookForTests(hook: (() => void) | nul async function resolveMainAccountToken( dependencies: NativeMainRefreshDependencies = {}, rejectedAccessToken?: string, + replacementAttempted = false, ): Promise<{ accessToken: string; chatgptAccountId: string } | null> { const initial = readMainAuthJsonCredential(); if (!initial) return null; @@ -200,7 +206,8 @@ async function resolveMainAccountToken( const context = resolveNativeProfileContext(); const current = nativeMainRefreshFlights.get(context.homeId); - if (current) return await waitForNativeMainRefresh(current, dependencies.signal); + const resolution = { dependencies, rejectedAccessToken, replacementAttempted }; + if (current) return await resolveNativeMainRefreshFlight(context, current, resolution); if (nativeMainRefreshFlights.size >= MAX_NATIVE_MAIN_REFRESH_FLIGHTS) { throw new MainAccountTokenRefreshError("transient"); } @@ -209,7 +216,6 @@ async function resolveMainAccountToken( const flight: NativeMainRefreshFlight = { controller, deadline, - waiters: 0, promise: runNativeMainRefreshFlight(context, dependencies, rejectedAccessToken, controller.signal), }; nativeMainRefreshFlights.set(context.homeId, flight); @@ -217,7 +223,21 @@ async function resolveMainAccountToken( clearTimeout(flight.deadline); if (nativeMainRefreshFlights.get(context.homeId) === flight) nativeMainRefreshFlights.delete(context.homeId); }).catch(() => undefined); - return await waitForNativeMainRefresh(flight, dependencies.signal); + return await resolveNativeMainRefreshFlight(context, flight, resolution); +} + +async function resolveNativeMainRefreshFlight( + context: NativeProfileContext, + flight: NativeMainRefreshFlight, + resolution: NativeMainRefreshResolution, +): Promise<{ accessToken: string; chatgptAccountId: string }> { + const result = await waitForNativeMainRefresh(flight, resolution.dependencies.signal); + if (resolution.rejectedAccessToken === undefined || result.accessToken !== resolution.rejectedAccessToken) return result; + if (resolution.replacementAttempted) throw new MainAccountTokenRefreshError("transient"); + if (nativeMainRefreshFlights.get(context.homeId) === flight) nativeMainRefreshFlights.delete(context.homeId); + const replacement = await resolveMainAccountToken(resolution.dependencies, resolution.rejectedAccessToken, true); + if (!replacement) throw new MainAccountTokenRefreshError("transient"); + return replacement; } function abortError(_signal: AbortSignal): MainAccountRefreshCancelledError { @@ -228,18 +248,12 @@ async function waitForNativeMainRefresh( flight: NativeMainRefreshFlight, signal: AbortSignal | undefined, ): Promise<{ accessToken: string; chatgptAccountId: string }> { - flight.waiters += 1; - try { - if (!signal) return await flight.promise; - if (signal.aborted) throw abortError(signal); - return await Promise.race([ - flight.promise, - new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(abortError(signal)), { once: true })), - ]); - } finally { - flight.waiters -= 1; - if (flight.waiters === 0) flight.controller.abort(); - } + if (!signal) return await flight.promise; + if (signal.aborted) throw abortError(signal); + return await Promise.race([ + flight.promise, + new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(abortError(signal)), { once: true })), + ]); } async function runNativeMainRefreshFlight( diff --git a/src/codex/native-main-refresh-publication.ts b/src/codex/native-main-refresh-publication.ts index 582e1b30c9..f6d0775e4b 100644 --- a/src/codex/native-main-refresh-publication.ts +++ b/src/codex/native-main-refresh-publication.ts @@ -19,8 +19,8 @@ type Journal = { }; export class NativeMainRefreshPublicationError extends Error { - constructor() { - super("Native credential refresh could not be published."); + constructor(options?: ErrorOptions) { + super("Native credential refresh could not be published.", options); this.name = "NativeMainRefreshPublicationError"; } } @@ -30,7 +30,7 @@ function digest(value: string | Buffer): string { } function fsync(path: string): void { - const fd = openSync(path, "r"); + const fd = openSync(path, "r+"); try { fsyncSync(fd); } finally { closeSync(fd); } } @@ -57,7 +57,7 @@ function readExact(path: string): Buffer | null { } function removeExact(path: string): void { - try { unlinkSync(path); } catch { throw new NativeMainRefreshPublicationError(); } + try { unlinkSync(path); } catch (cause) { throw new NativeMainRefreshPublicationError({ cause }); } } function journalPaths(context: NativeProfileContext, journal: Journal): { staged: string; previous: string } { @@ -79,22 +79,23 @@ export function recoverNativeMainRefreshPublication(context: NativeProfileContex const path = journalPath(context); if (!existsSync(path)) return; let journal: Journal; - try { journal = JSON.parse(readFileSync(path, "utf8")) as Journal; } catch { throw new NativeMainRefreshPublicationError(); } + try { journal = JSON.parse(readFileSync(path, "utf8")) as Journal; } catch (cause) { throw new NativeMainRefreshPublicationError({ cause }); } if (!validJournal(journal)) throw new NativeMainRefreshPublicationError(); const { staged, previous } = journalPaths(context, journal); const canonical = readExact(context.authPath); const stagedBytes = readExact(staged); - const previousBytes = readExact(previous); if (!canonical) throw new NativeMainRefreshPublicationError(); if (digest(canonical) === journal.expectedSha256 && stagedBytes && digest(stagedBytes) === journal.replacementSha256) { - replaceFilePreservingTarget(staged, context.authPath, previous); - cleanup(context, { ...journal, phase: "replaced" }); + try { + replaceFilePreservingTarget(staged, context.authPath, previous); + cleanup(context, { ...journal, phase: "replaced" }); + } catch (cause) { + throw new NativeMainRefreshPublicationError({ cause }); + } return; } - if (digest(canonical) === journal.replacementSha256 - && ((stagedBytes && digest(stagedBytes) === journal.expectedSha256) - || (previousBytes && digest(previousBytes) === journal.expectedSha256))) { - cleanup(context, journal); + if (digest(canonical) === journal.replacementSha256) { + try { cleanup(context, journal); } catch (cause) { throw new NativeMainRefreshPublicationError({ cause }); } return; } throw new NativeMainRefreshPublicationError(); @@ -144,11 +145,10 @@ export function publishNativeMainRefresh( } atomicWriteFile(journalPath(context), `${JSON.stringify({ ...journal, phase: "replaced" })}\n`); cleanup(context, journal); - } catch (error) { - if (error instanceof NativeMainRefreshPublicationError || error instanceof PreservingReplaceError) { - throw new NativeMainRefreshPublicationError(); - } - throw new NativeMainRefreshPublicationError(); + } catch (cause) { + if (cause instanceof NativeMainRefreshPublicationError) throw cause; + if (cause instanceof PreservingReplaceError) throw new NativeMainRefreshPublicationError({ cause }); + throw new NativeMainRefreshPublicationError({ cause }); } } diff --git a/src/lib/atomic-file-preserving-replace.ts b/src/lib/atomic-file-preserving-replace.ts index eaffc5d804..b1b9442866 100644 --- a/src/lib/atomic-file-preserving-replace.ts +++ b/src/lib/atomic-file-preserving-replace.ts @@ -1,36 +1,75 @@ -import { dlopen, ptr } from "bun:ffi"; +import { dlopen, ptr, read } from "bun:ffi"; const AT_FDCWD = -100; const RENAME_EXCHANGE = 2; const RENAME_SWAP = 0x00000002; +type ReplaceOperation = "renameat2" | "renamex_np" | "ReplaceFileW" | "unsupported"; +type NativeExchangeResult = + | { ok: true } + | { ok: false; nativeCode?: number; cause?: unknown }; + +type FailedReplacementDetails = { + operation: ReplaceOperation; + sourcePath: string; + targetPath: string; + backupPath: string; + result: Extract; +}; + export class PreservingReplaceError extends Error { - constructor() { - super("Native credential publication could not complete."); + readonly operation: ReplaceOperation; + readonly sourcePath: string; + readonly targetPath: string; + readonly backupPath: string; + readonly platform: NodeJS.Platform; + readonly nativeCode?: number; + + constructor(details: { + operation: ReplaceOperation; + sourcePath: string; + targetPath: string; + backupPath: string; + platform: NodeJS.Platform; + nativeCode?: number; + cause?: unknown; + }) { + super("Native credential publication could not complete.", details.cause === undefined ? undefined : { cause: details.cause }); this.name = "PreservingReplaceError"; + this.operation = details.operation; + this.sourcePath = details.sourcePath; + this.targetPath = details.targetPath; + this.backupPath = details.backupPath; + this.platform = details.platform; + this.nativeCode = details.nativeCode; } } -type Exchange = (source: string, target: string) => boolean; - function cString(value: string): Buffer { return Buffer.from(`${value}\0`); } -function unixExchange(symbol: "renameat2" | "renamex_np"): Exchange | null { +function unixExchange(symbol: "renameat2" | "renamex_np"): (source: string, target: string) => NativeExchangeResult { try { + const errnoSymbol = process.platform === "darwin" ? "__error" : "__errno_location"; const library = dlopen(process.platform === "darwin" ? "/usr/lib/libSystem.B.dylib" : "libc.so.6", { [symbol]: { args: symbol === "renameat2" ? ["i32", "cstring", "i32", "cstring", "u32"] : ["cstring", "cstring", "u32"], returns: "i32", }, + [errnoSymbol]: { args: [], returns: "ptr" }, }); const call = library.symbols[symbol] as (...args: unknown[]) => number; - return (source, target) => symbol === "renameat2" - ? call(AT_FDCWD, cString(source), AT_FDCWD, cString(target), RENAME_EXCHANGE) === 0 - : call(cString(source), cString(target), RENAME_SWAP) === 0; - } catch { - return null; + const errnoLocation = library.symbols[errnoSymbol] as () => number; + return (source, target) => { + const status = symbol === "renameat2" + ? call(AT_FDCWD, cString(source), AT_FDCWD, cString(target), RENAME_EXCHANGE) + : call(cString(source), cString(target), RENAME_SWAP); + if (status === 0) return { ok: true }; + return { ok: false, nativeCode: read.i32(errnoLocation()) }; + }; + } catch (cause) { + return () => ({ ok: false, cause }); } } @@ -38,40 +77,59 @@ function wide(value: string): Buffer { return Buffer.from(`${value}\0`, "utf16le"); } -function windowsExchange(source: string, target: string, backup: string): boolean { +function windowsExchange(source: string, target: string, backup: string): NativeExchangeResult { try { const library = dlopen("kernel32.dll", { ReplaceFileW: { args: ["ptr", "ptr", "ptr", "u32", "ptr", "ptr"], returns: "i32" }, + GetLastError: { args: [], returns: "u32" }, }); const replacement = wide(source); const replaced = wide(target); const privateBackup = wide(backup); - return (library.symbols.ReplaceFileW as (...args: unknown[]) => number)( + const status = (library.symbols.ReplaceFileW as (...args: unknown[]) => number)( ptr(replaced), ptr(replacement), ptr(privateBackup), 0, null, null, - ) !== 0; - } catch { - return false; + ); + if (status !== 0) return { ok: true }; + return { ok: false, nativeCode: (library.symbols.GetLastError as () => number)() }; + } catch (cause) { + return { ok: false, cause }; } } +function failedReplacement(details: FailedReplacementDetails): PreservingReplaceError { + return new PreservingReplaceError({ + operation: details.operation, + sourcePath: details.sourcePath, + targetPath: details.targetPath, + backupPath: details.backupPath, + platform: process.platform, + ...(details.result.nativeCode === undefined ? {} : { nativeCode: details.result.nativeCode }), + ...(details.result.cause === undefined ? {} : { cause: details.result.cause }), + }); +} + /** * Exchange a staged file with an existing canonical file without a missing-target window. * On Unix the displaced entry remains at `source`; Windows places it at `backup`. */ export function replaceFilePreservingTarget(source: string, target: string, backup: string): void { if (process.platform === "linux") { - const exchange = unixExchange("renameat2"); - if (exchange?.(source, target)) return; - throw new PreservingReplaceError(); + const result = unixExchange("renameat2")(source, target); + if (result.ok) return; + throw failedReplacement({ operation: "renameat2", sourcePath: source, targetPath: target, backupPath: backup, result }); } if (process.platform === "darwin") { - const exchange = unixExchange("renamex_np"); - if (exchange?.(source, target)) return; - throw new PreservingReplaceError(); + const result = unixExchange("renamex_np")(source, target); + if (result.ok) return; + throw failedReplacement({ operation: "renamex_np", sourcePath: source, targetPath: target, backupPath: backup, result }); + } + if (process.platform === "win32") { + const result = windowsExchange(source, target, backup); + if (result.ok) return; + throw failedReplacement({ operation: "ReplaceFileW", sourcePath: source, targetPath: target, backupPath: backup, result }); } - if (process.platform === "win32" && windowsExchange(source, target, backup)) return; // No rename fallback is safe: it can make auth.json absent between operations. - throw new PreservingReplaceError(); + throw failedReplacement({ operation: "unsupported", sourcePath: source, targetPath: target, backupPath: backup, result: { ok: false } }); } /** Restore a verified displaced entry while preserving the canonical target. */ diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 7a356df8c8..36021b37e0 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -9,7 +9,7 @@ import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenReques import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic"; import { loginKimi, refreshKimiToken } from "./kimi"; import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous"; -import { loginChatGPT, refreshChatGPTToken } from "./chatgpt"; +import { ChatGPTTokenRefreshError, loginChatGPT, refreshChatGPTToken } from "./chatgpt"; import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; import { loginCursor, refreshCursorToken } from "./cursor"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; @@ -556,6 +556,9 @@ function terminal(error:unknown):boolean{ if(error instanceof AnthropicTokenError)return (error.httpStatus===400||error.httpStatus===401)&&["invalid_grant","refresh_token_reused","revoked","revoked_token","refresh_token_revoked"].includes(error.oauthError??""); if(error instanceof KiroTokenRefreshError)return (error.httpStatus===400||error.httpStatus===401)&&error.oauthError!==undefined; if(error instanceof NousTokenError)return error.terminal===true||["invalid_grant","refresh_token_reused","revoked","revoked_token","expired_token"].includes(error.oauthError??""); + if (error instanceof ChatGPTTokenRefreshError) { + return error.code === "invalid_grant" && (error.status === 400 || error.status === 401); + } // Local durable-write/read/cleanup failures are operational, not credential // death: the provider credential was never rejected or consumed. Never mark // the account needsReauth for broken local persistence infrastructure. diff --git a/tests/atomic-file-preserving-replace.test.ts b/tests/atomic-file-preserving-replace.test.ts index d2a789dc86..8d618f8fe1 100644 --- a/tests/atomic-file-preserving-replace.test.ts +++ b/tests/atomic-file-preserving-replace.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { replaceFilePreservingTarget } from "../src/lib/atomic-file-preserving-replace"; +import { PreservingReplaceError, replaceFilePreservingTarget } from "../src/lib/atomic-file-preserving-replace"; let directory = ""; @@ -26,4 +26,28 @@ describe("preserving file replacement", () => { expect(readFileSync(canonical, "utf8")).toBe("replacement"); expect(readFileSync(process.platform === "win32" ? backup : staged, "utf8")).toBe("external-before"); }); + + test("reports structured native details when the staged source is missing", () => { + directory = mkdtempSync(join(tmpdir(), "ocx-preserving-replace-")); + const staged = join(directory, ".missing-refresh.new"); + const canonical = join(directory, "auth.json"); + const backup = join(directory, ".refresh.previous"); + writeFileSync(canonical, "external-before"); + + let failure: unknown; + try { + replaceFilePreservingTarget(staged, canonical, backup); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(PreservingReplaceError); + const structured = failure as PreservingReplaceError; + expect(structured.operation).toBe(process.platform === "linux" ? "renameat2" : process.platform === "darwin" ? "renamex_np" : process.platform === "win32" ? "ReplaceFileW" : "unsupported"); + expect(structured.sourcePath).toBe(staged); + expect(structured.targetPath).toBe(canonical); + expect(structured.backupPath).toBe(backup); + expect(structured.platform).toBe(process.platform); + if (["linux", "darwin", "win32"].includes(process.platform)) expect(structured.nativeCode).toBe(2); + }); }); diff --git a/tests/codex-main-account-refresh.test.ts b/tests/codex-main-account-refresh.test.ts index d13fb54b61..db93f30661 100644 --- a/tests/codex-main-account-refresh.test.ts +++ b/tests/codex-main-account-refresh.test.ts @@ -4,10 +4,14 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSy import { tmpdir } from "node:os"; import { join } from "node:path"; import { + forceRefreshMainAccountToken, getValidMainAccountToken, + MainAccountRefreshCancelledError, MainAccountTokenRefreshError, setMainAuthJsonBeforeRenameHookForTests, } from "../src/codex/main-account"; +import { recoverNativeMainRefreshPublication } from "../src/codex/native-main-refresh-publication"; +import { resolveNativeProfileContext } from "../src/codex/native-profile-store"; let home: string; let previousCodexHome: string | undefined; @@ -235,4 +239,158 @@ describe("native main token refresh", () => { await expect(joined).resolves.toEqual({ accessToken: "new-access", chatgptAccountId: "account-main" }); expect(attempts).toBe(1); }); + + test("persists a returned rotation after the only caller cancels", async () => { + const authPath = join(home, "auth.json"); + writeFileSync(authPath, JSON.stringify({ + tokens: { + access_token: expiredJwt(), + refresh_token: "old-refresh", + account_id: "account-main", + }, + })); + const caller = new AbortController(); + let entered!: () => void; + const enteredRefresh = new Promise(resolve => { entered = resolve; }); + let complete!: (value: { access: string; refresh: string; expires: number; accountId: string }) => void; + const remoteResult = new Promise<{ access: string; refresh: string; expires: number; accountId: string }>(resolve => { + complete = resolve; + }); + let attempts = 0; + const refreshToken = async () => { + attempts += 1; + entered(); + return await remoteResult; + }; + + const cancelled = getValidMainAccountToken({ signal: caller.signal, refreshToken }); + await enteredRefresh; + complete({ access: "access-b", refresh: "refresh-b", expires: Date.now() + 3_600_000, accountId: "account-main" }); + caller.abort(); + + await expect(cancelled).rejects.toBeInstanceOf(MainAccountRefreshCancelledError); + await expect(getValidMainAccountToken({ refreshToken })).resolves.toEqual({ accessToken: "access-b", chatgptAccountId: "account-main" }); + expect(JSON.parse(readFileSync(authPath, "utf8")).tokens).toMatchObject({ + access_token: "access-b", + refresh_token: "refresh-b", + }); + expect(attempts).toBe(1); + }); + + test("reflights a joined rejected bearer once and coalesces force-refresh joiners", async () => { + writeFileSync(join(home, "auth.json"), JSON.stringify({ + tokens: { + access_token: expiredJwt(), + refresh_token: "old-refresh", + account_id: "account-main", + }, + })); + let attempts = 0; + let firstEntered!: () => void; + const firstStarted = new Promise(resolve => { firstEntered = resolve; }); + let releaseFirst!: () => void; + const firstReleased = new Promise(resolve => { releaseFirst = resolve; }); + let secondEntered!: () => void; + const secondStarted = new Promise(resolve => { secondEntered = resolve; }); + let releaseSecond!: () => void; + const secondReleased = new Promise(resolve => { releaseSecond = resolve; }); + const refreshToken = async () => { + attempts += 1; + if (attempts === 1) { + firstEntered(); + await firstReleased; + return { access: "rejected-bearer", refresh: "refresh-b", expires: Date.now() + 3_600_000, accountId: "account-main" }; + } + secondEntered(); + await secondReleased; + return { access: "fresh-bearer", refresh: "refresh-c", expires: Date.now() + 3_600_000, accountId: "account-main" }; + }; + + const ordinary = getValidMainAccountToken({ refreshToken }); + await firstStarted; + const forceOne = forceRefreshMainAccountToken("rejected-bearer", { refreshToken }); + const forceTwo = forceRefreshMainAccountToken("rejected-bearer", { refreshToken }); + releaseFirst(); + await secondStarted; + releaseSecond(); + + await expect(ordinary).resolves.toEqual({ accessToken: "rejected-bearer", chatgptAccountId: "account-main" }); + await expect(forceOne).resolves.toEqual({ accessToken: "fresh-bearer", chatgptAccountId: "account-main" }); + await expect(forceTwo).resolves.toEqual({ accessToken: "fresh-bearer", chatgptAccountId: "account-main" }); + expect(attempts).toBe(2); + }); + + test("fails transiently when the bounded replacement returns the rejected bearer again", async () => { + writeFileSync(join(home, "auth.json"), JSON.stringify({ + tokens: { + access_token: expiredJwt(), + refresh_token: "old-refresh", + account_id: "account-main", + }, + })); + let attempts = 0; + const refreshToken = async () => { + attempts += 1; + return { + access: "rejected-bearer", + refresh: attempts === 1 ? "refresh-b" : "refresh-c", + expires: Date.now() + 3_600_000, + accountId: "account-main", + }; + }; + + const failure = await forceRefreshMainAccountToken("rejected-bearer", { refreshToken }).catch(error => error); + + expect(failure).toBeInstanceOf(MainAccountTokenRefreshError); + expect((failure as MainAccountTokenRefreshError).reason).toBe("transient"); + expect(attempts).toBe(2); + }); + + test("cleans committed recovery journals with zero, one, and multiple exact remnants", () => { + const original = JSON.stringify({ tokens: { refresh_token: "old-refresh", account_id: "account-main" } }); + const replacement = JSON.stringify({ tokens: { access_token: "access-b", refresh_token: "refresh-b", account_id: "account-main" } }); + const digest = (value: string) => createHash("sha256").update(value).digest("hex"); + const cases = [ + { transactionId: "11111111-1111-4111-8111-111111111111", remnants: [] as string[] }, + { transactionId: "22222222-2222-4222-8222-222222222222", remnants: ["staged"] }, + { transactionId: "33333333-3333-4333-8333-333333333333", remnants: ["staged", "previous"] }, + ]; + for (const testCase of cases) { + const staged = `.opencodex-native-main-refresh.${testCase.transactionId}.new`; + const previous = `.opencodex-native-main-refresh.${testCase.transactionId}.previous`; + const journalPath = join(home, ".opencodex-native-main-refresh.json"); + writeFileSync(join(home, "auth.json"), replacement); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + transactionId: testCase.transactionId, + stagedBasename: staged, + previousBasename: previous, + phase: "replaced", + expectedSha256: digest(original), + replacementSha256: digest(replacement), + })); + if (testCase.remnants.includes("staged")) writeFileSync(join(home, staged), original); + if (testCase.remnants.includes("previous")) writeFileSync(join(home, previous), original); + + recoverNativeMainRefreshPublication(resolveNativeProfileContext()); + + expect(readFileSync(join(home, "auth.json"), "utf8")).toBe(replacement); + expect(existsSync(journalPath)).toBe(false); + expect(existsSync(join(home, staged))).toBe(false); + expect(existsSync(join(home, previous))).toBe(false); + } + }); + + test("retains malformed recovery state while preserving its parse cause", async () => { + const journalPath = join(home, ".opencodex-native-main-refresh.json"); + writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { refresh_token: "old-refresh", account_id: "account-main" } })); + writeFileSync(journalPath, "{"); + + const failure = await getValidMainAccountToken().catch(error => error); + + expect(failure).toBeInstanceOf(MainAccountTokenRefreshError); + expect((failure as Error & { cause?: unknown }).cause).toBeInstanceOf(Error); + expect(((failure as Error & { cause?: Error }).cause as Error & { cause?: unknown }).cause).toBeInstanceOf(SyntaxError); + expect(existsSync(journalPath)).toBe(true); + }); }); diff --git a/tests/helpers/native-main-refresh-process-worker.ts b/tests/helpers/native-main-refresh-process-worker.ts new file mode 100644 index 0000000000..d1059101b0 --- /dev/null +++ b/tests/helpers/native-main-refresh-process-worker.ts @@ -0,0 +1,32 @@ +import { createInterface } from "node:readline"; +import { getValidMainAccountToken } from "../../src/codex/main-account"; + +function emit(event: Record): void { + process.stdout.write(`${JSON.stringify({ ...event, pid: process.pid })}\n`); +} + +const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); + +emit({ event: "ready" }); + +for await (const line of input) { + if (line !== "run") continue; + try { + const result = await getValidMainAccountToken({ + refreshToken: async () => { + emit({ event: "refresh" }); + return { + access: "fresh-access", + refresh: "rotated-grant", + expires: Date.now() + 3_600_000, + accountId: "account-main", + }; + }, + }); + emit({ event: "result", ...result }); + } catch (error) { + emit({ event: "fatal", message: error instanceof Error ? error.message : String(error) }); + process.exitCode = 1; + } + break; +} diff --git a/tests/native-main-refresh-process.test.ts b/tests/native-main-refresh-process.test.ts index a372baa686..dbb0683605 100644 --- a/tests/native-main-refresh-process.test.ts +++ b/tests/native-main-refresh-process.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getValidMainAccountToken } from "../src/codex/main-account"; @@ -7,6 +7,76 @@ import { getValidMainAccountToken } from "../src/codex/main-account"; let home = ""; let previousCodexHome: string | undefined; +type WorkerEvent = { + event: "ready" | "refresh" | "result" | "fatal"; + pid: number; + accessToken?: string; + chatgptAccountId?: string; + message?: string; +}; + +type Worker = { + child: ReturnType; + ready: Promise; + result: Promise; + events: WorkerEvent[]; + stdout: Promise; +}; + +function spawnRefreshWorker(): Worker { + const child = Bun.spawn([process.execPath, join(import.meta.dir, "helpers", "native-main-refresh-process-worker.ts")], { + cwd: join(import.meta.dir, ".."), + env: { ...process.env, CODEX_HOME: home }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + let resolveReady!: (value: WorkerEvent) => void; + let rejectReady!: (reason: unknown) => void; + const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; }); + let resolveResult!: (value: WorkerEvent) => void; + let rejectResult!: (reason: unknown) => void; + const result = new Promise((resolve, reject) => { resolveResult = resolve; rejectResult = reject; }); + const events: WorkerEvent[] = []; + const stdout = (async () => { + const reader = child.stdout.getReader(); + const decoder = new TextDecoder(); + let pending = ""; + for (;;) { + const next = await reader.read(); + if (next.done) break; + pending += decoder.decode(next.value, { stream: true }); + const lines = pending.split("\n"); + pending = lines.pop() ?? ""; + for (const line of lines) { + if (!line) continue; + const event = JSON.parse(line) as WorkerEvent; + events.push(event); + if (event.event === "ready") resolveReady(event); + if (event.event === "result") resolveResult(event); + if (event.event === "fatal") { + const failure = new Error(event.message ?? "native-main refresh worker failed"); + rejectReady(failure); + rejectResult(failure); + } + } + } + if (pending) { + const event = JSON.parse(pending) as WorkerEvent; + events.push(event); + if (event.event === "ready") resolveReady(event); + if (event.event === "result") resolveResult(event); + } + })(); + child.exited.then(async exit => { + if (exit === 0) return; + const failure = new Error(await new Response(child.stderr).text()); + rejectReady(failure); + rejectResult(failure); + }); + return { child, ready, result, events, stdout }; +} + beforeEach(() => { home = mkdtempSync(join(tmpdir(), "ocx-native-main-flight-")); previousCodexHome = process.env.CODEX_HOME; @@ -44,4 +114,33 @@ describe("native-main refresh process coordination", () => { await expect(second).resolves.toEqual({ accessToken: "fresh-access", chatgptAccountId: "account-main" }); expect(attempts).toBe(1); }); + + test("two Bun PIDs share the SQLite claim and persist one rotated credential", async () => { + const first = spawnRefreshWorker(); + const second = spawnRefreshWorker(); + await Promise.all([first.ready, second.ready]); + first.child.stdin.write("run\n"); + second.child.stdin.write("run\n"); + first.child.stdin.end(); + second.child.stdin.end(); + + const [firstResult, secondResult, firstExit, secondExit] = await Promise.all([ + first.result, + second.result, + first.child.exited, + second.child.exited, + ]); + await Promise.all([first.stdout, second.stdout]); + + expect(firstExit).toBe(0); + expect(secondExit).toBe(0); + expect(new Set([first.events[0]?.pid, second.events[0]?.pid]).size).toBe(2); + expect([...first.events, ...second.events].filter(event => event.event === "refresh")).toHaveLength(1); + expect(firstResult).toMatchObject({ event: "result", accessToken: "fresh-access", chatgptAccountId: "account-main" }); + expect(secondResult).toMatchObject({ event: "result", accessToken: "fresh-access", chatgptAccountId: "account-main" }); + expect(JSON.parse(readFileSync(join(home, "auth.json"), "utf8")).tokens).toMatchObject({ + access_token: "fresh-access", + refresh_token: "rotated-grant", + }); + }); }); diff --git a/tests/oauth-refresh.test.ts b/tests/oauth-refresh.test.ts index 0975e8285c..d976844bbb 100644 --- a/tests/oauth-refresh.test.ts +++ b/tests/oauth-refresh.test.ts @@ -3,7 +3,8 @@ import { Database } from "bun:sqlite"; import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { getValidAccessToken, getValidAccessTokenForAccount, OAuthLoginRequiredError, OAuthTokenRefreshBusyError, OAuthTokenRefreshStaleError, OAUTH_PROVIDERS, refreshAnthropicAccountWithLock, seedOAuthTokenRefreshFlightsForTests } from "../src/oauth"; +import { getValidAccessToken, getValidAccessTokenForAccount, OAuthLoginRequiredError, OAuthTokenRefreshBusyError, OAuthTokenRefreshStaleError, OAUTH_PROVIDERS, refreshAnthropicAccountWithLock, refreshGenericAccountWithLock, seedOAuthTokenRefreshFlightsForTests } from "../src/oauth"; +import { ChatGPTTokenRefreshError } from "../src/oauth/chatgpt"; import { RefreshIntentIOError, nousRefreshIntentBlocksReplay } from "../src/oauth/nous"; import * as nousModule from "../src/oauth/nous"; import { AnthropicTokenError } from "../src/oauth/anthropic"; @@ -137,6 +138,41 @@ function mockRefreshFetch(responses: Array): { count: () => nu } describe("oauth refresh hardening", () => { + test("classifies only structured ChatGPT invalid_grant 400 and 401 as terminal", async () => { + const cases = [ + { status: 400, code: "invalid_grant", terminal: true }, + { status: 401, code: "invalid_grant", terminal: true }, + { status: 500, code: "invalid_grant", terminal: false }, + { status: 400, code: "other", terminal: false }, + ]; + for (const testCase of cases) { + const accountId = `chatgpt-${testCase.status}-${testCase.code}`; + const credential = { + access: "expired-access", + refresh: "refresh-token", + expires: Date.now() - 1, + accountId, + }; + await saveCredential("chatgpt", credential); + const activeAccountId = getAccountSet("chatgpt")!.activeAccountId; + const storedCredential = getAccountCredential("chatgpt", activeAccountId)!; + const provider = { + ...OAUTH_PROVIDERS.chatgpt!, + refresh: async () => { throw new ChatGPTTokenRefreshError(testCase.status, testCase.code); }, + }; + + const failure = await refreshGenericAccountWithLock("chatgpt", activeAccountId, provider, storedCredential).catch(error => error); + + if (testCase.terminal) { + expect(failure).toBeInstanceOf(OAuthLoginRequiredError); + expect(getAccountSet("chatgpt")?.accounts.find(account => account.id === activeAccountId)?.needsReauth).toBe(true); + continue; + } + expect(failure).toBeInstanceOf(ChatGPTTokenRefreshError); + expect(getAccountSet("chatgpt")?.accounts.find(account => account.id === activeAccountId)?.needsReauth).not.toBe(true); + } + }); + test("OAuth token-refresh flight 33 rejects and a stale same-key owner cannot delete replacement", async () => { await saveCredential("kiro", { access: "old", refresh: "rt-old", expires: 1, accountId: "flight-owner" }); const accountId = getAccountSet("kiro")!.activeAccountId; From 88bdf21a40ee0d89376326a7e55bc34cb74fa33c Mon Sep 17 00:00:00 2001 From: MarcTCruz <58499846+MarcTCruz@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:01:09 +0000 Subject: [PATCH 4/8] fix(codex): preserve symlinked native auth targets --- src/codex/main-account.ts | 5 +- src/codex/native-main-refresh-publication.ts | 53 ++++-- tests/codex-main-account-refresh.test.ts | 169 ++++++++++++++++++- 3 files changed, 208 insertions(+), 19 deletions(-) diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index fd4e33bbad..4450af0250 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -11,6 +11,7 @@ import { import type { OAuthCredentials } from "../oauth/types"; import { extractChatgptPlanType } from "./plan"; import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; +import { resolveWriteTarget } from "../config/atomic-write"; import { resolveCodexHomeDir } from "./home"; import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard"; import { clearAccountNeedsReauth } from "./account-runtime-state"; @@ -95,7 +96,7 @@ function nonEmptyString(value: unknown): string | undefined { } function readMainAuthJsonCredential(): MainAuthJsonCredential | null { - const path = join(resolveCodexHomeDir(), "auth.json"); + const path = resolveWriteTarget(join(resolveCodexHomeDir(), "auth.json")); let raw: string; try { raw = readFileSync(path, "utf8"); @@ -177,7 +178,7 @@ function persistRefreshedMainAuthJson( beforeMainAuthJsonRenameForTests = null; hook?.(); assertMainAuthJsonSnapshotUnchanged(expected); - publishNativeMainRefresh(context, expected.raw, JSON.stringify({ ...expected.root, tokens }, null, 2) + "\n"); + publishNativeMainRefresh(context, expected.path, expected.raw, JSON.stringify({ ...expected.root, tokens }, null, 2) + "\n"); return { accessToken, chatgptAccountId }; } diff --git a/src/codex/native-main-refresh-publication.ts b/src/codex/native-main-refresh-publication.ts index f6d0775e4b..1f75978db8 100644 --- a/src/codex/native-main-refresh-publication.ts +++ b/src/codex/native-main-refresh-publication.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import { closeSync, existsSync, fsyncSync, openSync, readFileSync, unlinkSync } from "node:fs"; -import { basename, join } from "node:path"; -import { atomicWriteFile } from "../config/atomic-write"; +import { basename, dirname, join } from "node:path"; +import { atomicWriteFile, resolveWriteTarget } from "../config/atomic-write"; import { PreservingReplaceError, replaceFilePreservingTarget, restoreFilePreservingTarget } from "../lib/atomic-file-preserving-replace"; import type { NativeProfileContext } from "./native-profile-store"; @@ -11,6 +11,7 @@ const TRANSACTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}- type Journal = { version: 1; transactionId: string; + targetPath: string; stagedBasename: string; previousBasename: string; phase: "prepared" | "replaced"; @@ -44,6 +45,8 @@ function validJournal(value: unknown): value is Journal { const transactionId = typeof item.transactionId === "string" ? item.transactionId : ""; return item.version === 1 && TRANSACTION_ID.test(transactionId) + && typeof item.targetPath === "string" + && item.targetPath.length > 0 && item.stagedBasename === `.opencodex-native-main-refresh.${transactionId}.new` && item.previousBasename === `.opencodex-native-main-refresh.${transactionId}.previous` && (item.phase === "prepared" || item.phase === "replaced") @@ -60,15 +63,28 @@ function removeExact(path: string): void { try { unlinkSync(path); } catch (cause) { throw new NativeMainRefreshPublicationError({ cause }); } } -function journalPaths(context: NativeProfileContext, journal: Journal): { staged: string; previous: string } { +function resolveAuthTarget(context: NativeProfileContext): string { + try { + return resolveWriteTarget(context.authPath); + } catch (cause) { + throw new NativeMainRefreshPublicationError({ cause }); + } +} + +function assertAuthTarget(context: NativeProfileContext, expectedTarget: string): void { + if (resolveAuthTarget(context) !== expectedTarget) throw new NativeMainRefreshPublicationError(); +} + +function journalPaths(journal: Journal): { staged: string; previous: string } { + const targetDir = dirname(journal.targetPath); return { - staged: join(context.codexHome, journal.stagedBasename), - previous: join(context.codexHome, journal.previousBasename), + staged: join(targetDir, journal.stagedBasename), + previous: join(targetDir, journal.previousBasename), }; } function cleanup(context: NativeProfileContext, journal: Journal): void { - const { staged, previous } = journalPaths(context, journal); + const { staged, previous } = journalPaths(journal); for (const path of [staged, previous, journalPath(context)]) { if (existsSync(path)) removeExact(path); } @@ -81,13 +97,14 @@ export function recoverNativeMainRefreshPublication(context: NativeProfileContex let journal: Journal; try { journal = JSON.parse(readFileSync(path, "utf8")) as Journal; } catch (cause) { throw new NativeMainRefreshPublicationError({ cause }); } if (!validJournal(journal)) throw new NativeMainRefreshPublicationError(); - const { staged, previous } = journalPaths(context, journal); - const canonical = readExact(context.authPath); + assertAuthTarget(context, journal.targetPath); + const { staged, previous } = journalPaths(journal); + const canonical = readExact(journal.targetPath); const stagedBytes = readExact(staged); if (!canonical) throw new NativeMainRefreshPublicationError(); if (digest(canonical) === journal.expectedSha256 && stagedBytes && digest(stagedBytes) === journal.replacementSha256) { try { - replaceFilePreservingTarget(staged, context.authPath, previous); + replaceFilePreservingTarget(staged, journal.targetPath, previous); cleanup(context, { ...journal, phase: "replaced" }); } catch (cause) { throw new NativeMainRefreshPublicationError({ cause }); @@ -104,6 +121,7 @@ export function recoverNativeMainRefreshPublication(context: NativeProfileContex /** The sole native-main auth.json publisher. */ export function publishNativeMainRefresh( context: NativeProfileContext, + targetPath: string, expected: string, replacement: string, ): void { @@ -113,33 +131,36 @@ export function publishNativeMainRefresh( const journal: Journal = { version: 1, transactionId: tx, + targetPath, stagedBasename, previousBasename, phase: "prepared", expectedSha256: digest(expected), replacementSha256: digest(replacement), }; - const staged = join(context.codexHome, stagedBasename); - const previous = join(context.codexHome, previousBasename); + const { staged, previous } = journalPaths(journal); try { - if (digest(readFileSync(context.authPath)) !== journal.expectedSha256) throw new NativeMainRefreshPublicationError(); + assertAuthTarget(context, targetPath); + if (digest(readFileSync(targetPath)) !== journal.expectedSha256) throw new NativeMainRefreshPublicationError(); atomicWriteFile(staged, replacement); fsync(staged); atomicWriteFile(journalPath(context), `${JSON.stringify(journal)}\n`); - replaceFilePreservingTarget(staged, context.authPath, previous); + assertAuthTarget(context, targetPath); + if (digest(readFileSync(targetPath)) !== journal.expectedSha256) throw new NativeMainRefreshPublicationError(); + replaceFilePreservingTarget(staged, targetPath, previous); const displaced = readExact(process.platform === "win32" ? previous : staged); if (!displaced || digest(displaced) !== journal.expectedSha256) { - const canonical = readExact(context.authPath); + const canonical = readExact(targetPath); if (canonical && digest(canonical) === journal.replacementSha256) { restoreFilePreservingTarget( process.platform === "win32" ? previous : staged, - context.authPath, + targetPath, process.platform === "win32" ? staged : previous, ); } throw new NativeMainRefreshPublicationError(); } - const canonical = readExact(context.authPath); + const canonical = readExact(targetPath); if (!canonical || digest(canonical) !== journal.replacementSha256) { throw new NativeMainRefreshPublicationError(); } diff --git a/tests/codex-main-account-refresh.test.ts b/tests/codex-main-account-refresh.test.ts index db93f30661..5301e8a726 100644 --- a/tests/codex-main-account-refresh.test.ts +++ b/tests/codex-main-account-refresh.test.ts @@ -1,6 +1,18 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -16,6 +28,19 @@ import { resolveNativeProfileContext } from "../src/codex/native-profile-store"; let home: string; let previousCodexHome: string | undefined; +const canSymlink = (() => { + const dir = mkdtempSync(join(tmpdir(), "ocx-main-refresh-symlink-probe-")); + try { + symlinkSync(join(dir, "probe-target"), join(dir, "probe-link")); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EPERM") return false; + throw error; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +})(); + function expiredJwt(): string { const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) - 60 })).toString("base64url"); return `header.${payload}.signature`; @@ -78,6 +103,73 @@ describe("native main token refresh", () => { expect(readdirSync(home).filter(name => name.includes(".tmp"))).toEqual([]); }); + test.skipIf(!canSymlink)("preserves a symlinked auth file and rotates its canonical target", async () => { + const managedDir = join(home, "dotfiles"); + const targetPath = join(managedDir, "auth.json"); + const authPath = join(home, "auth.json"); + mkdirSync(managedDir); + writeFileSync(targetPath, JSON.stringify({ + tokens: { + access_token: expiredJwt(), + refresh_token: "old-refresh", + account_id: "account-main", + }, + })); + symlinkSync(targetPath, authPath); + + await expect(getValidMainAccountToken({ + refreshToken: async () => ({ + access: "new-access", + refresh: "rotated-refresh", + expires: Date.now() + 3_600_000, + accountId: "account-main", + }), + })).resolves.toEqual({ accessToken: "new-access", chatgptAccountId: "account-main" }); + + expect(lstatSync(authPath).isSymbolicLink()).toBe(true); + expect(readlinkSync(authPath)).toBe(targetPath); + expect(JSON.parse(readFileSync(targetPath, "utf8")).tokens).toMatchObject({ + access_token: "new-access", + refresh_token: "rotated-refresh", + }); + expect(readdirSync(managedDir).filter(name => name.startsWith(".opencodex-native-main-refresh."))).toEqual([]); + }); + + test.skipIf(!canSymlink)("fails closed when the auth symlink is retargeted during refresh", async () => { + const authPath = join(home, "auth.json"); + const firstTarget = join(home, "first-auth.json"); + const secondTarget = join(home, "second-auth.json"); + const original = JSON.stringify({ + tokens: { + access_token: expiredJwt(), + refresh_token: "old-refresh", + account_id: "account-main", + }, + }); + const external = JSON.stringify({ tokens: { access_token: "external-access" } }); + writeFileSync(firstTarget, original); + writeFileSync(secondTarget, external); + symlinkSync(firstTarget, authPath); + setMainAuthJsonBeforeRenameHookForTests(() => { + unlinkSync(authPath); + symlinkSync(secondTarget, authPath); + }); + + await expect(getValidMainAccountToken({ + refreshToken: async () => ({ + access: "new-access", + refresh: "rotated-refresh", + expires: Date.now() + 3_600_000, + accountId: "account-main", + }), + })).rejects.toThrow("changed while its token was refreshing"); + + expect(lstatSync(authPath).isSymbolicLink()).toBe(true); + expect(readlinkSync(authPath)).toBe(secondTarget); + expect(readFileSync(firstTarget, "utf8")).toBe(original); + expect(readFileSync(secondTarget, "utf8")).toBe(external); + }); + test("refuses to overwrite an external auth writer after refresh", async () => { const authPath = join(home, "auth.json"); writeFileSync(authPath, JSON.stringify({ @@ -154,6 +246,7 @@ describe("native main token refresh", () => { writeFileSync(journalPath, JSON.stringify({ version: 1, transactionId: "22222222-2222-4222-8222-222222222222", + targetPath: authPath, stagedBasename, previousBasename: `.opencodex-native-main-refresh.${fileTransactionId}.previous`, phase: "prepared", @@ -188,6 +281,7 @@ describe("native main token refresh", () => { writeFileSync(journalPath, JSON.stringify({ version: 1, transactionId, + targetPath: authPath, stagedBasename, previousBasename: `.opencodex-native-main-refresh.${transactionId}.previous`, phase: "prepared", @@ -346,6 +440,78 @@ describe("native main token refresh", () => { expect(attempts).toBe(2); }); + test.skipIf(!canSymlink)("recovers a prepared publication through the original symlink target", () => { + const managedDir = join(home, "dotfiles-recovery"); + const targetPath = join(managedDir, "auth.json"); + const authPath = join(home, "auth.json"); + const transactionId = "11111111-1111-4111-8111-111111111111"; + const stagedBasename = `.opencodex-native-main-refresh.${transactionId}.new`; + const previousBasename = `.opencodex-native-main-refresh.${transactionId}.previous`; + const original = JSON.stringify({ tokens: { refresh_token: "old-refresh", account_id: "account-main" } }); + const replacement = JSON.stringify({ tokens: { access_token: "new-access", refresh_token: "new-refresh" } }); + const digest = (value: string) => createHash("sha256").update(value).digest("hex"); + mkdirSync(managedDir); + writeFileSync(targetPath, original); + symlinkSync(targetPath, authPath); + writeFileSync(join(managedDir, stagedBasename), replacement); + writeFileSync(join(home, ".opencodex-native-main-refresh.json"), JSON.stringify({ + version: 1, + transactionId, + targetPath, + stagedBasename, + previousBasename, + phase: "prepared", + expectedSha256: digest(original), + replacementSha256: digest(replacement), + })); + + recoverNativeMainRefreshPublication(resolveNativeProfileContext()); + + expect(lstatSync(authPath).isSymbolicLink()).toBe(true); + expect(readlinkSync(authPath)).toBe(targetPath); + expect(readFileSync(targetPath, "utf8")).toBe(replacement); + expect(readdirSync(managedDir)).toEqual(["auth.json"]); + expect(existsSync(join(home, ".opencodex-native-main-refresh.json"))).toBe(false); + }); + + test.skipIf(!canSymlink)("refuses recovery after the auth symlink target changes", () => { + const authPath = join(home, "auth.json"); + const firstTarget = join(home, "first-auth.json"); + const secondTarget = join(home, "second-auth.json"); + const transactionId = "11111111-1111-4111-8111-111111111111"; + const stagedBasename = `.opencodex-native-main-refresh.${transactionId}.new`; + const previousBasename = `.opencodex-native-main-refresh.${transactionId}.previous`; + const journalPath = join(home, ".opencodex-native-main-refresh.json"); + const original = JSON.stringify({ tokens: { refresh_token: "old-refresh", account_id: "account-main" } }); + const replacement = JSON.stringify({ tokens: { access_token: "new-access", refresh_token: "new-refresh" } }); + const external = JSON.stringify({ tokens: { access_token: "external-access" } }); + const digest = (value: string) => createHash("sha256").update(value).digest("hex"); + writeFileSync(firstTarget, original); + writeFileSync(secondTarget, external); + symlinkSync(secondTarget, authPath); + writeFileSync(join(home, stagedBasename), replacement); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + transactionId, + targetPath: firstTarget, + stagedBasename, + previousBasename, + phase: "prepared", + expectedSha256: digest(original), + replacementSha256: digest(replacement), + })); + + expect(() => recoverNativeMainRefreshPublication(resolveNativeProfileContext())).toThrow( + "Native credential refresh could not be published", + ); + + expect(readlinkSync(authPath)).toBe(secondTarget); + expect(readFileSync(firstTarget, "utf8")).toBe(original); + expect(readFileSync(secondTarget, "utf8")).toBe(external); + expect(existsSync(journalPath)).toBe(true); + expect(existsSync(join(home, stagedBasename))).toBe(true); + }); + test("cleans committed recovery journals with zero, one, and multiple exact remnants", () => { const original = JSON.stringify({ tokens: { refresh_token: "old-refresh", account_id: "account-main" } }); const replacement = JSON.stringify({ tokens: { access_token: "access-b", refresh_token: "refresh-b", account_id: "account-main" } }); @@ -363,6 +529,7 @@ describe("native main token refresh", () => { writeFileSync(journalPath, JSON.stringify({ version: 1, transactionId: testCase.transactionId, + targetPath: join(home, "auth.json"), stagedBasename: staged, previousBasename: previous, phase: "replaced", From a0078320ca319f188468a942e5ca1c35057534a9 Mon Sep 17 00:00:00 2001 From: MarcTCruz <58499846+MarcTCruz@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:17:33 +0000 Subject: [PATCH 5/8] fix(codex): preserve displaced auth writers --- src/codex/native-main-refresh-publication.ts | 18 ++++++ tests/codex-main-account-refresh.test.ts | 62 ++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/codex/native-main-refresh-publication.ts b/src/codex/native-main-refresh-publication.ts index 1f75978db8..573b0566c7 100644 --- a/src/codex/native-main-refresh-publication.ts +++ b/src/codex/native-main-refresh-publication.ts @@ -101,6 +101,9 @@ export function recoverNativeMainRefreshPublication(context: NativeProfileContex const { staged, previous } = journalPaths(journal); const canonical = readExact(journal.targetPath); const stagedBytes = readExact(staged); + const displacedPath = process.platform === "win32" ? previous : staged; + const rollbackPath = process.platform === "win32" ? staged : previous; + const displacedBytes = readExact(displacedPath); if (!canonical) throw new NativeMainRefreshPublicationError(); if (digest(canonical) === journal.expectedSha256 && stagedBytes && digest(stagedBytes) === journal.replacementSha256) { try { @@ -112,6 +115,20 @@ export function recoverNativeMainRefreshPublication(context: NativeProfileContex return; } if (digest(canonical) === journal.replacementSha256) { + if (journal.phase === "prepared") { + if (!displacedBytes) throw new NativeMainRefreshPublicationError(); + if (digest(displacedBytes) !== journal.expectedSha256) { + try { + restoreFilePreservingTarget(displacedPath, journal.targetPath, rollbackPath); + const restored = readExact(journal.targetPath); + if (!restored || !restored.equals(displacedBytes)) throw new NativeMainRefreshPublicationError(); + cleanup(context, journal); + } catch (cause) { + throw new NativeMainRefreshPublicationError({ cause }); + } + return; + } + } try { cleanup(context, journal); } catch (cause) { throw new NativeMainRefreshPublicationError({ cause }); } return; } @@ -157,6 +174,7 @@ export function publishNativeMainRefresh( targetPath, process.platform === "win32" ? staged : previous, ); + cleanup(context, journal); } throw new NativeMainRefreshPublicationError(); } diff --git a/tests/codex-main-account-refresh.test.ts b/tests/codex-main-account-refresh.test.ts index 5301e8a726..6d4fdf0d4e 100644 --- a/tests/codex-main-account-refresh.test.ts +++ b/tests/codex-main-account-refresh.test.ts @@ -512,6 +512,68 @@ describe("native main token refresh", () => { expect(existsSync(join(home, stagedBasename))).toBe(true); }); + test("restores an external writer displaced by an interrupted prepared exchange", () => { + const authPath = join(home, "auth.json"); + const transactionId = "11111111-1111-4111-8111-111111111111"; + const stagedBasename = `.opencodex-native-main-refresh.${transactionId}.new`; + const previousBasename = `.opencodex-native-main-refresh.${transactionId}.previous`; + const stagedPath = join(home, stagedBasename); + const previousPath = join(home, previousBasename); + const journalPath = join(home, ".opencodex-native-main-refresh.json"); + const original = JSON.stringify({ tokens: { access_token: "old-access", refresh_token: "old-refresh" } }); + const replacement = JSON.stringify({ tokens: { access_token: "new-access", refresh_token: "new-refresh" } }); + const external = JSON.stringify({ tokens: { access_token: "external-access", refresh_token: "external-refresh" } }); + const digest = (value: string) => createHash("sha256").update(value).digest("hex"); + writeFileSync(authPath, replacement); + writeFileSync(process.platform === "win32" ? previousPath : stagedPath, external); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + transactionId, + targetPath: authPath, + stagedBasename, + previousBasename, + phase: "prepared", + expectedSha256: digest(original), + replacementSha256: digest(replacement), + })); + + recoverNativeMainRefreshPublication(resolveNativeProfileContext()); + + expect(readFileSync(authPath, "utf8")).toBe(external); + expect(existsSync(journalPath)).toBe(false); + expect(existsSync(stagedPath)).toBe(false); + expect(existsSync(previousPath)).toBe(false); + }); + + test("retains an unprovable prepared replacement without a displaced artifact", () => { + const authPath = join(home, "auth.json"); + const transactionId = "11111111-1111-4111-8111-111111111111"; + const stagedBasename = `.opencodex-native-main-refresh.${transactionId}.new`; + const previousBasename = `.opencodex-native-main-refresh.${transactionId}.previous`; + const journalPath = join(home, ".opencodex-native-main-refresh.json"); + const original = JSON.stringify({ tokens: { access_token: "old-access", refresh_token: "old-refresh" } }); + const replacement = JSON.stringify({ tokens: { access_token: "new-access", refresh_token: "new-refresh" } }); + const digest = (value: string) => createHash("sha256").update(value).digest("hex"); + writeFileSync(authPath, replacement); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + transactionId, + targetPath: authPath, + stagedBasename, + previousBasename, + phase: "prepared", + expectedSha256: digest(original), + replacementSha256: digest(replacement), + })); + + expect(() => recoverNativeMainRefreshPublication(resolveNativeProfileContext())).toThrow( + "Native credential refresh could not be published", + ); + + expect(readFileSync(authPath, "utf8")).toBe(replacement); + expect(existsSync(journalPath)).toBe(true); + }); + test("cleans committed recovery journals with zero, one, and multiple exact remnants", () => { const original = JSON.stringify({ tokens: { refresh_token: "old-refresh", account_id: "account-main" } }); const replacement = JSON.stringify({ tokens: { access_token: "access-b", refresh_token: "refresh-b", account_id: "account-main" } }); From 58b6c7b676ec475c35be0c85f2d03b7b3b113610 Mon Sep 17 00:00:00 2001 From: MarcTCruz <58499846+MarcTCruz@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:33:49 +0000 Subject: [PATCH 6/8] fix(codex): guard refresh recovery exchange races --- src/codex/native-main-refresh-publication.ts | 48 +++++++++++++------- tests/codex-main-account-refresh.test.ts | 40 +++++++++++++++- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/src/codex/native-main-refresh-publication.ts b/src/codex/native-main-refresh-publication.ts index 573b0566c7..e1ade9b65a 100644 --- a/src/codex/native-main-refresh-publication.ts +++ b/src/codex/native-main-refresh-publication.ts @@ -7,6 +7,7 @@ import type { NativeProfileContext } from "./native-profile-store"; const JOURNAL = ".opencodex-native-main-refresh.json"; const TRANSACTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +let beforeRecoveryReplaceForTests: (() => void) | null = null; type Journal = { version: 1; @@ -90,6 +91,22 @@ function cleanup(context: NativeProfileContext, journal: Journal): void { } } +function settlePreparedReplacement( + context: NativeProfileContext, + journal: Journal, + displacedPath: string, + rollbackPath: string, +): void { + const displaced = readExact(displacedPath); + if (!displaced) throw new NativeMainRefreshPublicationError(); + if (digest(displaced) !== journal.expectedSha256) { + restoreFilePreservingTarget(displacedPath, journal.targetPath, rollbackPath); + const restored = readExact(journal.targetPath); + if (!restored || !restored.equals(displaced)) throw new NativeMainRefreshPublicationError(); + } + cleanup(context, journal); +} + /** Recover only a transaction whose exact hashes prove one deterministic outcome. */ export function recoverNativeMainRefreshPublication(context: NativeProfileContext): void { const path = journalPath(context); @@ -103,12 +120,14 @@ export function recoverNativeMainRefreshPublication(context: NativeProfileContex const stagedBytes = readExact(staged); const displacedPath = process.platform === "win32" ? previous : staged; const rollbackPath = process.platform === "win32" ? staged : previous; - const displacedBytes = readExact(displacedPath); if (!canonical) throw new NativeMainRefreshPublicationError(); if (digest(canonical) === journal.expectedSha256 && stagedBytes && digest(stagedBytes) === journal.replacementSha256) { try { + const hook = beforeRecoveryReplaceForTests; + beforeRecoveryReplaceForTests = null; + hook?.(); replaceFilePreservingTarget(staged, journal.targetPath, previous); - cleanup(context, { ...journal, phase: "replaced" }); + settlePreparedReplacement(context, journal, displacedPath, rollbackPath); } catch (cause) { throw new NativeMainRefreshPublicationError({ cause }); } @@ -116,18 +135,9 @@ export function recoverNativeMainRefreshPublication(context: NativeProfileContex } if (digest(canonical) === journal.replacementSha256) { if (journal.phase === "prepared") { - if (!displacedBytes) throw new NativeMainRefreshPublicationError(); - if (digest(displacedBytes) !== journal.expectedSha256) { - try { - restoreFilePreservingTarget(displacedPath, journal.targetPath, rollbackPath); - const restored = readExact(journal.targetPath); - if (!restored || !restored.equals(displacedBytes)) throw new NativeMainRefreshPublicationError(); - cleanup(context, journal); - } catch (cause) { - throw new NativeMainRefreshPublicationError({ cause }); - } - return; - } + try { settlePreparedReplacement(context, journal, displacedPath, rollbackPath); } + catch (cause) { throw new NativeMainRefreshPublicationError({ cause }); } + return; } try { cleanup(context, journal); } catch (cause) { throw new NativeMainRefreshPublicationError({ cause }); } return; @@ -169,12 +179,12 @@ export function publishNativeMainRefresh( if (!displaced || digest(displaced) !== journal.expectedSha256) { const canonical = readExact(targetPath); if (canonical && digest(canonical) === journal.replacementSha256) { - restoreFilePreservingTarget( + settlePreparedReplacement( + context, + journal, process.platform === "win32" ? previous : staged, - targetPath, process.platform === "win32" ? staged : previous, ); - cleanup(context, journal); } throw new NativeMainRefreshPublicationError(); } @@ -194,3 +204,7 @@ export function publishNativeMainRefresh( export function nativeMainRefreshJournalBasename(): string { return basename(JOURNAL); } + +export function setNativeMainBeforeRecoveryReplaceHookForTests(hook: (() => void) | null): void { + beforeRecoveryReplaceForTests = hook; +} diff --git a/tests/codex-main-account-refresh.test.ts b/tests/codex-main-account-refresh.test.ts index 6d4fdf0d4e..a1162d0133 100644 --- a/tests/codex-main-account-refresh.test.ts +++ b/tests/codex-main-account-refresh.test.ts @@ -22,7 +22,10 @@ import { MainAccountTokenRefreshError, setMainAuthJsonBeforeRenameHookForTests, } from "../src/codex/main-account"; -import { recoverNativeMainRefreshPublication } from "../src/codex/native-main-refresh-publication"; +import { + recoverNativeMainRefreshPublication, + setNativeMainBeforeRecoveryReplaceHookForTests, +} from "../src/codex/native-main-refresh-publication"; import { resolveNativeProfileContext } from "../src/codex/native-profile-store"; let home: string; @@ -54,6 +57,7 @@ beforeEach(() => { afterEach(() => { setMainAuthJsonBeforeRenameHookForTests(null); + setNativeMainBeforeRecoveryReplaceHookForTests(null); if (previousCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; rmSync(home, { recursive: true, force: true }); @@ -545,6 +549,40 @@ describe("native main token refresh", () => { expect(existsSync(previousPath)).toBe(false); }); + test("preserves an external write that races a prepared recovery exchange", () => { + const authPath = join(home, "auth.json"); + const transactionId = "11111111-1111-4111-8111-111111111111"; + const stagedBasename = `.opencodex-native-main-refresh.${transactionId}.new`; + const previousBasename = `.opencodex-native-main-refresh.${transactionId}.previous`; + const stagedPath = join(home, stagedBasename); + const previousPath = join(home, previousBasename); + const journalPath = join(home, ".opencodex-native-main-refresh.json"); + const original = JSON.stringify({ tokens: { access_token: "old-access", refresh_token: "old-refresh" } }); + const replacement = JSON.stringify({ tokens: { access_token: "new-access", refresh_token: "new-refresh" } }); + const external = JSON.stringify({ tokens: { access_token: "external-access", refresh_token: "external-refresh" } }); + const digest = (value: string) => createHash("sha256").update(value).digest("hex"); + writeFileSync(authPath, original); + writeFileSync(stagedPath, replacement); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + transactionId, + targetPath: authPath, + stagedBasename, + previousBasename, + phase: "prepared", + expectedSha256: digest(original), + replacementSha256: digest(replacement), + })); + setNativeMainBeforeRecoveryReplaceHookForTests(() => writeFileSync(authPath, external)); + + recoverNativeMainRefreshPublication(resolveNativeProfileContext()); + + expect(readFileSync(authPath, "utf8")).toBe(external); + expect(existsSync(journalPath)).toBe(false); + expect(existsSync(stagedPath)).toBe(false); + expect(existsSync(previousPath)).toBe(false); + }); + test("retains an unprovable prepared replacement without a displaced artifact", () => { const authPath = join(home, "auth.json"); const transactionId = "11111111-1111-4111-8111-111111111111"; From c3570d4677927aa83715748c7bdf74d8a2794830 Mon Sep 17 00:00:00 2001 From: MarcTCruz <58499846+MarcTCruz@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:45:54 +0000 Subject: [PATCH 7/8] fix(codex): retain concurrent recovery writers --- src/codex/native-main-refresh-publication.ts | 12 +++++++ tests/codex-main-account-refresh.test.ts | 38 ++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/codex/native-main-refresh-publication.ts b/src/codex/native-main-refresh-publication.ts index e1ade9b65a..949e3ec7f5 100644 --- a/src/codex/native-main-refresh-publication.ts +++ b/src/codex/native-main-refresh-publication.ts @@ -8,6 +8,7 @@ import type { NativeProfileContext } from "./native-profile-store"; const JOURNAL = ".opencodex-native-main-refresh.json"; const TRANSACTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; let beforeRecoveryReplaceForTests: (() => void) | null = null; +let beforeDisplacedRestoreForTests: (() => void) | null = null; type Journal = { version: 1; @@ -100,9 +101,16 @@ function settlePreparedReplacement( const displaced = readExact(displacedPath); if (!displaced) throw new NativeMainRefreshPublicationError(); if (digest(displaced) !== journal.expectedSha256) { + const hook = beforeDisplacedRestoreForTests; + beforeDisplacedRestoreForTests = null; + hook?.(); restoreFilePreservingTarget(displacedPath, journal.targetPath, rollbackPath); const restored = readExact(journal.targetPath); if (!restored || !restored.equals(displaced)) throw new NativeMainRefreshPublicationError(); + const replaced = readExact(process.platform === "win32" ? rollbackPath : displacedPath); + if (!replaced || digest(replaced) !== journal.replacementSha256) { + throw new NativeMainRefreshPublicationError(); + } } cleanup(context, journal); } @@ -208,3 +216,7 @@ export function nativeMainRefreshJournalBasename(): string { export function setNativeMainBeforeRecoveryReplaceHookForTests(hook: (() => void) | null): void { beforeRecoveryReplaceForTests = hook; } + +export function setNativeMainBeforeDisplacedRestoreHookForTests(hook: (() => void) | null): void { + beforeDisplacedRestoreForTests = hook; +} diff --git a/tests/codex-main-account-refresh.test.ts b/tests/codex-main-account-refresh.test.ts index a1162d0133..b2b2f0c8f5 100644 --- a/tests/codex-main-account-refresh.test.ts +++ b/tests/codex-main-account-refresh.test.ts @@ -24,6 +24,7 @@ import { } from "../src/codex/main-account"; import { recoverNativeMainRefreshPublication, + setNativeMainBeforeDisplacedRestoreHookForTests, setNativeMainBeforeRecoveryReplaceHookForTests, } from "../src/codex/native-main-refresh-publication"; import { resolveNativeProfileContext } from "../src/codex/native-profile-store"; @@ -57,6 +58,7 @@ beforeEach(() => { afterEach(() => { setMainAuthJsonBeforeRenameHookForTests(null); + setNativeMainBeforeDisplacedRestoreHookForTests(null); setNativeMainBeforeRecoveryReplaceHookForTests(null); if (previousCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; @@ -583,6 +585,42 @@ describe("native main token refresh", () => { expect(existsSync(previousPath)).toBe(false); }); + test("retains a second external writer that races displaced credential restoration", () => { + const authPath = join(home, "auth.json"); + const transactionId = "11111111-1111-4111-8111-111111111111"; + const stagedBasename = `.opencodex-native-main-refresh.${transactionId}.new`; + const previousBasename = `.opencodex-native-main-refresh.${transactionId}.previous`; + const stagedPath = join(home, stagedBasename); + const previousPath = join(home, previousBasename); + const journalPath = join(home, ".opencodex-native-main-refresh.json"); + const original = JSON.stringify({ tokens: { access_token: "old-access", refresh_token: "old-refresh" } }); + const replacement = JSON.stringify({ tokens: { access_token: "new-access", refresh_token: "new-refresh" } }); + const firstExternal = JSON.stringify({ tokens: { access_token: "external-a", refresh_token: "external-a-refresh" } }); + const secondExternal = JSON.stringify({ tokens: { access_token: "external-b", refresh_token: "external-b-refresh" } }); + const digest = (value: string) => createHash("sha256").update(value).digest("hex"); + writeFileSync(authPath, replacement); + writeFileSync(process.platform === "win32" ? previousPath : stagedPath, firstExternal); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + transactionId, + targetPath: authPath, + stagedBasename, + previousBasename, + phase: "prepared", + expectedSha256: digest(original), + replacementSha256: digest(replacement), + })); + setNativeMainBeforeDisplacedRestoreHookForTests(() => writeFileSync(authPath, secondExternal)); + + expect(() => recoverNativeMainRefreshPublication(resolveNativeProfileContext())).toThrow( + "Native credential refresh could not be published", + ); + + expect(readFileSync(authPath, "utf8")).toBe(firstExternal); + expect(readFileSync(stagedPath, "utf8")).toBe(secondExternal); + expect(existsSync(journalPath)).toBe(true); + }); + test("retains an unprovable prepared replacement without a displaced artifact", () => { const authPath = join(home, "auth.json"); const transactionId = "11111111-1111-4111-8111-111111111111"; From 1582dec7f2fc846454084ef0fffea37c31131663 Mon Sep 17 00:00:00 2001 From: MarcTCruz <58499846+MarcTCruz@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:24:53 +0000 Subject: [PATCH 8/8] fix(codex): revalidate recovery symlink target --- src/codex/native-main-refresh-publication.ts | 1 + tests/codex-main-account-refresh.test.ts | 43 ++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/codex/native-main-refresh-publication.ts b/src/codex/native-main-refresh-publication.ts index 949e3ec7f5..d1fb583b9e 100644 --- a/src/codex/native-main-refresh-publication.ts +++ b/src/codex/native-main-refresh-publication.ts @@ -134,6 +134,7 @@ export function recoverNativeMainRefreshPublication(context: NativeProfileContex const hook = beforeRecoveryReplaceForTests; beforeRecoveryReplaceForTests = null; hook?.(); + assertAuthTarget(context, journal.targetPath); replaceFilePreservingTarget(staged, journal.targetPath, previous); settlePreparedReplacement(context, journal, displacedPath, rollbackPath); } catch (cause) { diff --git a/tests/codex-main-account-refresh.test.ts b/tests/codex-main-account-refresh.test.ts index b2b2f0c8f5..5d5ad158ef 100644 --- a/tests/codex-main-account-refresh.test.ts +++ b/tests/codex-main-account-refresh.test.ts @@ -518,6 +518,49 @@ describe("native main token refresh", () => { expect(existsSync(join(home, stagedBasename))).toBe(true); }); + test.skipIf(!canSymlink)("refuses recovery when the auth symlink is retargeted before replacement", () => { + const authPath = join(home, "auth.json"); + const firstTarget = join(home, "first-auth.json"); + const secondTarget = join(home, "second-auth.json"); + const transactionId = "11111111-1111-4111-8111-111111111111"; + const stagedBasename = `.opencodex-native-main-refresh.${transactionId}.new`; + const previousBasename = `.opencodex-native-main-refresh.${transactionId}.previous`; + const stagedPath = join(home, stagedBasename); + const journalPath = join(home, ".opencodex-native-main-refresh.json"); + const original = JSON.stringify({ tokens: { refresh_token: "old-refresh", account_id: "account-main" } }); + const replacement = JSON.stringify({ tokens: { access_token: "new-access", refresh_token: "new-refresh" } }); + const external = JSON.stringify({ tokens: { access_token: "external-access" } }); + const digest = (value: string) => createHash("sha256").update(value).digest("hex"); + writeFileSync(firstTarget, original); + writeFileSync(secondTarget, external); + symlinkSync(firstTarget, authPath); + writeFileSync(stagedPath, replacement); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + transactionId, + targetPath: firstTarget, + stagedBasename, + previousBasename, + phase: "prepared", + expectedSha256: digest(original), + replacementSha256: digest(replacement), + })); + setNativeMainBeforeRecoveryReplaceHookForTests(() => { + unlinkSync(authPath); + symlinkSync(secondTarget, authPath); + }); + + expect(() => recoverNativeMainRefreshPublication(resolveNativeProfileContext())).toThrow( + "Native credential refresh could not be published", + ); + + expect(readlinkSync(authPath)).toBe(secondTarget); + expect(readFileSync(firstTarget, "utf8")).toBe(original); + expect(readFileSync(secondTarget, "utf8")).toBe(external); + expect(existsSync(journalPath)).toBe(true); + expect(readFileSync(stagedPath, "utf8")).toBe(replacement); + }); + test("restores an external writer displaced by an interrupted prepared exchange", () => { const authPath = join(home, "auth.json"); const transactionId = "11111111-1111-4111-8111-111111111111";