Skip to content

Follow-up: address PR #46 review findings#47

Open
ndycode wants to merge 1 commit intodevfrom
fix/pr-46-review-followups
Open

Follow-up: address PR #46 review findings#47
ndycode wants to merge 1 commit intodevfrom
fix/pr-46-review-followups

Conversation

@ndycode
Copy link
Owner

@ndycode ndycode commented Mar 6, 2026

Summary

  • make telemetry log rotation fully async so queued writes do not block on sync filesystem calls
  • serialize flagged-account secret rotation under a single storage lock to remove the read/write TOCTOU window
  • prefer the freshest credential set during account conflict recovery instead of always preferring disk tokens
  • add regression coverage for concurrent telemetry rotation, flagged-account rotation races, and stale-disk token merge behavior

Context

Follow-up to merged integration PR #46 and review #46 (review).

Validation

  • npm test
  • npm run typecheck
  • npm run build
  • npm run lint:ts
  • npm run clean:repo:check

Notes

  • npm run lint still shells out to a bare biome executable in this environment; the TypeScript lint gate passed locally.

note: greptile review for oc-chatgpt-multi-auth. cite files like lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.

Greptile Summary

this pr addresses three correctness issues flagged in the pr #46 review: async telemetry log rotation, flagged-account secret rotation TOCTOU, and stale-disk token preference during conflict merge.

  • lib/storage.ts: rotateStoredSecretEncryption correctly wraps both loadFlaggedAccountsUnlocked and saveFlaggedAccountsUnlocked under one withStorageLock call, closing the TOCTOU window for that operation. however, the public loadFlaggedAccounts export is now an unguarded alias without documentation about locking contracts — any future caller doing a read-modify-write cycle without holding withStorageLock externally will silently lose concurrent writes.

  • lib/accounts.ts: selectCredentialMergeSource adds expiry-based credential preference during conflict merge. the tie-breaking case when both credentials lack expiresAt now returns "incoming" — preferring the disk token — which contradicts the stated PR goal to prefer fresher credentials. no vitest coverage exists for this tie-breaking path, which changes behavior and carries token safety risk.

  • lib/telemetry.ts: the async rotation correctly uses await fs.stat/unlink/rename and handles ENOENT gracefully. however, intermediate archive renames (log.1 → log.2, etc.) only catch ENOENT; on windows, antivirus or file-lock holds can cause EPERM which propagates uncaught and is swallowed by the outer error handler, causing that event's appendFile to be skipped silently.

  • tests: regression tests for concurrent rotation, concurrent flagged-account saves, and fresh-credential merge are solid additions. missing coverage: the no-expiry tie-breaking case in credential merge, and windows EPERM mid-rotation for intermediate archive slots.

Confidence Score: 2/5

  • this pr addresses real TOCTOU and async issues but introduces and leaves unresolved two token safety gaps: credential tie-breaking behavior change without coverage, and windows filesystem error handling for telemetry rotation.
  • the flagged-account lock fix is correct, and async telemetry rotation is properly implemented. however, two concerns lower confidence: (1) credential merge tie-breaking changed from preferring in-memory to preferring disk for non-expiring tokens, contradicting the PR goal and introducing silent token leakage risk on accounts without expiresAt, with no test coverage for this path; (2) intermediate telemetry archive renames do not catch EPERM/EACCES, so windows antivirus locks cause silent event loss. both gaps are fixable but should be resolved before shipping to windows desktop users.
  • lib/accounts.ts (credential merge tie-breaking behavior change) and lib/telemetry.ts (windows EPERM in intermediate archive rename) require attention. consider either fixing the issues or adding explicit test coverage for both scenarios before merge.

Important Files Changed

