@@ -27,6 +27,7 @@ import {
2727 setStoragePathState ,
2828} from "../lib/storage/path-state.js" ;
2929import type { OAuthAuthDetails } from "../lib/types.js" ;
30+ import { MODEL_FAMILIES } from "../lib/prompts/codex.js" ;
3031
3132vi . 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