Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/codex/account-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { closeSync, existsSync, readFileSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs";
import { closeSync, existsSync, fstatSync, readFileSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
ConfigMutationLockError,
Expand Down Expand Up @@ -327,7 +327,13 @@ function isRefreshLockStale(path: string): boolean {
const parsed = JSON.parse(readFileSync(path, "utf-8")) as { acquiredAt?: unknown };
return typeof parsed.acquiredAt !== "number" || Date.now() - parsed.acquiredAt > REFRESH_LOCK_STALE_MS;
} catch {
return true;
// A newly-created lock is briefly empty while its owner writes metadata.
// Do not let a waiter steal it during that acquisition window.
try {
return Date.now() - statSync(path).mtimeMs > REFRESH_LOCK_STALE_MS;
} catch {
return false;
}
Comment on lines +330 to +336

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Make refresh-lock removal atomic with lock ownership.

Both paths validate a lock and later remove path by pathname. Another process can replace the path in that interval. The subsequent unlinkSync(path) then deletes an active replacement lock.

  • src/codex/account-store.ts#L330-L336: Change stale-lock reclamation so the inspected stale lock is reclaimed atomically. Do not let the caller unlink a path from a boolean stale result.
  • src/codex/account-store.ts#L372-L382: Replace the statSync(path) then unlinkSync(path) release sequence with an owner-bound release mechanism. A second identity check cannot close this race.

Add a regression test that replaces the lock after identity validation and before removal.

📍 Affects 1 file
  • src/codex/account-store.ts#L330-L336 (this comment)
  • src/codex/account-store.ts#L372-L382
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/codex/account-store.ts` around lines 330 - 336, Make refresh-lock removal
atomic with ownership: in src/codex/account-store.ts lines 330-336, replace the
boolean stale-check flow with an atomic mechanism that reclaims the inspected
lock without returning a result that callers later unlink by pathname; in lines
372-382, replace the statSync(path)/unlinkSync(path) release sequence with the
same owner-bound removal mechanism, since a second identity check does not close
the race. Add a regression test that replaces the lock after identity validation
and before removal, verifying the replacement lock is preserved.

}
}

Expand Down Expand Up @@ -363,9 +369,17 @@ async function withCodexRefreshFileLock<T>(lockKey: string, signal: AbortSignal,
try {
return await fn();
} finally {
const ownedIdentity = fd == null ? null : fstatSync(fd);
if (fd != null) closeSync(fd);
try {
unlinkSync(path);
const currentIdentity = statSync(path);
if (
ownedIdentity
&& currentIdentity.dev === ownedIdentity.dev
&& currentIdentity.ino === ownedIdentity.ino
) {
unlinkSync(path);
}
} catch (err) {
if (errCode(err) !== "ENOENT") throw err;
}
Expand Down
50 changes: 50 additions & 0 deletions tests/codex-account-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,56 @@ describe("codex-account-store CRUD", () => {
}
});

test("refresh does not steal a newly-created empty file lock", async () => {
const {
getValidCodexToken,
readCodexAccountRecord,
saveCodexAccountCredential,
saveCodexAccountCredentialIfGeneration,
} = await import("../src/codex/account-store");
saveCodexAccountCredential("refresh-empty-lock", { accessToken: "old", refreshToken: "old-r", expiresAt: 0, chatgptAccountId: "acc" });
const generation = readCodexAccountRecord("refresh-empty-lock")!.generation;
const lockPath = refreshLockPathForToken("old-r");
writeFileSync(lockPath, "");
const refreshed = { accessToken: "other-process", refreshToken: "other-r", expiresAt: Date.now() + 3600_000, chatgptAccountId: "acc" };
const release = setTimeout(() => {
saveCodexAccountCredentialIfGeneration("refresh-empty-lock", generation, refreshed);
unlinkSync(lockPath);
}, 20);
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
throw new Error("fetch should not be called while an empty lock is being initialized");
}) as typeof fetch;

try {
expect((await getValidCodexToken("refresh-empty-lock")).accessToken).toBe("other-process");
} finally {
clearTimeout(release);
globalThis.fetch = originalFetch;
}
});

test("refresh owner does not remove a replacement file lock", async () => {
const { getValidCodexToken, saveCodexAccountCredential } = await import("../src/codex/account-store");
saveCodexAccountCredential("refresh-replaced-lock", { accessToken: "old", refreshToken: "old-r", expiresAt: 0, chatgptAccountId: "acc" });
const lockPath = refreshLockPathForToken("old-r");
const replacement = JSON.stringify({ acquiredAt: Date.now(), pid: 54321 }) + "\n";
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
unlinkSync(lockPath);
writeFileSync(lockPath, replacement);
return new Response(JSON.stringify({ access_token: "new", expires_in: 3600 }), { status: 200 });
}) as typeof fetch;

try {
expect((await getValidCodexToken("refresh-replaced-lock")).accessToken).toBe("new");
expect(readFileSync(lockPath, "utf8")).toBe(replacement);
} finally {
if (existsSync(lockPath)) unlinkSync(lockPath);
globalThis.fetch = originalFetch;
}
});

test("stale refresh lock is reclaimed", async () => {
const { getValidCodexToken, saveCodexAccountCredential } = await import("../src/codex/account-store");
saveCodexAccountCredential("refresh-stale-lock", { accessToken: "old", refreshToken: "old-r", expiresAt: 0, chatgptAccountId: "acc" });
Expand Down
Loading