Skip to content

fix(accounts): normalize active pointer when the active account is disabled#399

Merged
ndycode merged 2 commits intomainfrom
fix/active-pointer-normalization
Apr 17, 2026
Merged

fix(accounts): normalize active pointer when the active account is disabled#399
ndycode merged 2 commits intomainfrom
fix/active-pointer-normalization

Conversation

@ndycode
Copy link
Copy Markdown
Owner

@ndycode ndycode commented Apr 17, 2026

Summary

Normalizes the active-account pointer so it always references a routable (enabled) account. Previously getActiveIndexForFamily() only bounds-clamped the stored index, and setAccountEnabled(idx, false) set the enabled flag without repairing any active-index pointer that referenced the just-disabled slot. UI / automation / routing could therefore hold a stale pointer to a disabled account after a user toggled it off in the CLI dashboard.

Problem

Audit (docs/audits/MASTER_AUDIT.md §5 HIGH AUDIT-H10 / dim-D-routing.md D-05) demonstrated the dangling-pointer case:

  1. User has accounts [0, 1, 2], active pointer = 1.
  2. User disables account 1 via the settings-hub or codex auth command.
  3. account.enabled = false but activeIndexByFamily.codex = 1 remains.
  4. Next getActiveIndexForFamily("codex") returns 1 — a disabled account.

Consumers either returned a disabled ManagedAccount or forced an implicit skip inside the fetch loop — neither is easy to observe or explain.

Change

lib/accounts.ts getActiveIndexForFamily()

- if (index < 0 || index >= this.accounts.length) {
-   return this.accounts.length > 0 ? 0 : -1;
- }
- return index;
+ if (this.accounts.length === 0) return -1;
+ const clamped = index < 0 || index >= this.accounts.length ? 0 : index;
+ // "Active" must mean ROUTABLE, not merely in-range.
+ if (this.accounts[clamped]?.enabled !== false) return clamped;
+ for (let step = 1; step < this.accounts.length; step += 1) {
+   const candidate = (clamped + step) % this.accounts.length;
+   if (this.accounts[candidate]?.enabled !== false) return candidate;
+ }
+ return clamped;  // all disabled — let caller surface via unavailable-pool path

lib/accounts.ts setAccountEnabled(index, false)

Walks forward from the just-disabled slot to the next enabled account for each family whose activeIndexByFamily pointer matched index. No-op when enabled = true.

Test

Added a regression test in test/accounts.test.ts that exercises the disable-active-account flow and asserts the pointer advances to the next enabled slot:

it("normalizes active pointer to next enabled slot when active account is disabled (AUDIT-H10)", () => {
  // Set active = 1, disable account 1, expect active pointer to become 2
});

Verification

  • Full suite: 225/225 files, 3419/3419 tests pass (+1 regression test)
  • npm run typecheck exit 0
  • npm run lint exit 0
  • No new dependencies
  • No new public API

Audit reference

  • docs/audits/MASTER_AUDIT.md §5 HIGH (post-Oracle adjustment) → AUDIT-H10
  • docs/audits/evidence/dim-D-routing.md D-05
  • docs/audits/evidence/oracle-verdicts.md §1.1 (Oracle demoted H10 to MEDIUM; this PR still closes it at the implementation level)

Scope guarantees

  • ✅ One concern — active-pointer normalization on disable + read
  • ✅ All-disabled case preserves existing semantics (pointer stays clamped, callers surface via unavailable-pool path)
  • ✅ No changes to activeIndex (the global pointer) — only activeIndexByFamily
  • ✅ Full test suite green

Follow-up

Phase 1 continues with PR-G (release hygiene — AUDIT-H7 pack:check + AUDIT-H8 AGENTS.md regen + AUDIT-M31 tmp-file leakage). Tracked in .sisyphus/plans/phase1-implementation.md.

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

