@@ -31,6 +31,7 @@ import {
3131 setStoragePathState ,
3232} from "../lib/storage/path-state.js" ;
3333import type { OAuthAuthDetails } from "../lib/types.js" ;
34+ import { MODEL_FAMILIES } from "../lib/prompts/codex.js" ;
3435
3536vi . 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