@@ -3078,6 +3078,122 @@ describe("AccountManager", () => {
30783078 } ) ;
30793079 } ) ;
30803080
3081+ describe ( "removed-current retarget signal (HI-04)" , ( ) => {
3082+ // PR #413 hardened removeAccount() to avoid the pointer-dangle
3083+ // default-to-minus-one behavior, but it silently retargeted the
3084+ // current pointer onto a different account when the removed
3085+ // account WAS the current one. HI-04 flagged that silent
3086+ // retarget: callers observing getCurrentAccountForFamily()
3087+ // receive an account they never selected with no audit trail.
3088+ //
3089+ // The fix stamps lastSwitchReason="rotation" on the successor
3090+ // whenever removeAccount() retargets off the removed slot. This
3091+ // mirrors the convention already used by setActiveIndex() and
3092+ // markSwitched() for pool-driven selection events.
3093+
3094+ it ( "stamps lastSwitchReason='rotation' on successor when current is removed" , ( ) => {
3095+ const now = Date . now ( ) ;
3096+ const stored = {
3097+ version : 3 as const ,
3098+ activeIndex : 1 ,
3099+ activeIndexByFamily : { codex : 1 } ,
3100+ accounts : [
3101+ { refreshToken : "token-1" , addedAt : now , lastUsed : now , lastSwitchReason : "initial" as const } ,
3102+ { refreshToken : "token-2" , addedAt : now , lastUsed : now , lastSwitchReason : "initial" as const } ,
3103+ { refreshToken : "token-3" , addedAt : now , lastUsed : now , lastSwitchReason : "initial" as const } ,
3104+ ] ,
3105+ } ;
3106+
3107+ const manager = new AccountManager ( undefined , stored as never ) ;
3108+ const active = manager . getCurrentAccountForFamily ( "codex" ) ;
3109+ expect ( active ?. refreshToken ) . toBe ( "token-2" ) ;
3110+
3111+ // Remove the currently active (middle) account. The successor
3112+ // (token-3) shifts into index 1 and becomes the new current.
3113+ manager . removeAccount ( active ! ) ;
3114+
3115+ const after = manager . getCurrentAccountForFamily ( "codex" ) ;
3116+ expect ( after ?. refreshToken ) . toBe ( "token-3" ) ;
3117+ // Successor must be explicitly stamped with the rotation
3118+ // reason so callers can tell the pool retargeted them rather
3119+ // than assuming they still hold the account they originally
3120+ // selected.
3121+ expect ( after ?. lastSwitchReason ) . toBe ( "rotation" ) ;
3122+
3123+ // The untouched account (token-1) retains its prior reason,
3124+ // proving we only stamp the successor the retarget landed on.
3125+ const untouched = manager . getAccountByIndex ( 0 ) ;
3126+ expect ( untouched ?. refreshToken ) . toBe ( "token-1" ) ;
3127+ expect ( untouched ?. lastSwitchReason ) . toBe ( "initial" ) ;
3128+ } ) ;
3129+
3130+ it ( "does not stamp a successor when every remaining account is disabled" , ( ) => {
3131+ const now = Date . now ( ) ;
3132+ const stored = {
3133+ version : 3 as const ,
3134+ activeIndex : 2 ,
3135+ activeIndexByFamily : { codex : 2 } ,
3136+ accounts : [
3137+ { refreshToken : "token-1" , addedAt : now , lastUsed : now , enabled : false , lastSwitchReason : "initial" as const } ,
3138+ { refreshToken : "token-2" , addedAt : now , lastUsed : now , enabled : false , lastSwitchReason : "initial" as const } ,
3139+ { refreshToken : "token-3" , addedAt : now , lastUsed : now , enabled : true , lastSwitchReason : "initial" as const } ,
3140+ ] ,
3141+ } ;
3142+
3143+ const manager = new AccountManager ( undefined , stored as never ) ;
3144+ const active = manager . getCurrentAccountForFamily ( "codex" ) ;
3145+ expect ( active ?. refreshToken ) . toBe ( "token-3" ) ;
3146+
3147+ // Remove the last enabled account; remaining pool is entirely
3148+ // disabled. Pointer must fall to -1 (no routable account).
3149+ manager . removeAccount ( active ! ) ;
3150+
3151+ expect ( manager . getCurrentAccountForFamily ( "codex" ) ) . toBeNull ( ) ;
3152+ expect ( manager . getActiveIndexForFamily ( "codex" ) ) . toBe ( - 1 ) ;
3153+
3154+ // Neither surviving (disabled) account may be silently
3155+ // promoted by having rotation stamped on them. Their stored
3156+ // reason must stay "initial".
3157+ for ( let i = 0 ; i < manager . getAccountCount ( ) ; i ++ ) {
3158+ const acc = manager . getAccountByIndex ( i ) ;
3159+ expect ( acc ?. lastSwitchReason ) . toBe ( "initial" ) ;
3160+ }
3161+ } ) ;
3162+
3163+ it ( "stamps the sole enabled successor when all other peers are disabled" , ( ) => {
3164+ const now = Date . now ( ) ;
3165+ const stored = {
3166+ version : 3 as const ,
3167+ activeIndex : 0 ,
3168+ activeIndexByFamily : { codex : 0 } ,
3169+ accounts : [
3170+ { refreshToken : "token-1" , addedAt : now , lastUsed : now , enabled : true , lastSwitchReason : "initial" as const } ,
3171+ { refreshToken : "token-2" , addedAt : now , lastUsed : now , enabled : false , lastSwitchReason : "initial" as const } ,
3172+ { refreshToken : "token-3" , addedAt : now , lastUsed : now , enabled : true , lastSwitchReason : "initial" as const } ,
3173+ ] ,
3174+ } ;
3175+
3176+ const manager = new AccountManager ( undefined , stored as never ) ;
3177+ const active = manager . getCurrentAccountForFamily ( "codex" ) ;
3178+ expect ( active ?. refreshToken ) . toBe ( "token-1" ) ;
3179+
3180+ // Remove the currently active account (token-1). token-2 is
3181+ // disabled, so findNextEnabled must skip it and land on
3182+ // token-3 (the single remaining enabled peer).
3183+ manager . removeAccount ( active ! ) ;
3184+
3185+ const after = manager . getCurrentAccountForFamily ( "codex" ) ;
3186+ expect ( after ?. refreshToken ) . toBe ( "token-3" ) ;
3187+ expect ( after ?. lastSwitchReason ) . toBe ( "rotation" ) ;
3188+
3189+ // The disabled peer that was skipped during retarget must
3190+ // keep its original reason — we only stamp the account the
3191+ // pointer actually lands on.
3192+ const disabledPeer = manager . getAccountByIndex ( 0 ) ;
3193+ expect ( disabledPeer ?. refreshToken ) . toBe ( "token-2" ) ;
3194+ expect ( disabledPeer ?. lastSwitchReason ) . toBe ( "initial" ) ;
3195+ } ) ;
3196+ } ) ;
30813197 describe ( "flushPendingSave" , ( ) => {
30823198 it ( "flushes pending debounced save" , async ( ) => {
30833199 const { saveAccounts } = await import ( "../lib/storage.js" ) ;
0 commit comments