fixes the dangling active-pointer bug (AUDIT-H10 / D-05) by patching two sites: getActiveIndexForFamily now defensively walks forward past disabled slots on every read, and setAccountEnabled(idx, false) eagerly normalizes both currentAccountIndexByFamily and cursorByFamily in lockstep so the stored pointer is repaired immediately rather than deferred to the next read.

the PR description's change snippet shows return clamped; for the all-disabled case, but the actual code (and all four new regression tests) correctly return -1 — the description is just a stale draft excerpt and doesn't affect correctness.

Confidence Score: 5/5

safe to merge — fix is correct, all-disabled edge case returns -1 as expected, no P0/P1 findings

all remaining findings are P2 — the only gap is that cursorByFamily normalization isn't directly exercised by a getCurrentOrNextForFamily call post-disable, but the skip-disabled loop in rotation masks any incorrectness there, and the four core AUDIT-H10 scenarios are well covered

test/accounts.test.ts — add a getCurrentOrNextForFamily assertion to cover the cursorByFamily normalization path

Important Files Changed

Filename Overview
lib/accounts.ts getActiveIndexForFamily now walks past disabled slots (defensive read) and setAccountEnabled eagerly repairs both currentAccountIndexByFamily and cursorByFamily; logic is sound and PR description's "return clamped" snippet is outdated — code correctly returns -1 for the all-disabled case
test/accounts.test.ts four new regression tests cover AUDIT-H10 scenarios well; cursorByFamily normalization path in setAccountEnabled has no direct test exercising getCurrentOrNextForFamily post-disable

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["setAccountEnabled(idx, false)"] --> B["account.enabled = false"]
    B --> C{"currentAccountIndexByFamily matches idx?"}
    C -- yes --> D["findNextEnabled(idx)"]
    D --> E{found?}
    E -- yes --> F["update currentAccountIndexByFamily"]
    E -- no --> G["leave stale - all disabled"]
    C -- no --> H["skip"]
    F --> I{"cursorByFamily matches idx?"}
    H --> I
    G --> I
    I -- yes --> J["findNextEnabled(idx) again"]
    J --> K{found?}
    K -- yes --> L["update cursorByFamily"]
    K -- no --> M["leave stale"]
    I -- no --> N["skip"]

    P["getActiveIndexForFamily(family)"] --> Q{accounts empty?}
    Q -- yes --> R["return -1"]
    Q -- no --> S["clamp index to valid range"]
    S --> T{"accounts-at-clamped enabled?"}
    T -- yes --> U["return clamped"]
    T -- no --> V["walk forward N-1 steps"]
    V --> W{enabled candidate found?}
    W -- yes --> X["return candidate"]
    W -- no --> Y["return -1 sentinel"]
Loading

Fix All in Codex

Prompt To Fix All With AI
This is a comment left during a code review.
Path: test/accounts.test.ts
Line: 3622-3660

Comment:
**`cursorByFamily` normalization not exercised by tests**

the new tests only call `getActiveIndexForFamily` after `setAccountEnabled`, which reads from `currentAccountIndexByFamily`. the companion normalization of `cursorByFamily` (also updated in lockstep in `setAccountEnabled`) is never directly verified.

a follow-on call to `getCurrentOrNextForFamily` after disabling the active account would confirm the cursor doesn't restart from the disabled slot — without it, the `cursorByFamily` branch in `setAccountEnabled` (lines 1289-1294 of `accounts.ts`) is dead to coverage. given that `getCurrentOrNextForFamily` already skips disabled accounts anyway, a stale cursor is masked; but the stated goal of the PR is to keep both pointers in sync, and that invariant isn't tested.

```ts
// add inside the "normalizes active pointer" test, after the first expect:
expect(manager.getCurrentOrNextForFamily("codex")?.refreshToken).toBe("token-2"); // cursor must start at 2, not 1
```

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

Reviews (2): Last reviewed commit: "fix(accounts): harden pointer normalizat..." | Re-trigger Greptile

…sabled

