Skip to content

Commit f4d9bd3

Browse files
committed
staging: integrate PR #413 fix/remove-account-pointer-dangle
# Conflicts: # lib/accounts.ts # test/accounts.test.ts
2 parents a22da1e + 421bb89 commit f4d9bd3

2 files changed

Lines changed: 210 additions & 16 deletions

File tree

lib/accounts.ts

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1289,12 +1289,40 @@ export class AccountManager {
12891289
return waitTimes.length > 0 ? Math.min(...waitTimes) : 0;
12901290
}
12911291

1292+
/**
1293+
* Walks forward from `start` (inclusive) looking for the next enabled
1294+
* account, wrapping around at most once. Returns -1 when every account in
1295+
* the pool is disabled or the pool is empty.
1296+
*/
1297+
private findNextEnabled(start: number): number {
1298+
const count = this.accounts.length;
1299+
if (count === 0) return -1;
1300+
const base = ((start % count) + count) % count;
1301+
for (let step = 0; step < count; step++) {
1302+
const candidate = (base + step) % count;
1303+
const account = this.accounts[candidate];
1304+
if (account && account.enabled !== false) {
1305+
return candidate;
1306+
}
1307+
}
1308+
return -1;
1309+
}
1310+
12921311
removeAccount(account: ManagedAccount): boolean {
12931312
const idx = this.accounts.indexOf(account);
12941313
if (idx < 0) {
12951314
return false;
12961315
}
12971316

1317+
// Snapshot family pointers before splice so we can distinguish "was
1318+
// pointing at the removed account" from "was pointing past it".
1319+
const priorCursor: Record<ModelFamily, number> = {} as Record<ModelFamily, number>;
1320+
const priorActive: Record<ModelFamily, number> = {} as Record<ModelFamily, number>;
1321+
for (const family of MODEL_FAMILIES) {
1322+
priorCursor[family] = this.cursorByFamily[family];
1323+
priorActive[family] = this.currentAccountIndexByFamily[family];
1324+
}
1325+
12981326
this.accounts.splice(idx, 1);
12991327
this.accounts.forEach((acc, index) => {
13001328
acc.index = index;
@@ -1309,25 +1337,39 @@ export class AccountManager {
13091337
}
13101338

13111339
for (const family of MODEL_FAMILIES) {
1312-
if (this.cursorByFamily[family] > idx) {
1313-
this.cursorByFamily[family] = Math.max(
1314-
0,
1315-
this.cursorByFamily[family] - 1,
1316-
);
1340+
// Cursor: shift down if it was past the removed index, then normalize
1341+
// into [0, length). If the cursor was pointing AT the removed slot
1342+
// we keep the same numeric position which now references the
1343+
// successor account (post-splice).
1344+
let cursor = priorCursor[family];
1345+
if (cursor > idx) {
1346+
cursor = Math.max(0, cursor - 1);
13171347
}
1318-
}
1319-
for (const family of MODEL_FAMILIES) {
1320-
this.cursorByFamily[family] =
1321-
this.cursorByFamily[family] % this.accounts.length;
1322-
}
1323-
1324-
for (const family of MODEL_FAMILIES) {
1325-
if (this.currentAccountIndexByFamily[family] > idx) {
1326-
this.currentAccountIndexByFamily[family] -= 1;
1348+
if (cursor >= this.accounts.length) {
1349+
cursor = 0;
13271350
}
1328-
if (this.currentAccountIndexByFamily[family] >= this.accounts.length) {
1329-
this.currentAccountIndexByFamily[family] = -1;
1351+
if (cursor < 0) {
1352+
cursor = 0;
1353+
}
1354+
this.cursorByFamily[family] = cursor;
1355+
1356+
// Active pointer: preserve pre-PR behavior for pointers strictly
1357+
// past the removed index (just shift down). When the pointer was
1358+
// AT the removed slot (or is now dangling off the end after
1359+
// splice), advance to the next enabled account instead of
1360+
// defaulting to -1. Fall back to -1 only when every remaining
1361+
// account is disabled or the pool is empty.
1362+
let active = priorActive[family];
1363+
if (active > idx) {
1364+
active -= 1;
1365+
} else if (active === idx) {
1366+
// Same numeric position now hosts the successor account.
1367+
active = this.findNextEnabled(Math.min(idx, this.accounts.length - 1));
1368+
}
1369+
if (active >= this.accounts.length) {
1370+
active = this.findNextEnabled(0);
13301371
}
1372+
this.currentAccountIndexByFamily[family] = active;
13311373
}
13321374

13331375
return true;

test/accounts.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
setStoragePathState,
3232
} from "../lib/storage/path-state.js";
3333
import type { OAuthAuthDetails } from "../lib/types.js";
34+
import { MODEL_FAMILIES } from "../lib/prompts/codex.js";
3435

3536
vi.mock("../lib/storage.js", async (importOriginal) => {
3637
const actual = await importOriginal<typeof import("../lib/storage.js")>();
@@ -2737,6 +2738,157 @@ describe("AccountManager", () => {
27372738
expect(manager.getActiveIndexForFamily("codex")).toBe(-1);
27382739
});
27392740
});
2741+
describe("active-account pointer dangle (audit HIGH-3)", () => {
2742+
it("advances active pointer to next enabled account when active is at last slot", () => {
2743+
const now = Date.now();
2744+
const stored = {
2745+
version: 3 as const,
2746+
activeIndex: 2,
2747+
activeIndexByFamily: { codex: 2 },
2748+
accounts: [
2749+
{ refreshToken: "token-1", addedAt: now, lastUsed: now },
2750+
{ refreshToken: "token-2", addedAt: now, lastUsed: now },
2751+
{ refreshToken: "token-3", addedAt: now, lastUsed: now },
2752+
],
2753+
};
2754+
2755+
const manager = new AccountManager(undefined, stored as never);
2756+
const active = manager.getCurrentAccountForFamily("codex");
2757+
expect(active?.refreshToken).toBe("token-3");
2758+
2759+
// Remove the currently active account that lives at the last slot.
2760+
manager.removeAccount(active!);
2761+
2762+
expect(manager.getAccountCount()).toBe(2);
2763+
// Pointer must reference a valid enabled account, not -1.
2764+
const after = manager.getActiveIndexForFamily("codex");
2765+
expect(after).toBeGreaterThanOrEqual(0);
2766+
expect(after).toBeLessThan(2);
2767+
const newActive = manager.getCurrentAccountForFamily("codex");
2768+
expect(newActive).not.toBeNull();
2769+
expect(newActive?.enabled).not.toBe(false);
2770+
});
2771+
2772+
it("advances active pointer to next enabled when active is at middle slot", () => {
2773+
const now = Date.now();
2774+
const stored = {
2775+
version: 3 as const,
2776+
activeIndex: 1,
2777+
activeIndexByFamily: { codex: 1 },
2778+
accounts: [
2779+
{ refreshToken: "token-1", addedAt: now, lastUsed: now },
2780+
{ refreshToken: "token-2", addedAt: now, lastUsed: now },
2781+
{ refreshToken: "token-3", addedAt: now, lastUsed: now },
2782+
],
2783+
};
2784+
2785+
const manager = new AccountManager(undefined, stored as never);
2786+
const active = manager.getCurrentAccountForFamily("codex");
2787+
expect(active?.refreshToken).toBe("token-2");
2788+
2789+
manager.removeAccount(active!);
2790+
2791+
expect(manager.getAccountCount()).toBe(2);
2792+
// After removing token-2 from index 1, token-3 slides into index 1.
2793+
// The pointer should now reference token-3 (the successor).
2794+
const newActive = manager.getCurrentAccountForFamily("codex");
2795+
expect(newActive?.refreshToken).toBe("token-3");
2796+
expect(manager.getActiveIndexForFamily("codex")).toBe(1);
2797+
});
2798+
2799+
it("yields no routable account when every remaining account is disabled", () => {
2800+
const now = Date.now();
2801+
const stored = {
2802+
version: 3 as const,
2803+
activeIndex: 2,
2804+
activeIndexByFamily: { codex: 2 },
2805+
accounts: [
2806+
{ refreshToken: "token-1", addedAt: now, lastUsed: now, enabled: false },
2807+
{ refreshToken: "token-2", addedAt: now, lastUsed: now, enabled: false },
2808+
{ refreshToken: "token-3", addedAt: now, lastUsed: now, enabled: true },
2809+
],
2810+
};
2811+
2812+
const manager = new AccountManager(undefined, stored as never);
2813+
const active = manager.getCurrentAccountForFamily("codex");
2814+
expect(active?.refreshToken).toBe("token-3");
2815+
2816+
// Remove the last enabled account; remaining pool is all-disabled.
2817+
manager.removeAccount(active!);
2818+
2819+
expect(manager.getAccountCount()).toBe(2);
2820+
// getCurrentAccountForFamily returns null whenever the pointer
2821+
// references a disabled slot or is -1, which is the observable
2822+
// contract callers rely on for "no routable account".
2823+
expect(manager.getCurrentAccountForFamily("codex")).toBeNull();
2824+
});
2825+
2826+
it("sets active pointer to -1 when pool becomes empty", () => {
2827+
const now = Date.now();
2828+
const stored = {
2829+
version: 3 as const,
2830+
activeIndex: 0,
2831+
accounts: [
2832+
{ refreshToken: "token-1", addedAt: now, lastUsed: now },
2833+
],
2834+
};
2835+
2836+
const manager = new AccountManager(undefined, stored);
2837+
const account = manager.getCurrentAccount()!;
2838+
manager.removeAccount(account);
2839+
2840+
expect(manager.getAccountCount()).toBe(0);
2841+
expect(manager.getActiveIndexForFamily("codex")).toBe(-1);
2842+
});
2843+
2844+
it("does not perturb unrelated family pointers", () => {
2845+
const now = Date.now();
2846+
const stored = {
2847+
version: 3 as const,
2848+
activeIndex: 0,
2849+
activeIndexByFamily: { codex: 2 },
2850+
accounts: [
2851+
{ refreshToken: "token-1", addedAt: now, lastUsed: now },
2852+
{ refreshToken: "token-2", addedAt: now, lastUsed: now },
2853+
{ refreshToken: "token-3", addedAt: now, lastUsed: now },
2854+
],
2855+
};
2856+
2857+
const manager = new AccountManager(undefined, stored as never);
2858+
// Pick a family other than codex that exists in MODEL_FAMILIES.
2859+
const otherFamilies = MODEL_FAMILIES.filter((f) => f !== "codex");
2860+
if (otherFamilies.length === 0) {
2861+
// Defensive: single-family config means this test reduces to no-op.
2862+
return;
2863+
}
2864+
const otherFamily = otherFamilies[0]!;
2865+
2866+
// Drive the other-family pointer to index 0 via its own rotation.
2867+
// Directly manipulate via getActiveIndexForFamily/setActiveIndex since
2868+
// setActiveIndex mirrors all families; instead rotate the target
2869+
// family by calling getCurrentOrNextForFamily once.
2870+
const viaRotation = manager.getCurrentOrNextForFamily(otherFamily);
2871+
expect(viaRotation).not.toBeNull();
2872+
const otherBefore = manager.getActiveIndexForFamily(otherFamily);
2873+
expect(otherBefore).toBeGreaterThanOrEqual(0);
2874+
expect(otherBefore).toBeLessThan(3);
2875+
2876+
// Remove the codex-active account (last slot).
2877+
const codexActive = manager.getCurrentAccountForFamily("codex");
2878+
expect(codexActive?.refreshToken).toBe("token-3");
2879+
manager.removeAccount(codexActive!);
2880+
2881+
// The other family's pointer must still reference a valid enabled
2882+
// account in the new pool; it must not dangle to -1 just because we
2883+
// removed from codex.
2884+
const otherAfter = manager.getActiveIndexForFamily(otherFamily);
2885+
expect(otherAfter).toBeGreaterThanOrEqual(0);
2886+
expect(otherAfter).toBeLessThan(2);
2887+
const otherAccount = manager.getCurrentAccountForFamily(otherFamily);
2888+
expect(otherAccount).not.toBeNull();
2889+
expect(otherAccount?.enabled).not.toBe(false);
2890+
});
2891+
});
27402892

27412893
describe("flushPendingSave", () => {
27422894
it("flushes pending debounced save", async () => {

0 commit comments

Comments
 (0)