-
Notifications
You must be signed in to change notification settings - Fork 0
Follow-up: address PR #46 review findings #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2092,7 +2092,7 @@ function cloneFlaggedStorageForPersist(storage: FlaggedAccountStorageV1): Flagge | |||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export async function loadFlaggedAccounts(): Promise<FlaggedAccountStorageV1> { | ||||||||||||||||||||||||||||||
| async function loadFlaggedAccountsUnlocked(): Promise<FlaggedAccountStorageV1> { | ||||||||||||||||||||||||||||||
| const path = getFlaggedAccountsPath(); | ||||||||||||||||||||||||||||||
| const empty: FlaggedAccountStorageV1 = { version: 1, accounts: [] }; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
@@ -2121,7 +2121,7 @@ export async function loadFlaggedAccounts(): Promise<FlaggedAccountStorageV1> { | |||||||||||||||||||||||||||||
| const legacyData = JSON.parse(legacyContent) as unknown; | ||||||||||||||||||||||||||||||
| const migrated = normalizeFlaggedStorage(legacyData); | ||||||||||||||||||||||||||||||
| if (migrated.accounts.length > 0) { | ||||||||||||||||||||||||||||||
| await saveFlaggedAccounts(migrated); | ||||||||||||||||||||||||||||||
| await saveFlaggedAccountsUnlocked(migrated); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| await fs.unlink(legacyPath); | ||||||||||||||||||||||||||||||
|
|
@@ -2148,28 +2148,34 @@ export async function loadFlaggedAccounts(): Promise<FlaggedAccountStorageV1> { | |||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Promise<void> { | ||||||||||||||||||||||||||||||
| return withStorageLock(async () => { | ||||||||||||||||||||||||||||||
| const path = getFlaggedAccountsPath(); | ||||||||||||||||||||||||||||||
| const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; | ||||||||||||||||||||||||||||||
| const tempPath = `${path}.${uniqueSuffix}.tmp`; | ||||||||||||||||||||||||||||||
| async function saveFlaggedAccountsUnlocked(storage: FlaggedAccountStorageV1): Promise<void> { | ||||||||||||||||||||||||||||||
| const path = getFlaggedAccountsPath(); | ||||||||||||||||||||||||||||||
| const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; | ||||||||||||||||||||||||||||||
| const tempPath = `${path}.${uniqueSuffix}.tmp`; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| await fs.mkdir(dirname(path), { recursive: true }); | ||||||||||||||||||||||||||||||
| const normalized = normalizeFlaggedStorage(storage); | ||||||||||||||||||||||||||||||
| const content = JSON.stringify(cloneFlaggedStorageForPersist(normalized), null, 2); | ||||||||||||||||||||||||||||||
| await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); | ||||||||||||||||||||||||||||||
| await fs.rename(tempPath, path); | ||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||
|
Comment on lines
+2156
to
+2162
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. use retrying rename for flagged saves on windows locks.
proposed patch- await fs.rename(tempPath, path);
+ await renameFileWithRetry(tempPath, path);As per coding guidelines 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| await fs.mkdir(dirname(path), { recursive: true }); | ||||||||||||||||||||||||||||||
| const normalized = normalizeFlaggedStorage(storage); | ||||||||||||||||||||||||||||||
| const content = JSON.stringify(cloneFlaggedStorageForPersist(normalized), null, 2); | ||||||||||||||||||||||||||||||
| await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); | ||||||||||||||||||||||||||||||
| await fs.rename(tempPath, path); | ||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| await fs.unlink(tempPath); | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| // Ignore cleanup failures. | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| log.error("Failed to save flagged account storage", { path, error: String(error) }); | ||||||||||||||||||||||||||||||
| throw error; | ||||||||||||||||||||||||||||||
| await fs.unlink(tempPath); | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| // Ignore cleanup failures. | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| log.error("Failed to save flagged account storage", { path, error: String(error) }); | ||||||||||||||||||||||||||||||
| throw error; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export async function loadFlaggedAccounts(): Promise<FlaggedAccountStorageV1> { | ||||||||||||||||||||||||||||||
| return loadFlaggedAccountsUnlocked(); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+2173
to
+2175
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The const accounts = await loadFlaggedAccounts(); // no lock held
// concurrent saveFlaggedAccounts can fire here
await saveFlaggedAccounts(mergeOrModify(accounts)); // writes stale snapshot...the concurrent write is silently lost. on windows this is especially risky because antivirus i/o can extend the TOCTOU window substantially. consider adding explicit jsdoc to Prompt To Fix With AIThis is a comment left during a code review.
Path: lib/storage.ts
Line: 2173-2175
Comment:
`loadFlaggedAccounts` exports the unlocked variant without guarding — any future caller doing a read-modify-write cycle risks a TOCTOU race.
The `rotateStoredSecretEncryption` fix is solid (both read and write under `withStorageLock`), but `loadFlaggedAccounts()` is now just a passthrough to the unguarded `loadFlaggedAccountsUnlocked()`. if external code does:
```ts
const accounts = await loadFlaggedAccounts(); // no lock held
// concurrent saveFlaggedAccounts can fire here
await saveFlaggedAccounts(mergeOrModify(accounts)); // writes stale snapshot
```
...the concurrent write is silently lost. on windows this is especially risky because antivirus i/o can extend the TOCTOU window substantially.
consider adding explicit jsdoc to `loadFlaggedAccounts` documenting that callers needing a read-modify-write cycle must hold `withStorageLock` externally, or expose a locked variant as the public API to prevent future misuse.
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Promise<void> { | ||||||||||||||||||||||||||||||
| return withStorageLock(() => saveFlaggedAccountsUnlocked(storage)); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export async function clearFlaggedAccounts(): Promise<void> { | ||||||||||||||||||||||||||||||
|
|
@@ -2292,11 +2298,14 @@ export async function rotateStoredSecretEncryption(): Promise<{ | |||||||||||||||||||||||||||||
| return current.accounts.length; | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const flagged = await loadFlaggedAccounts(); | ||||||||||||||||||||||||||||||
| const flaggedCount = flagged.accounts.length; | ||||||||||||||||||||||||||||||
| if (flaggedCount > 0) { | ||||||||||||||||||||||||||||||
| await saveFlaggedAccounts(flagged); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| const flaggedCount = await withStorageLock(async () => { | ||||||||||||||||||||||||||||||
| const flagged = await loadFlaggedAccountsUnlocked(); | ||||||||||||||||||||||||||||||
| const count = flagged.accounts.length; | ||||||||||||||||||||||||||||||
| if (count > 0) { | ||||||||||||||||||||||||||||||
| await saveFlaggedAccountsUnlocked(flagged); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| return count; | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||
| accounts: accountCount, | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,4 @@ | ||
| import { | ||
| existsSync, | ||
| promises as fs, | ||
| readdirSync, | ||
| renameSync, | ||
| statSync, | ||
| unlinkSync, | ||
| } from "node:fs"; | ||
| import { existsSync, promises as fs, readdirSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
| import { getCorrelationId, maskEmail } from "./logger.js"; | ||
| import { getCodexLogDir } from "./runtime-paths.js"; | ||
|
|
@@ -144,21 +137,42 @@ function parseArchiveSuffix(fileName: string): number | null { | |
| return Number.isFinite(parsed) ? parsed : null; | ||
| } | ||
|
|
||
| function rotateLogsIfNeeded(): void { | ||
| function isMissingFsError(error: unknown): boolean { | ||
| return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"; | ||
| } | ||
|
|
||
| async function rotateLogsIfNeeded(): Promise<void> { | ||
| const logPath = getTelemetryPath(); | ||
| if (!existsSync(logPath)) return; | ||
| let size: number; | ||
| try { | ||
| size = (await fs.stat(logPath)).size; | ||
| } catch (error) { | ||
| if (isMissingFsError(error)) { | ||
| return; | ||
| } | ||
| throw error; | ||
| } | ||
|
|
||
| const size = statSync(logPath).size; | ||
| if (size < telemetryConfig.maxFileSizeBytes) return; | ||
|
|
||
| for (let i = telemetryConfig.maxFiles - 1; i >= 1; i -= 1) { | ||
| const target = `${logPath}.${i}`; | ||
| const source = i === 1 ? logPath : `${logPath}.${i - 1}`; | ||
| if (i === telemetryConfig.maxFiles - 1 && existsSync(target)) { | ||
| unlinkSync(target); | ||
| if (i === telemetryConfig.maxFiles - 1) { | ||
| try { | ||
| await fs.unlink(target); | ||
| } catch (error) { | ||
| if (!isMissingFsError(error)) { | ||
| throw error; | ||
| } | ||
| } | ||
| } | ||
| if (existsSync(source)) { | ||
| renameSync(source, target); | ||
| try { | ||
| await fs.rename(source, target); | ||
| } catch (error) { | ||
| if (!isMissingFsError(error)) { | ||
| throw error; | ||
| } | ||
| } | ||
|
Comment on lines
+161
to
176
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add retry/backoff for windows busy-file rotation failures.
proposed patch+function isRetryableFsBusyError(error: unknown): boolean {
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
+ return code === "EBUSY" || code === "EPERM" || code === "EAGAIN";
+}
+
+async function withFsRetry(task: () => Promise<void>): Promise<void> {
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ try {
+ await task();
+ return;
+ } catch (error) {
+ if (!isRetryableFsBusyError(error) || attempt === 4) {
+ throw error;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 20 * 2 ** attempt));
+ }
+ }
+}
+
async function rotateLogsIfNeeded(): Promise<void> {
@@
- try {
- await fs.unlink(target);
+ try {
+ await withFsRetry(() => fs.unlink(target));
} catch (error) {
@@
- try {
- await fs.rename(source, target);
+ try {
+ await withFsRetry(() => fs.rename(source, target));
} catch (error) {As per coding guidelines 🤖 Prompt for AI Agents
Comment on lines
+170
to
176
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
since the oldest archive (line 161-168) is explicitly unlinked first, mitigating this for that slot, but intermediate renames (i < maxFiles - 1) are vulnerable. on busy windows systems with aggressive antivirus, this can cause systematic telemetry loss under log rotation. consider extending function isMissingFsError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === "ENOENT" || code === "EPERM" || code === "EACCES";
}also consider adding a regression test that mocks intermediate rename to reject with Prompt To Fix With AIThis is a comment left during a code review.
Path: lib/telemetry.ts
Line: 170-176
Comment:
`isMissingFsError` only catches `ENOENT` when renaming intermediate archive slots. on windows/ntfs, if a target file is held open by antivirus or another process, `fs.rename` throws `EPERM` instead, which propagates out uncaught.
since `rotateLogsIfNeeded()` is called from within `recordTelemetryEvent`'s outer `catch { }` (line 274-276), the error gets swallowed silently — the telemetry event for that call is lost permanently because the `appendFile` line never executes.
the oldest archive (line 161-168) is explicitly unlinked first, mitigating this for that slot, but intermediate renames (i < maxFiles - 1) are vulnerable. on busy windows systems with aggressive antivirus, this can cause systematic telemetry loss under log rotation.
consider extending `isMissingFsError` to include `EPERM` and `EACCES`:
```ts
function isMissingFsError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === "ENOENT" || code === "EPERM" || code === "EACCES";
}
```
also consider adding a regression test that mocks intermediate rename to reject with `EPERM` to verify graceful handling.
How can I resolve this? If you propose a fix, please make it concise. |
||
| } | ||
| } | ||
|
|
@@ -253,7 +267,7 @@ export async function recordTelemetryEvent(input: TelemetryEventInput): Promise< | |
| try { | ||
| await queueAppend(async () => { | ||
| await ensureLogDir(); | ||
| rotateLogsIfNeeded(); | ||
| await rotateLogsIfNeeded(); | ||
| const line = `${JSON.stringify(entry)}\n`; | ||
| await fs.appendFile(getTelemetryPath(), line, "utf8"); | ||
| }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -564,6 +564,58 @@ describe("accounts edge branches", () => { | |||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| it("prefers fresher local credentials over stale disk tokens during conflict merge", async () => { | ||||||||||||||||||||||||||||||||
| const staleExpiresAt = Date.now() + 1_000; | ||||||||||||||||||||||||||||||||
| const freshExpiresAt = Date.now() + 120_000; | ||||||||||||||||||||||||||||||||
| const stored = buildStored([ | ||||||||||||||||||||||||||||||||
| buildStoredAccount({ | ||||||||||||||||||||||||||||||||
| refreshToken: "refresh-fresh", | ||||||||||||||||||||||||||||||||
| accessToken: "access-fresh", | ||||||||||||||||||||||||||||||||
| expiresAt: freshExpiresAt, | ||||||||||||||||||||||||||||||||
| email: "identity@example.com", | ||||||||||||||||||||||||||||||||
| accountId: "account-identity-1", | ||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const latestDisk = buildStored([ | ||||||||||||||||||||||||||||||||
| buildStoredAccount({ | ||||||||||||||||||||||||||||||||
| refreshToken: "refresh-stale", | ||||||||||||||||||||||||||||||||
| accessToken: "access-stale", | ||||||||||||||||||||||||||||||||
| expiresAt: staleExpiresAt, | ||||||||||||||||||||||||||||||||
| email: "identity@example.com", | ||||||||||||||||||||||||||||||||
| accountId: "account-identity-1", | ||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const conflictError = Object.assign(new Error("conflict"), { | ||||||||||||||||||||||||||||||||
| code: "ECONFLICT", | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
| mockSaveAccounts | ||||||||||||||||||||||||||||||||
| .mockRejectedValueOnce(conflictError) | ||||||||||||||||||||||||||||||||
| .mockResolvedValueOnce(undefined); | ||||||||||||||||||||||||||||||||
| mockLoadAccounts.mockResolvedValueOnce(latestDisk); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const { AccountManager } = await importAccountsModule(); | ||||||||||||||||||||||||||||||||
| const manager = new AccountManager(undefined, stored as never); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| await manager.saveToDisk(); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const retriedPayload = mockSaveAccounts.mock.calls[1]?.[0] as { | ||||||||||||||||||||||||||||||||
| accounts: Array<{ | ||||||||||||||||||||||||||||||||
| accountId?: string; | ||||||||||||||||||||||||||||||||
| refreshToken: string; | ||||||||||||||||||||||||||||||||
| accessToken?: string; | ||||||||||||||||||||||||||||||||
| expiresAt?: number; | ||||||||||||||||||||||||||||||||
| }>; | ||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||
| const mergedAccount = retriedPayload.accounts.find( | ||||||||||||||||||||||||||||||||
| (account) => account.accountId === "account-identity-1", | ||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||
| expect(mergedAccount?.refreshToken).toBe("refresh-fresh"); | ||||||||||||||||||||||||||||||||
| expect(mergedAccount?.accessToken).toBe("access-fresh"); | ||||||||||||||||||||||||||||||||
| expect(mergedAccount?.expiresAt).toBe(freshExpiresAt); | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
Comment on lines
+611
to
+617
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. assert account id stability in this regression. the new case verifies credential fields but not identity stability. add an explicit proposed patch const mergedAccount = retriedPayload.accounts.find(
(account) => account.accountId === "account-identity-1",
);
+ expect(mergedAccount?.accountId).toBe("account-identity-1");
expect(mergedAccount?.refreshToken).toBe("refresh-fresh");
expect(mergedAccount?.accessToken).toBe("access-fresh");
expect(mergedAccount?.expiresAt).toBe(freshExpiresAt);As per coding guidelines 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| it("prefers fresher timestamp and rate-limit reset values during conflict merge", async () => { | ||||||||||||||||||||||||||||||||
| const localNow = Date.now(); | ||||||||||||||||||||||||||||||||
| const localAccount = buildStoredAccount({ | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import { | |
| getFlaggedAccountsPath, | ||
| getStoragePath, | ||
| loadFlaggedAccounts, | ||
| rotateStoredSecretEncryption, | ||
| saveFlaggedAccounts, | ||
| setStoragePathDirect, | ||
| } from "../lib/storage.js"; | ||
|
|
@@ -239,4 +240,82 @@ describe("flagged account storage", () => { | |
|
|
||
| renameSpy.mockRestore(); | ||
| }); | ||
|
|
||
| it("preserves concurrent flagged-account updates during secret rotation", async () => { | ||
| const previousEncryptionKey = process.env.CODEX_AUTH_ENCRYPTION_KEY; | ||
| const previousPreviousKey = process.env.CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY; | ||
| process.env.CODEX_AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef"; | ||
| delete process.env.CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY; | ||
|
|
||
| await saveFlaggedAccounts({ | ||
| version: 1, | ||
| accounts: [ | ||
| { | ||
| refreshToken: "flagged-alpha", | ||
| flaggedAt: 1, | ||
| addedAt: 1, | ||
| lastUsed: 1, | ||
| }, | ||
| ], | ||
| }); | ||
|
Comment on lines
+245
to
+260
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. move env mutation inside the try/finally restoration scope.
proposed patch- process.env.CODEX_AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
- delete process.env.CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY;
-
- await saveFlaggedAccounts({
- version: 1,
- accounts: [
- {
- refreshToken: "flagged-alpha",
- flaggedAt: 1,
- addedAt: 1,
- lastUsed: 1,
- },
- ],
- });
+ try {
+ process.env.CODEX_AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
+ delete process.env.CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY;
+
+ await saveFlaggedAccounts({
+ version: 1,
+ accounts: [
+ {
+ refreshToken: "flagged-alpha",
+ flaggedAt: 1,
+ addedAt: 1,
+ lastUsed: 1,
+ },
+ ],
+ });
- let concurrentSavePromise: Promise<void> | null = null;
+ let concurrentSavePromise: Promise<void> | null = null;
// ... existing test body ...
- try {
- const result = await rotateStoredSecretEncryption();
+ const result = await rotateStoredSecretEncryption();
// ...
- } finally {
+ } finally {
readFileSpy.mockRestore();
// env restore stays here
}As per coding guidelines 🤖 Prompt for AI Agents |
||
|
|
||
| let concurrentSavePromise: Promise<void> | null = null; | ||
| const flaggedPath = getFlaggedAccountsPath(); | ||
| const originalReadFile = fs.readFile.bind(fs); | ||
| const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (path, options) => { | ||
| if ( | ||
| concurrentSavePromise === null && | ||
| typeof path === "string" && | ||
| path === flaggedPath && | ||
| options === "utf-8" | ||
| ) { | ||
| queueMicrotask(() => { | ||
| concurrentSavePromise = saveFlaggedAccounts({ | ||
| version: 1, | ||
| accounts: [ | ||
| { | ||
| refreshToken: "flagged-alpha", | ||
| flaggedAt: 1, | ||
| addedAt: 1, | ||
| lastUsed: 1, | ||
| }, | ||
| { | ||
| refreshToken: "flagged-beta", | ||
| flaggedAt: 2, | ||
| addedAt: 2, | ||
| lastUsed: 2, | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
| } | ||
| return originalReadFile(path, options); | ||
| }); | ||
|
|
||
| try { | ||
| const result = await rotateStoredSecretEncryption(); | ||
| if (concurrentSavePromise) { | ||
| await concurrentSavePromise; | ||
| } | ||
|
|
||
| const flagged = await loadFlaggedAccounts(); | ||
| expect(result.flaggedAccounts).toBe(1); | ||
| expect(flagged.accounts.map((account) => account.refreshToken).sort()).toEqual([ | ||
| "flagged-alpha", | ||
| "flagged-beta", | ||
| ]); | ||
| } finally { | ||
| readFileSpy.mockRestore(); | ||
| if (previousEncryptionKey === undefined) { | ||
| delete process.env.CODEX_AUTH_ENCRYPTION_KEY; | ||
| } else { | ||
| process.env.CODEX_AUTH_ENCRYPTION_KEY = previousEncryptionKey; | ||
| } | ||
| if (previousPreviousKey === undefined) { | ||
| delete process.env.CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY; | ||
| } else { | ||
| process.env.CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY = previousPreviousKey; | ||
| } | ||
| } | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
when both credentials lack
expiresAt, the code returns"incoming"(disk token), which contradicts the PR goal to "prefer the freshest credential set."the PR description says fresh tokens are preferred during conflict recovery, but when both tokens lack expiry timestamps, preferring
incoming(the on-disk snapshot) can silently regress non-expiring api-key style credentials to stale disk tokens regardless of recency.currentis the in-memory merged value (fresher), whileincomingis the on-disk snapshot (potentially stale). when timestamps are absent on both, preferringcurrentis safer and aligns with the stated goal.suggest:
also missing: no vitest coverage exists for this tie-breaking path, which changed behavior and carries token safety risk.
Prompt To Fix With AI