getActiveIndexForFamily() previously only bounds-clamped the stored index;
it did not check whether the referenced account was actually routable.
setAccountEnabled(idx, false) set the enabled flag but did not repair any
active-index pointer that happened to reference the just-disabled slot.

Together that meant UI / automation / routing could hold a stale pointer
to a disabled account after a user toggled it off in the CLI dashboard,
returning either a disabled ManagedAccount or forcing an implicit skip
inside the fetch loop — neither of which is easy to observe or explain.

This change:
- getActiveIndexForFamily() walks forward from the clamped slot to the
  next enabled account when the primary resolution lands on a disabled
  slot; if all accounts are disabled, it preserves the clamped pointer
  so callers can surface the 'no routable account' condition through the
  existing unavailable-pool paths rather than getting -1 mid-rotation.
- setAccountEnabled(idx, false) repairs every activeIndexByFamily pointer
  that referenced idx by walking forward to the next enabled slot.

Adds a regression test exercising the disable-active-account flow.

Closes AUDIT-H10 / D-05 (active-account pointer can dangle after
disable, identified in master repository audit).

Evidence: 225/225 test files, 3419/3419 tests pass (+1 regression).
typecheck + lint exit 0. No new dependencies, no new public API.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 17, 2026

Warning

Rate limit exceeded

@ndycode has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 54 minutes and 18 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 54 minutes and 18 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0cca00d9-ab37-4ba7-981f-b8666e041998

📥 Commits

Reviewing files that changed from the base of the PR and between 1f6da97 and d7af34d.

📒 Files selected for processing (2)
  • lib/accounts.ts
  • test/accounts.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/active-pointer-normalization
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/active-pointer-normalization

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

…ByFamily

Addresses oracle audit HIGH findings on PR #399 (skipping HIGH-3 which is pre-existing and out of scope):

- F1: getActiveIndexForFamily now returns -1 when all accounts disabled (previously returned a disabled index that callers trusted)

- F2: setAccountEnabled now also normalizes cursorByFamily, matching the normalization already applied to currentAccountIndexByFamily

- F4: Added regression tests for all-disabled, disable-then-re-enable, and multi-family divergence scenarios
ndycode added a commit that referenced this pull request Apr 17, 2026
Addresses oracle audit HIGH-3 finding (flagged as pre-existing during
PR #399 review). When removing the currently active account while
other accounts remain in the pool, pointers now advance to the next
enabled account instead of defaulting to -1.

Normalizes activeIndex, currentAccountIndexByFamily, and cursorByFamily
consistently via a shared findNextEnabled helper. The helper walks
forward (with modulo wrap) from a search origin and only returns -1
when every remaining account is disabled or the pool is empty.

Tests cover: remove-at-last, remove-at-middle, remove-with-all-others-disabled,
remove-until-empty, and multi-family isolation.

Source: .sisyphus/notepads/phase1-audit/reports/pr399.json HIGH-3
@ndycode ndycode merged commit 368a492 into main Apr 17, 2026
1 of 2 checks passed
ndycode added a commit that referenced this pull request Apr 17, 2026
markSwitched updated currentAccountIndexByFamily but left cursorByFamily
pointing at the pre-switch position. Subsequent round-robin passes
(getCurrentOrNextForFamily / getNextForFamily) started from the stale
cursor and could either re-pick the same slot or skip the just-switched
account entirely, dropping the caller's explicit switch intent after a
rate-limit-triggered rotation.

Fix both markSwitched and its mutex-serialized sibling markSwitchedLocked
to advance cursorByFamily[family] to `(account.index + 1) % count`,
matching the convention already used in getCurrentOrNextForFamilyHybrid
and the inner loop of getCurrentOrNextForFamily. No-op when the pool is
empty.

Adds a regression test that walks the cursor to a non-zero position,
marks a different account as switched, and asserts the next rotation
resumes AFTER the marked slot rather than from the stale cursor.

Not covered by PR #399 (which normalized pointers on setAccountEnabled
and getActiveIndexForFamily only).
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