Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(accounts): harden removeAccount pointer normalization - #413

Merged
ndycode merged 1 commit into
mainfrom
fix/remove-account-pointer-dangle
Apr 17, 2026
Merged

ndycode merged 1 commit into
mainfrom
fix/remove-account-pointer-dangle

Conversation

@ndycode

@ndycode ndycode commented Apr 17, 2026

Copy link
Copy Markdown
Owner

Follow-up to PR #399 addressing the pre-existing HIGH-3 finding from the oracle audit.

PR #399 addressed all-disabled and cursorByFamily drift but intentionally deferred the removeAccount dangle (flagged as pre-existing, out of scope for PR #399). This PR closes that remaining HIGH.

See .sisyphus/notepads/phase1-audit/reports/pr399.json finding HIGH-3.

Summary

When removeAccount removes the currently active account (e.g. the user's active index was at the last slot of the pool), the active pointer could collapse to -1 even when other enabled accounts still remained. Rotation paths paper over this at runtime, but any caller reading the pointer directly would see "no active account" immediately after the remove.

This PR adds a shared findNextEnabled helper and routes both activeIndex and cursorByFamily normalization through it. Pointers now advance to the next enabled account after removal and only fall back to -1 when the pool is empty or every remaining account is disabled.

Changes

  • lib/accounts.ts

    • Added findNextEnabled(start) private helper (wraps via modulo).
    • removeAccount snapshots prior per-family pointer state, applies the splice, then uses findNextEnabled to re-seat any pointer that was pointing at the removed slot or now dangles off the end.
    • Shift-down behavior for pointers strictly past the removed index is preserved to stay backward compatible with the existing test suite.
  • test/accounts.test.ts

    • New nested describe("active-account pointer dangle (audit HIGH-3)") with 5 regression cases:
      • Remove active account at the last array position → pointer advances to a valid enabled slot.
      • Remove active account at a middle slot → pointer advances to the successor (now at the same numeric index).
      • Remove active account when every other account is disabled → getCurrentAccountForFamily returns null.
      • Remove active account when the pool is now empty → pointer is -1.
      • Multi-family: removing from one family does not perturb another family's independently-rotated pointer.

Validation

  • npm test → all 3423 tests pass (225 files).
  • npm run typecheck → exit 0.
  • npm run lint → exit 0.

No changes outside lib/accounts.ts and test/accounts.test.ts. No type escape hatches (as any / @ts-ignore / @ts-expect-error).

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

closes audit HIGH-3: removeAccount now uses findNextEnabled (modulo wrap, returns -1 only on empty/all-disabled pool) to re-seat both the active and cursor pointers when the removed slot was the active account, instead of unconditionally collapsing to -1. no type escapes, all 3423 tests pass.

remaining findings are all P2: an unreachable defensive branch in the active-pointer normalization, a silent-return in the multi-family test that passes vacuously on single-family builds, and a missing getActiveIndexForFamily assertion in the all-disabled case that would reveal the pre-existing inconsistency between getActiveIndexForFamily's clamping fallback and the raw -1 stored after findNextEnabled returns no results. no property-based test was added to test/property/ for the new walk logic despite fast-check being available.

Confidence Score: 5/5

safe to merge — the core logic is correct and all remaining findings are P2 style/test gaps

all three comments are P2: one unreachable defensive branch, one silent-pass test risk on single-family builds, one missing assertion to pin internal state. none affect runtime correctness of the fix. the HIGH-3 regression is properly closed, no type escapes, 3423 tests pass.

test/accounts.test.ts — silent early-return and missing pointer assertion worth tightening before the test suite grows

Important Files Changed

Filename Overview
lib/accounts.ts adds findNextEnabled helper and updates removeAccount to re-seat active pointers via modulo walk instead of defaulting to -1; logic is correct for the stated cases, one unreachable defensive branch noted (P2)
test/accounts.test.ts adds 5 regression cases for HIGH-3 covering last-slot, mid-slot, all-disabled, empty-pool, and cross-family scenarios; two test gaps: silent early-return in multi-family test and missing pointer-state assertion in all-disabled case (both P2)

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[removeAccount called] --> B[indexOf account]
    B -- not found --> C[return false]
    B -- found idx --> D[snapshot priorCursor + priorActive per family]
    D --> E[splice accounts at idx]
    E --> F{pool empty?}
    F -- yes --> G[set all family pointers to -1, cursor to 0\nreturn true]
    F -- no --> H[for each MODEL_FAMILY]
    H --> I[cursor normalization\nshift-down if cursor > idx\nclamp to 0..length-1]
    I --> J{priorActive vs idx}
    J -- active > idx --> K[active -= 1\ntrack same account]
    J -- active === idx --> L[findNextEnabled start=min idx, length-1\nwrap-around walk]
    J -- active < idx --> M[active unchanged]
    L --> N{found enabled?}
    N -- yes --> O[active = candidate index]
    N -- no all-disabled --> P[active = -1]
    K --> Q{active >= length?}
    O --> Q
    M --> Q
    P --> Q
    Q -- yes defensive guard --> R[findNextEnabled 0]
    Q -- no --> S[currentAccountIndexByFamily = active]
    R --> S
    S --> T[return true]
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: 2746-2748

Comment:
**silent pass when `MODEL_FAMILIES` is single-family**

bare `return` here exits the test with zero assertions, so vitest reports it green even though nothing was actually verified. if this ever runs on a single-family build the test gives false confidence.

use `it.skipIf` / `vi.skip()` or assert the precondition explicitly so the skip is visible:

```suggestion
        if (otherFamilies.length === 0) {
          // single-family config: explicit skip so the gap is visible in the report
          return;
        }
```

or convert the whole test to `it.runIf(MODEL_FAMILIES.length > 1)(...)` at the top level so vitest marks it skipped rather than passing vacuously.

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

---

This is a comment left during a code review.
Path: test/accounts.test.ts
Line: 2685-2710

Comment:
**test doesn't pin the stored pointer value after all-disabled removal**

the test relies on `getCurrentAccountForFamily` returning null, which is correct, but that method also returns null when the pointer happens to land on a disabled slot — not only when it's `-1`. adding an explicit `getActiveIndexForFamily` assertion would pin that `findNextEnabled` actually returned -1 and not some stale enabled-but-now-disabled index.

note: `getActiveIndexForFamily` has a clamping fallback (`return this.accounts.length > 0 ? 0 : -1`) so you'd want to assert the raw internal state or use a helper that reflects `currentAccountIndexByFamily` directly. adding even a single line like the one below makes the intent clear:

```typescript
// active pointer must be -1 since every remaining account is disabled
expect(manager.getActiveIndexForFamily("codex")).toBe(-1);
```

(the clamping logic in `getActiveIndexForFamily` would return `0` for a non-empty pool with stored `-1`, so this assertion would actually fail — which reveals the inconsistency and could be worth an explicit code comment or test note.)

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

---

This is a comment left during a code review.
Path: lib/accounts.ts
Line: 1167-1176

Comment:
**`active >= this.accounts.length` fallback is unreachable in valid state**

for all three branches of the `active` computation:
- `active < idx` → unchanged, already `< this.accounts.length`
- `active > idx``active - 1`, still `< this.accounts.length` since `priorActive ≤ original_length - 1`
- `active === idx``findNextEnabled(...)` returns either a valid index or `-1`, both `< this.accounts.length`

the second `findNextEnabled(0)` call on line 1175 is dead code under normal operation. it only fires for an already-corrupted `priorActive` (out-of-range stored value). it's harmless as a defensive guard, but worth a brief inline comment so future readers don't wonder whether there's a code path that can actually reach it.

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

Reviews (1): Last reviewed commit: "fix(accounts): harden removeAccount poin..." | Re-trigger Greptile

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
@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.

@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@ndycode has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 52 minutes and 37 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 52 minutes and 37 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: 871b0825-12e3-4e94-bd40-0184cbe9e8a3

📥 Commits

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

📒 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/remove-account-pointer-dangle
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/remove-account-pointer-dangle

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.

@ndycode
ndycode merged commit f4d9bd3 into main Apr 17, 2026
1 of 2 checks passed
ndycode added a commit that referenced this pull request Apr 17, 2026
- Move release notes from draft to docs/releases/v1.3.0.md
- Bump version 1.2.7 -> 1.3.0
- Update README release-notes pointer (if applicable)
- Update CHANGELOG.md (if present)

Full post-audit Phase 1: 20 PRs + follow-up #413 + 7 audit fixes.
Staging-merge validated: 3527 tests green, 8-check battery passing.
ndycode added a commit that referenced this pull request Apr 17, 2026
PR #413 hardened removeAccount pointer normalization so the current
pointer no longer defaults to -1 when the active account is removed
while other accounts remain in the pool. It did so by retargeting the
pointer onto the successor account at the post-splice slot, which
silently substitutes a different account for the caller's "current"
without any audit trail.

HI-04 (from .sisyphus/notepads/deep-audit/reports/accounts-rotation.json)
flagged this as a correctness issue: getCurrentAccountForFamily()
returns an account the caller never selected, with no lastSwitchReason
indicating that the pool chose it. This masks pool-driven retargets in
logs, dashboards, and session-affinity consumers that key on
lastSwitchReason to distinguish user-selected from pool-selected
identities.

Fix: when removeAccount retargets the active pointer off the removed
slot onto a new successor (priorActive[family] === idx), stamp
lastSwitchReason="rotation" on that successor. This mirrors the
existing convention already used by setActiveIndex() and markSwitched()
for pool-driven selection events, so downstream observers see a
consistent retarget signal instead of the successor's stale prior
reason.

Successors are deduped across families (lastSwitchReason is per-account,
not per-family) via a Set, and the signal is only applied when we
actually retargeted off the removed slot. If the pool collapses to no
routable account (every remaining peer disabled), no successor is
stamped and the pointer falls to -1 — matching the "no routable
account" contract PR #413 established.

Tests (test/accounts.test.ts "removed-current retarget signal (HI-04)"):
- removing the currently active (middle) account stamps rotation on
  the successor and leaves untouched peers with their prior reason
- removing the last enabled account when every remaining peer is
  disabled yields pointer=-1 and does NOT stamp any disabled peer
- removing the currently active account with exactly one other enabled
  peer (plus a disabled peer that findNextEnabled must skip) lands on
  the enabled successor and stamps rotation only on it

Source: .sisyphus/notepads/deep-audit/reports/accounts-rotation.json HI-04
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