Filename Overview
lib/storage.ts the flagged-account lock fix in rotateStoredSecretEncryption is correct and closes the TOCTOU window for secret rotation. however, the public loadFlaggedAccounts export now lacks documentation about external locking requirements — callers attempting a read-modify-write cycle without external lock acquisition will silently lose concurrent writes, especially risky on windows with antivirus i/o delays.
lib/accounts.ts credential merge tie-breaking when both lack expiresAt now prefers incoming (disk), contradicting the PR goal to prefer fresher tokens. this silently regresses non-expiring api-key style credentials to potentially stale disk tokens. no test coverage for this behavior change creates token safety risk; consider returning "current" (in-memory) instead to align with stated goals.
lib/telemetry.ts async rotation is correctly implemented with await on fs operations and ENOENT handling. intermediate archive renames do not handle EPERM/EACCES, so windows antivirus locks can cause uncaught errors that propagate into the outer catch handler, silently losing telemetry events. extend error handling or add regression test for windows lock scenario to prevent event loss on desktop deployments.
test/storage-flagged.test.ts concurrent secret rotation race test is solid and correctly verifies that a concurrent saveFlaggedAccounts queued during rotation does not lose its writes after the lock is released.
test/accounts-edge.test.ts covers conflict merge with expiry timestamps and timestamp preference; however, missing test for the "both credentials lack expiresAt" tie-breaking case that now returns "incoming", which is a behavior change not yet validated.
test/telemetry.test.ts concurrent rotation test with 12 parallel events verifies no events are lost under async rotation; however, does not cover the windows EPERM scenario for intermediate archive slots that could cause event loss under antivirus contention.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant rotateStoredSecretEncryption
    participant withStorageLock
    participant loadFlaggedAccountsUnlocked
    participant saveFlaggedAccountsUnlocked
    participant ConcurrentWriter

    Caller->>rotateStoredSecretEncryption: rotateStoredSecretEncryption()
    rotateStoredSecretEncryption->>withStorageLock: acquire lock
    withStorageLock-->>rotateStoredSecretEncryption: lock held

    rotateStoredSecretEncryption->>loadFlaggedAccountsUnlocked: read flagged accounts (no sub-lock)
    loadFlaggedAccountsUnlocked-->>rotateStoredSecretEncryption: { accounts: [alpha] }

    Note over ConcurrentWriter: concurrent save queued via saveFlaggedAccounts
    ConcurrentWriter->>withStorageLock: withStorageLock (BLOCKS — lock held)

    rotateStoredSecretEncryption->>saveFlaggedAccountsUnlocked: write re-encrypted [alpha]
    saveFlaggedAccountsUnlocked-->>rotateStoredSecretEncryption: done

    rotateStoredSecretEncryption->>withStorageLock: release lock
    withStorageLock-->>ConcurrentWriter: lock acquired

    ConcurrentWriter->>saveFlaggedAccountsUnlocked: write [alpha, beta]
    saveFlaggedAccountsUnlocked-->>ConcurrentWriter: done
    ConcurrentWriter->>withStorageLock: release lock

    Note over Caller: final state: [alpha, beta] — no writes lost
Loading

Fix All in Codex

Last reviewed commit: 6d8c362

Greptile also left 3 inline comments on this PR.

Context used:

  • Rule from dashboard - What: Every code change must explain how it defends against Windows filesystem concurrency bugs and ... (source)

Use async telemetry log rotation, serialize flagged-account secret rotation, and prefer the freshest credential set during conflict recovery.

Co-authored-by: Codex <noreply@openai.com>
@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

📝 Walkthrough

Walkthrough

changes to account credential merging add a new selectCredentialMergeSource helper that compares expiration times to decide whether to preserve current or prefer incoming credentials. storage.ts refactors flagged account load/save operations to separate unlocked variants and coordinate them under locking. telemetry.ts converts log rotation from sync to async file operations. tests verify conflict resolution during merges and concurrent updates during rotation.

Changes

Cohort / File(s) Summary
Credential Merge Logic
lib/accounts.ts
Renamed DISK_PREFERRED_MERGE_KEYS to CREDENTIAL_MERGE_KEYS; added selectCredentialMergeSource(current, incoming) helper that compares expiresAt fields to choose whether to preserve current or incoming credential values during account merge.
Storage Lock/Unlock Refactor
lib/storage.ts
Extracted loadFlaggedAccountsUnlocked() and saveFlaggedAccountsUnlocked() as internal helpers; public loadFlaggedAccounts() and saveFlaggedAccounts() now delegate through these, with saveFlaggedAccounts() wrapping calls under withStorageLock(). rotateStoredSecretEncryption() updated to use the unlocked variants within a lock scope.
Async Log Rotation
lib/telemetry.ts
Converted rotateLogsIfNeeded() from synchronous void to async Promise<void>; replaced existsSync/statSync/unlinkSync/renameSync with fs.stat/fs.unlink/fs.rename; added isMissingFsError() helper to silently skip ENOENT without throwing; recordTelemetryEvent now awaits rotation before appending logs.
Merge Conflict & Concurrency Tests
test/accounts-edge.test.ts, test/storage-flagged.test.ts, test/telemetry.test.ts
accounts-edge: test verifying fresher local credentials win during conflict merge. storage-flagged: test exercising concurrent flagged-account saves during rotation via mocked readFile. telemetry: test firing 12 concurrent events during log rotation and verifying all are retained.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

