Skip to content

Commit 421bb89

Browse files
committed
fix(accounts): harden removeAccount pointer normalization
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
1 parent 1f6da97 commit 421bb89

2 files changed

Lines changed: 215 additions & 12 deletions

File tree

lib/accounts.ts

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,12 +1094,40 @@ export class AccountManager {
10941094
return waitTimes.length > 0 ? Math.min(...waitTimes) : 0;
10951095
}
10961096

1097+
/**
1098+
* Walks forward from `start` (inclusive) looking for the next enabled
1099+
* account, wrapping around at most once. Returns -1 when every account in
1100+
* the pool is disabled or the pool is empty.
1101+
*/
1102+
private findNextEnabled(start: number): number {
1103+
const count = this.accounts.length;
1104+
if (count === 0) return -1;
1105+
const base = ((start % count) + count) % count;
1106+
for (let step = 0; step < count; step++) {
1107+
const candidate = (base + step) % count;
1108+
const account = this.accounts[candidate];
1109+
if (account && account.enabled !== false) {
1110+
return candidate;
1111+
}
1112+
}
1113+
return -1;
1114+
}
1115+
10971116
removeAccount(account: ManagedAccount): boolean {
10981117
const idx = this.accounts.indexOf(account);
10991118
if (idx < 0) {
11001119
return false;
11011120
}
11021121

1122+
// Snapshot family pointers before splice so we can distinguish "was
1123+
// pointing at the removed account" from "was pointing past it".
1124+
const priorCursor: Record<ModelFamily, number> = {} as Record<ModelFamily, number>;
1125+
const priorActive: Record<ModelFamily, number> = {} as Record<ModelFamily, number>;
1126+
for (const family of MODEL_FAMILIES) {
1127+
priorCursor[family] = this.cursorByFamily[family];
1128+
priorActive[family] = this.currentAccountIndexByFamily[family];
1129+
}
1130+
11031131
this.accounts.splice(idx, 1);
11041132
this.accounts.forEach((acc, index) => {
11051133
acc.index = index;
@@ -1114,21 +1142,39 @@ export class AccountManager {
11141142
}
11151143

11161144
for (const family of MODEL_FAMILIES) {
1117-
if (this.cursorByFamily[family] > idx) {
1118-
this.cursorByFamily[family] = Math.max(0, this.cursorByFamily[family] - 1);
1145+
// Cursor: shift down if it was past the removed index, then normalize
1146+
// into [0, length). If the cursor was pointing AT the removed slot
1147+
// we keep the same numeric position which now references the
1148+
// successor account (post-splice).
1149+
let cursor = priorCursor[family];
1150+
if (cursor > idx) {
1151+
cursor = Math.max(0, cursor - 1);
11191152
}
1120-
}
1121-
for (const family of MODEL_FAMILIES) {
1122-
this.cursorByFamily[family] = this.cursorByFamily[family] % this.accounts.length;
1123-
}
1124-
1125-
for (const family of MODEL_FAMILIES) {
1126-
if (this.currentAccountIndexByFamily[family] > idx) {
1127-
this.currentAccountIndexByFamily[family] -= 1;
1153+
if (cursor >= this.accounts.length) {
1154+
cursor = 0;
11281155
}
1129-
if (this.currentAccountIndexByFamily[family] >= this.accounts.length) {
1130-
this.currentAccountIndexByFamily[family] = -1;
1156+
if (cursor < 0) {
1157+
cursor = 0;
1158+
}
1159+
this.cursorByFamily[family] = cursor;
1160+
1161+
// Active pointer: preserve pre-PR behavior for pointers strictly
1162+
// past the removed index (just shift down). When the pointer was
1163+
// AT the removed slot (or is now dangling off the end after
1164+
// splice), advance to the next enabled account instead of
1165+
// defaulting to -1. Fall back to -1 only when every remaining
1166+
// account is disabled or the pool is empty.
1167+
let active = priorActive[family];
1168+
if (active > idx) {
1169+
active -= 1;
1170+
} else if (active === idx) {
1171+
// Same numeric position now hosts the successor account.
1172+
active = this.findNextEnabled(Math.min(idx, this.accounts.length - 1));
1173+
}
1174+
if (active >= this.accounts.length) {
1175+
active = this.findNextEnabled(0);
11311176
}
1177+
this.currentAccountIndexByFamily[family] = active;
11321178
}
11331179

11341180
return true;

test/accounts.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
setStoragePathState,
2828
} from "../lib/storage/path-state.js";
2929
import type { OAuthAuthDetails } from "../lib/types.js";
30+
import { MODEL_FAMILIES } from "../lib/prompts/codex.js";
3031

3132
vi.mock("../lib/storage.js", async (importOriginal) => {
3233
const actual = await importOriginal<typeof import("../lib/storage.js")>();
@@ -2618,6 +2619,162 @@ describe("AccountManager", () => {
26182619
expect(manager.getAccountCount()).toBe(0);
26192620
expect(manager.getActiveIndexForFamily("codex")).toBe(-1);
26202621
});
2622+
2623+
// Regression suite for audit finding HIGH-3 (PR #399 audit report):
2624+
// removeAccount previously set activeIndex to -1 whenever the active
2625+
// account was removed from the last array slot (even when other enabled
2626+
// accounts remained). Pointer must advance to the next enabled account.
2627+
describe("active-account pointer dangle (audit HIGH-3)", () => {
2628+
it("advances active pointer to next enabled account when active is at last slot", () => {
2629+
const now = Date.now();
2630+
const stored = {
2631+
version: 3 as const,
2632+
activeIndex: 2,
2633+
activeIndexByFamily: { codex: 2 },
2634+
accounts: [
2635+
{ refreshToken: "token-1", addedAt: now, lastUsed: now },
2636+
{ refreshToken: "token-2", addedAt: now, lastUsed: now },
2637+
{ refreshToken: "token-3", addedAt: now, lastUsed: now },
2638+
],
2639+
};
2640+
2641+
const manager = new AccountManager(undefined, stored as never);
2642+
const active = manager.getCurrentAccountForFamily("codex");
2643+
expect(active?.refreshToken).toBe("token-3");
2644+
2645+
// Remove the currently active account that lives at the last slot.
2646+
manager.removeAccount(active!);
2647+
2648+
expect(manager.getAccountCount()).toBe(2);
2649+
// Pointer must reference a valid enabled account, not -1.
2650+
const after = manager.getActiveIndexForFamily("codex");
2651+
expect(after).toBeGreaterThanOrEqual(0);
2652+
expect(after).toBeLessThan(2);
2653+
const newActive = manager.getCurrentAccountForFamily("codex");
2654+
expect(newActive).not.toBeNull();
2655+
expect(newActive?.enabled).not.toBe(false);
2656+
});
2657+
2658+
it("advances active pointer to next enabled when active is at middle slot", () => {
2659+
const now = Date.now();
2660+
const stored = {
2661+
version: 3 as const,
2662+
activeIndex: 1,
2663+
activeIndexByFamily: { codex: 1 },
2664+
accounts: [
2665+
{ refreshToken: "token-1", addedAt: now, lastUsed: now },
2666+
{ refreshToken: "token-2", addedAt: now, lastUsed: now },
2667+
{ refreshToken: "token-3", addedAt: now, lastUsed: now },
2668+
],
2669+
};
2670+
2671+
const manager = new AccountManager(undefined, stored as never);
2672+
const active = manager.getCurrentAccountForFamily("codex");
2673+
expect(active?.refreshToken).toBe("token-2");
2674+
2675+
manager.removeAccount(active!);
2676+
2677+
expect(manager.getAccountCount()).toBe(2);
2678+
// After removing token-2 from index 1, token-3 slides into index 1.
2679+
// The pointer should now reference token-3 (the successor).
2680+
const newActive = manager.getCurrentAccountForFamily("codex");
2681+
expect(newActive?.refreshToken).toBe("token-3");
2682+
expect(manager.getActiveIndexForFamily("codex")).toBe(1);
2683+
});
2684+
2685+
it("yields no routable account when every remaining account is disabled", () => {
2686+
const now = Date.now();
2687+
const stored = {
2688+
version: 3 as const,
2689+
activeIndex: 2,
2690+
activeIndexByFamily: { codex: 2 },
2691+
accounts: [
2692+
{ refreshToken: "token-1", addedAt: now, lastUsed: now, enabled: false },
2693+
{ refreshToken: "token-2", addedAt: now, lastUsed: now, enabled: false },
2694+
{ refreshToken: "token-3", addedAt: now, lastUsed: now, enabled: true },
2695+
],
2696+
};
2697+
2698+
const manager = new AccountManager(undefined, stored as never);
2699+
const active = manager.getCurrentAccountForFamily("codex");
2700+
expect(active?.refreshToken).toBe("token-3");
2701+
2702+
// Remove the last enabled account; remaining pool is all-disabled.
2703+
manager.removeAccount(active!);
2704+
2705+
expect(manager.getAccountCount()).toBe(2);
2706+
// getCurrentAccountForFamily returns null whenever the pointer
2707+
// references a disabled slot or is -1, which is the observable
2708+
// contract callers rely on for "no routable account".
2709+
expect(manager.getCurrentAccountForFamily("codex")).toBeNull();
2710+
});
2711+
2712+
it("sets active pointer to -1 when pool becomes empty", () => {
2713+
const now = Date.now();
2714+
const stored = {
2715+
version: 3 as const,
2716+
activeIndex: 0,
2717+
accounts: [
2718+
{ refreshToken: "token-1", addedAt: now, lastUsed: now },
2719+
],
2720+
};
2721+
2722+
const manager = new AccountManager(undefined, stored);
2723+
const account = manager.getCurrentAccount()!;
2724+
manager.removeAccount(account);
2725+
2726+
expect(manager.getAccountCount()).toBe(0);
2727+
expect(manager.getActiveIndexForFamily("codex")).toBe(-1);
2728+
});
2729+
2730+
it("does not perturb unrelated family pointers", () => {
2731+
const now = Date.now();
2732+
const stored = {
2733+
version: 3 as const,
2734+
activeIndex: 0,
2735+
activeIndexByFamily: { codex: 2 },
2736+
accounts: [
2737+
{ refreshToken: "token-1", addedAt: now, lastUsed: now },
2738+
{ refreshToken: "token-2", addedAt: now, lastUsed: now },
2739+
{ refreshToken: "token-3", addedAt: now, lastUsed: now },
2740+
],
2741+
};
2742+
2743+
const manager = new AccountManager(undefined, stored as never);
2744+
// Pick a family other than codex that exists in MODEL_FAMILIES.
2745+
const otherFamilies = MODEL_FAMILIES.filter((f) => f !== "codex");
2746+
if (otherFamilies.length === 0) {
2747+
// Defensive: single-family config means this test reduces to no-op.
2748+
return;
2749+
}
2750+
const otherFamily = otherFamilies[0]!;
2751+
2752+
// Drive the other-family pointer to index 0 via its own rotation.
2753+
// Directly manipulate via getActiveIndexForFamily/setActiveIndex since
2754+
// setActiveIndex mirrors all families; instead rotate the target
2755+
// family by calling getCurrentOrNextForFamily once.
2756+
const viaRotation = manager.getCurrentOrNextForFamily(otherFamily);
2757+
expect(viaRotation).not.toBeNull();
2758+
const otherBefore = manager.getActiveIndexForFamily(otherFamily);
2759+
expect(otherBefore).toBeGreaterThanOrEqual(0);
2760+
expect(otherBefore).toBeLessThan(3);
2761+
2762+
// Remove the codex-active account (last slot).
2763+
const codexActive = manager.getCurrentAccountForFamily("codex");
2764+
expect(codexActive?.refreshToken).toBe("token-3");
2765+
manager.removeAccount(codexActive!);
2766+
2767+
// The other family's pointer must still reference a valid enabled
2768+
// account in the new pool; it must not dangle to -1 just because we
2769+
// removed from codex.
2770+
const otherAfter = manager.getActiveIndexForFamily(otherFamily);
2771+
expect(otherAfter).toBeGreaterThanOrEqual(0);
2772+
expect(otherAfter).toBeLessThan(2);
2773+
const otherAccount = manager.getCurrentAccountForFamily(otherFamily);
2774+
expect(otherAccount).not.toBeNull();
2775+
expect(otherAccount?.enabled).not.toBe(false);
2776+
});
2777+
});
26212778
});
26222779

26232780
describe("flushPendingSave", () => {

0 commit comments

Comments
 (0)