bug


review notes

concurrency & locking risks:

  • lib/storage.ts:withStorageLock() now wraps saveFlaggedAccountsUnlocked() in rotateStoredSecretEncryption(). verify that the lock scope doesn't deadlock with other storage operations, especially if rotation runs concurrently with saveToDisk conflict retries mentioned in test/accounts-edge.test.ts.
  • lib/telemetry.ts:recordTelemetryEvent() now awaits rotateLogsIfNeeded() within a queued task. ensure the queue itself doesn't block event recording if rotation stalls on i/o delays.

missing regression test coverage:

  • no test covers selectCredentialMergeSource() edge cases: what if expiresAt is null on both sides? what if one is undefined? the test in test/accounts-edge.test.ts:+52 only exercises the full merge path during conflict, not the helper in isolation.
  • lib/storage.ts:saveFlaggedAccountsUnlocked() now performs atomic write with directory creation and cleanup in one try/catch. test in test/storage-flagged.test.ts:+79 mocks a concurrent save during readFile, but doesn't test the actual temp file cleanup path on write failure or permission errors.
  • lib/telemetry.ts:isMissingFsError() is new; no direct test confirms it catches ENOENT but re-throws other fs errors (e.g., EACCES, EIO).

windows edge cases:

  • lib/telemetry.ts:fs.unlink() behavior on windows if a log file is locked by another process may differ from posix. test in test/telemetry.test.ts:+26 doesn't exercise this path.
  • path separators in lib/storage.ts and lib/telemetry.ts should be validated on windows; check for hardcoded forward slashes in path construction.

async/await hazards:

  • lib/telemetry.ts:recordTelemetryEvent() now awaits rotateLogsIfNeeded(). if rotation takes significant time, this could delay telemetry flush. no timeout or cancellation logic visible.
  • lib/storage.ts:rotateStoredSecretEncryption() calls loadFlaggedAccountsUnlocked() and saveFlaggedAccountsUnlocked() under a single lock. if load fails, does the lock release cleanly?
🚥 Pre-merge checks | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning title does not follow conventional commits format; missing type prefix (fix/feat/chore/docs/refactor/test) required by repository standards. reformat title as 'fix: address pr #46 review findings' or appropriate type prefix followed by lowercase imperative summary.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning PR description covers the four main changes (async telemetry, TOCTOU fix, credential preference, regression tests) with context and validation steps, but greptile identified three unresolved correctness issues that contradict stated goals. Address the three blocking issues before merge: (1) return 'current' not 'incoming' in credential tie-break (lib/accounts.ts:916-918), (2) document external locking requirement for loadFlaggedAccounts (lib/storage.ts:2173-2175), (3) extend isMissingFsError to catch EPERM/EACCES on windows (lib/telemetry.ts:170-176).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/pr-46-review-followups

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/storage.ts`:
- Around line 2156-2162: The current save block uses a single fs.rename which
can fail on Windows with transient EPERM/EBUSY; replace the direct await
fs.rename(tempPath, path) in the flagged-save block that uses
normalizeFlaggedStorage and cloneFlaggedStorageForPersist with the project’s
existing retry helper (the retry function defined near lib/storage.ts:454) to
attempt the rename multiple times with backoff and only surface the error after
retries exhaust; ensure the retry only targets transient filesystem errors
(EPERM/EBUSY) and preserves the same tempPath/path semantics, add/adjust tests
(vitest) to cover Windows-like EBUSY/429 scenarios for rotation/concurrent
updates, and avoid logging any sensitive data (tokens/emails) during retries or
errors.

In `@lib/telemetry.ts`:
- Around line 161-176: The unlink/rename calls in the telemetry rotation code
(around the block using fs.unlink(target) and fs.rename(source, target)) must
retry on transient Windows filesystem errors (EBUSY/EPERM/EAGAIN) instead of
failing immediately; implement a small retry-with-exponential-backoff-and-jitter
helper (e.g., retryFsOp or useTransientRetry) and use it to wrap the existing
unlink and rename calls, consult and reuse isMissingFsError for
non-transient-not-found handling, and ensure non-transient errors are still
rethrown; update recordTelemetryEvent-related flows (where failures were
previously swallowed) and add/adjust Vitest unit tests to cover
EBUSY/EPERM/EAGAIN transient failures and successful retry, making sure no logs
leak tokens/emails.

In `@test/accounts-edge.test.ts`:
- Around line 611-617: The test currently checks credential fields for the
merged account but omits an explicit identity assertion; update the test so
after finding mergedAccount from retriedPayload.accounts (the variable
`mergedAccount` in accounts-edge.test.ts) you assert mergedAccount.accountId ===
"account-identity-1" to ensure identity stability and catch merge regressions
(this ties to the merge behavior in lib/accounts.ts around the merge logic at
~810); keep the assertion deterministic using the vitest expect API already in
the file and do not mock secrets or remove existing credential assertions.

In `@test/storage-flagged.test.ts`:
- Around line 245-260: Move the process.env mutations for
CODEX_AUTH_ENCRYPTION_KEY and CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY into the
try/finally scope so the original values saved in previousEncryptionKey and
previousPreviousKey are always restored; specifically, in the test surrounding
saveFlaggedAccounts(...) wrap the env setup (setting
process.env.CODEX_AUTH_ENCRYPTION_KEY and deleting
process.env.CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY) right after entering try and
perform the restoration of previousEncryptionKey/previousPreviousKey in the
finally block to guarantee cleanup even if setup or saveFlaggedAccounts throws.

In `@test/telemetry.test.ts`:
- Around line 179-203: Add a deterministic Vitest regression test (in
test/telemetry.test.ts) that mirrors the existing "preserves concurrent events
across async log rotation" case but specifically simulates Windows busy-file
rename/unlink behavior: use vitest.spyOn(fs, "rename") and vitest.spyOn(fs,
"unlink") (or only the one used by the rotation code) to throw an error with
code "EBUSY" exactly once and then delegate to the original implementation on
subsequent calls, call recordTelemetryEvent repeatedly (same pattern as the
existing test), await queryTelemetryEvents and assert all events are preserved,
and finally restore spies; target the rotation logic exercised in
lib/telemetry.ts (around the rename/unlink logic referenced at line ~161) and
ensure the test uses Vitest APIs (no nondeterministic timing or real secret
mocking) so it reliably reproduces the Windows busy-file retry path and verifies
no event loss.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 466cac5d-f709-462c-9db3-c83d104baf21

📥 Commits

Reviewing files that changed from the base of the PR and between 555ca58 and 6d8c362.

📒 Files selected for processing (6)
  • lib/accounts.ts
  • lib/storage.ts
  • lib/telemetry.ts
  • test/accounts-edge.test.ts
  • test/storage-flagged.test.ts
  • test/telemetry.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (2)
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/accounts.ts
  • lib/telemetry.ts
  • lib/storage.ts
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/telemetry.test.ts
  • test/accounts-edge.test.ts
  • test/storage-flagged.test.ts
🧬 Code graph analysis (3)
test/telemetry.test.ts (1)
lib/telemetry.ts (4)
  • configureTelemetry (240-242)
  • recordTelemetryEvent (252-277)
  • queryTelemetryEvents (279-299)
  • getTelemetryLogPath (248-250)
test/accounts-edge.test.ts (1)
lib/accounts.ts (1)
  • AccountManager (105-1029)
test/storage-flagged.test.ts (1)
lib/storage.ts (4)
  • saveFlaggedAccounts (2177-2179)
  • getFlaggedAccountsPath (961-963)
  • rotateStoredSecretEncryption (2284-2314)
  • loadFlaggedAccounts (2173-2175)
🔇 Additional comments (3)
lib/accounts.ts (1)

857-926: credential-source selection looks correct and is covered in both directions.

the merge gate in lib/accounts.ts:857 plus selectCredentialMergeSource in lib/accounts.ts:907 matches the regression coverage at test/accounts-edge.test.ts:512 and test/accounts-edge.test.ts:567. nice fix for stale-disk overwrite behavior.

As per coding guidelines lib/**: focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

test/storage-flagged.test.ts (1)

262-307: good race reproduction for flagged rotation serialization.

injecting a concurrent saveFlaggedAccounts from test/storage-flagged.test.ts:273 while rotation is reading is a solid regression for the lock-serialization fix in lib/storage.ts:2301.

As per coding guidelines test/**: tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

lib/storage.ts (1)

2301-2308: single-lock flagged rotation path closes the in-process read/write toctou window.

wrapping loadFlaggedAccountsUnlocked + saveFlaggedAccountsUnlocked inside one lock at lib/storage.ts:2301 aligns with the regression in test/storage-flagged.test.ts:244 and removes the prior unlocked read/write gap.

As per coding guidelines lib/**: focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Comment on lines +2156 to +2162
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) {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

use retrying rename for flagged saves on windows locks.

lib/storage.ts:2161 uses a single fs.rename. transient EPERM/EBUSY can fail flagged writes during rotation and concurrent updates. reuse the existing retry helper at lib/storage.ts:454.

proposed patch
-    await fs.rename(tempPath, path);
+    await renameFileWithRetry(tempPath, path);

As per coding guidelines lib/**: focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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.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 renameFileWithRetry(tempPath, path);
} catch (error) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/storage.ts` around lines 2156 - 2162, The current save block uses a
single fs.rename which can fail on Windows with transient EPERM/EBUSY; replace
the direct await fs.rename(tempPath, path) in the flagged-save block that uses
normalizeFlaggedStorage and cloneFlaggedStorageForPersist with the project’s
existing retry helper (the retry function defined near lib/storage.ts:454) to
attempt the rename multiple times with backoff and only surface the error after
retries exhaust; ensure the retry only targets transient filesystem errors
(EPERM/EBUSY) and preserves the same tempPath/path semantics, add/adjust tests
(vitest) to cover Windows-like EBUSY/429 scenarios for rotation/concurrent
updates, and avoid logging any sensitive data (tokens/emails) during retries or
errors.

Comment on lines +161 to 176
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;
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

add retry/backoff for windows busy-file rotation failures.

fs.unlink and fs.rename in lib/telemetry.ts:163 and lib/telemetry.ts:171 fail hard on transient EBUSY/EPERM/EAGAIN. that bubbles into recordTelemetryEvent and gets swallowed at lib/telemetry.ts:274, dropping events.

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 lib/**: focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/telemetry.ts` around lines 161 - 176, The unlink/rename calls in the
telemetry rotation code (around the block using fs.unlink(target) and
fs.rename(source, target)) must retry on transient Windows filesystem errors
(EBUSY/EPERM/EAGAIN) instead of failing immediately; implement a small
retry-with-exponential-backoff-and-jitter helper (e.g., retryFsOp or
useTransientRetry) and use it to wrap the existing unlink and rename calls,
consult and reuse isMissingFsError for non-transient-not-found handling, and
ensure non-transient errors are still rethrown; update
recordTelemetryEvent-related flows (where failures were previously swallowed)
and add/adjust Vitest unit tests to cover EBUSY/EPERM/EAGAIN transient failures
and successful retry, making sure no logs leak tokens/emails.

Comment on lines +611 to +617
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);
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

assert account id stability in this regression.

the new case verifies credential fields but not identity stability. add an explicit accountId assertion so identity merge regressions fail fast. see test/accounts-edge.test.ts:611 and lib/accounts.ts:810.

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 test/**: tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
});
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);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/accounts-edge.test.ts` around lines 611 - 617, The test currently checks
credential fields for the merged account but omits an explicit identity
assertion; update the test so after finding mergedAccount from
retriedPayload.accounts (the variable `mergedAccount` in accounts-edge.test.ts)
you assert mergedAccount.accountId === "account-identity-1" to ensure identity
stability and catch merge regressions (this ties to the merge behavior in
lib/accounts.ts around the merge logic at ~810); keep the assertion
deterministic using the vitest expect API already in the file and do not mock
secrets or remove existing credential assertions.

Comment on lines +245 to +260
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,
},
],
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

move env mutation inside the try/finally restoration scope.

process.env is changed before the try starts in test/storage-flagged.test.ts:247-260. if setup throws there, keys are not restored and later tests can flake.

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 test/**: tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/storage-flagged.test.ts` around lines 245 - 260, Move the process.env
mutations for CODEX_AUTH_ENCRYPTION_KEY and CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY
into the try/finally scope so the original values saved in previousEncryptionKey
and previousPreviousKey are always restored; specifically, in the test
surrounding saveFlaggedAccounts(...) wrap the env setup (setting
process.env.CODEX_AUTH_ENCRYPTION_KEY and deleting
process.env.CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY) right after entering try and
perform the restoration of previousEncryptionKey/previousPreviousKey in the
finally block to guarantee cleanup even if setup or saveFlaggedAccounts throws.

Comment on lines +179 to +203
it("preserves concurrent events across async log rotation", async () => {
configureTelemetry({ maxFileSizeBytes: 220, maxFiles: 16 });

await Promise.all(
Array.from({ length: 12 }, (_, index) =>
recordTelemetryEvent({
source: index % 2 === 0 ? "plugin" : "cli",
event: `rotation.concurrent.${index}`,
outcome: index % 3 === 0 ? "failure" : "success",
details: {
index,
message: "x".repeat(96),
},
}),
),
);

const events = await queryTelemetryEvents({ limit: 50 });

expect(events).toHaveLength(12);
expect(events.map((event) => event.event).sort()).toEqual(
Array.from({ length: 12 }, (_, index) => `rotation.concurrent.${index}`).sort(),
);
expect(existsSync(`${getTelemetryLogPath()}.1`)).toBe(true);
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

add a windows busy-file rotation regression case.

this test covers concurrent ordering, but it does not exercise windows lock behavior. add a vitest case that spies fs.rename/fs.unlink to throw EBUSY once, then succeed, and assert no event loss. see test/telemetry.test.ts:179 and lib/telemetry.ts:161.

As per coding guidelines test/**: tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/telemetry.test.ts` around lines 179 - 203, Add a deterministic Vitest
regression test (in test/telemetry.test.ts) that mirrors the existing "preserves
concurrent events across async log rotation" case but specifically simulates
Windows busy-file rename/unlink behavior: use vitest.spyOn(fs, "rename") and
vitest.spyOn(fs, "unlink") (or only the one used by the rotation code) to throw
an error with code "EBUSY" exactly once and then delegate to the original
implementation on subsequent calls, call recordTelemetryEvent repeatedly (same
pattern as the existing test), await queryTelemetryEvents and assert all events
are preserved, and finally restore spies; target the rotation logic exercised in
lib/telemetry.ts (around the rename/unlink logic referenced at line ~161) and
ensure the test uses Vitest APIs (no nondeterministic timing or real secret
mocking) so it reliably reproduces the Windows busy-file retry path and verifies
no event loss.

Comment on lines +2173 to +2175
export async function loadFlaggedAccounts(): Promise<FlaggedAccountStorageV1> {
return loadFlaggedAccountsUnlocked();
}
Copy link

Choose a reason for hiding this comment

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

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:

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.

Prompt To Fix With AI
This 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.

Fix in Codex

Comment on lines +916 to +918
if (incomingExpiresAt === undefined && currentExpiresAt === undefined) {
return "incoming";
}
Copy link

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.

current is the in-memory merged value (fresher), while incoming is the on-disk snapshot (potentially stale). when timestamps are absent on both, preferring current is safer and aligns with the stated goal.

suggest:

Suggested change
if (incomingExpiresAt === undefined && currentExpiresAt === undefined) {
return "incoming";
}
if (incomingExpiresAt === undefined && currentExpiresAt === undefined) {
return "current";
}

also missing: no vitest coverage exists for this tie-breaking path, which changed behavior and carries token safety risk.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/accounts.ts
Line: 916-918

Comment:
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.

`current` is the in-memory merged value (fresher), while `incoming` is the on-disk snapshot (potentially stale). when timestamps are absent on both, preferring `current` is safer and aligns with the stated goal.

suggest:

```suggestion
		if (incomingExpiresAt === undefined && currentExpiresAt === undefined) {
			return "current";
		}
```

also missing: no vitest coverage exists for this tie-breaking path, which changed behavior and carries token safety risk.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

Comment on lines +170 to 176
try {
await fs.rename(source, target);
} catch (error) {
if (!isMissingFsError(error)) {
throw error;
}
}
Copy link

Choose a reason for hiding this comment

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

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:

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.

Prompt To Fix With AI
This 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.

Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant