11import { mkdir , mkdtemp , readFile , rm } from "node:fs/promises" ;
22import { tmpdir } from "node:os" ;
33import { join } from "node:path" ;
4- import { describe , expect , test } from "bun:test" ;
4+ import { afterEach , describe , expect , test } from "bun:test" ;
55
66import {
77 saveGlobalSettings ,
@@ -11,10 +11,16 @@ import {
1111 type Settings ,
1212} from "../config/settings.js" ;
1313import {
14+ loadCodexProfile ,
15+ saveCodexProfile ,
1416 listXaiProfiles ,
1517 loadXaiProfile ,
1618 saveXaiProfile ,
1719} from "../config/oauth-stores.js" ;
20+ import {
21+ clearSourceCredentials ,
22+ registerSourceCredentialRecord ,
23+ } from "../config/source-credentials.js" ;
1824import { createGlobalSettingsWriter } from "../mcp/add-server.js" ;
1925import { withMockedHomedir } from "../../testkit/mock-module.js" ;
2026import { modelOptionId } from "./model-catalog.js" ;
@@ -31,6 +37,10 @@ const SEED_WATERMARKS = {
3137 lastChangelogVersion : typeof pkg . version === "string" ? pkg . version : "0.0.0" ,
3238} as const ;
3339
40+ afterEach ( ( ) => {
41+ clearSourceCredentials ( ) ;
42+ } ) ;
43+
3444function keeperEntry ( ) {
3545 return {
3646 baseURL : "https://keeper/v1" ,
@@ -48,6 +58,7 @@ interface RemoveHarness {
4858 readonly refreshed : unknown [ ] [ ] ;
4959 readonly opened : ( string | undefined ) [ ] ;
5060 readonly wiring : Awaited < ReturnType < typeof wireSettings > > ;
61+ readonly state : RunnerState ;
5162 readonly cleanup : ( ) => Promise < void > ;
5263}
5364
@@ -96,6 +107,11 @@ async function wireRemoveHarness(opts: {
96107 const services = {
97108 hookManager : { getStatuses : ( ) => [ ] } ,
98109 globalSettingsWriter : createGlobalSettingsWriter ( settingsPath ) ,
110+ permissionGate : { setProviderIdentity : ( ) => undefined } ,
111+ buildSessionSources : ( ) => ( { sources : [ ] , defaultSource : "" } ) ,
112+ directorHolder : { } ,
113+ computeAdvertised : ( ) => [ ] ,
114+ toolset : { dynamicRunner : { currentDefinitions : ( ) => [ ] } } ,
99115 } as unknown as RunnerServices ;
100116 const wiring = await wireSettings ( state , services ) ;
101117 return {
@@ -106,6 +122,7 @@ async function wireRemoveHarness(opts: {
106122 refreshed,
107123 opened,
108124 wiring,
125+ state,
109126 cleanup : ( ) => rm ( dir , { recursive : true , force : true } ) ,
110127 } ;
111128}
@@ -253,6 +270,11 @@ describe("provider removal execute path", () => {
253270 liveProvider : "keeper" ,
254271 } ) ;
255272 try {
273+ clearSourceCredentials ( ) ;
274+ registerSourceCredentialRecord ( "xai/work" , {
275+ provenance : { kind : "oauth" , provider : "xai" , profile : "work" } ,
276+ material : { secret : "token" } ,
277+ } ) ;
256278 await saveXaiProfile (
257279 { name : "work" , createdAt : 0 , tokens : PROFILE_TOKENS } ,
258280 harness . home ,
@@ -311,6 +333,11 @@ describe("provider removal execute path", () => {
311333 liveProvider : "keeper" ,
312334 } ) ;
313335 try {
336+ clearSourceCredentials ( ) ;
337+ registerSourceCredentialRecord ( "xai/work" , {
338+ provenance : { kind : "oauth" , provider : "xai" , profile : "work" } ,
339+ material : { secret : "token" } ,
340+ } ) ;
314341 await saveXaiProfile (
315342 { name : "work" , createdAt : 0 , tokens : PROFILE_TOKENS } ,
316343 harness . home ,
@@ -319,6 +346,11 @@ describe("provider removal execute path", () => {
319346 { name : "personal" , createdAt : 0 , tokens : PROFILE_TOKENS } ,
320347 harness . home ,
321348 ) ;
349+ expect (
350+ harness . wiring . describeRemoveProvider (
351+ modelOptionId ( "xai/work" , "grok-4" ) ,
352+ ) ,
353+ ) . toContain ( "auth profile 'work'" ) ;
322354 const before = await readFile ( harness . settingsPath , "utf8" ) ;
323355 await executeRemove ( harness , modelOptionId ( "xai/work" , "grok-4" ) ) ;
324356 // The orphaned credential is gone; siblings are untouched.
@@ -338,6 +370,81 @@ describe("provider removal execute path", () => {
338370 }
339371 } ) ;
340372
373+ test ( "custom namespaced providers leave unrelated OAuth profiles intact" , async ( ) => {
374+ for ( const provider of [ "xai/manual" , "codex/manual" ] as const ) {
375+ const seed : Settings = {
376+ ...SEED_WATERMARKS ,
377+ providers : {
378+ keeper : keeperEntry ( ) ,
379+ [ provider ] : {
380+ baseURL : "https://manual.example/v1" ,
381+ apiKey : "manual-key" ,
382+ models : [ "manual-model" ] ,
383+ defaultModel : "manual-model" ,
384+ } ,
385+ } ,
386+ } ;
387+ const harness = await wireRemoveHarness ( {
388+ seed,
389+ local : null ,
390+ liveProvider : "keeper" ,
391+ } ) ;
392+ try {
393+ clearSourceCredentials ( ) ;
394+ registerSourceCredentialRecord ( provider , {
395+ provenance : { kind : "api-key" } ,
396+ material : { secret : "manual-key" } ,
397+ } ) ;
398+ if ( provider . startsWith ( "xai/" ) ) {
399+ await saveXaiProfile (
400+ { name : "manual" , createdAt : 0 , tokens : PROFILE_TOKENS } ,
401+ harness . home ,
402+ ) ;
403+ } else {
404+ await saveCodexProfile (
405+ { name : "manual" , createdAt : 0 , tokens : PROFILE_TOKENS } ,
406+ harness . home ,
407+ ) ;
408+ }
409+ await executeRemove ( harness , modelOptionId ( provider , "manual-model" ) ) ;
410+ const profile = provider . startsWith ( "xai/" )
411+ ? await loadXaiProfile ( "manual" , harness . home )
412+ : await loadCodexProfile ( "manual" , harness . home ) ;
413+ expect ( profile ) . toBeDefined ( ) ;
414+ } finally {
415+ await harness . cleanup ( ) ;
416+ }
417+ }
418+ } ) ;
419+
420+ test ( "cannot switch to a provider while its removal is pending" , async ( ) => {
421+ const seed : Settings = {
422+ ...SEED_WATERMARKS ,
423+ providers : {
424+ keeper : keeperEntry ( ) ,
425+ victim : {
426+ baseURL : "https://victim/v1" ,
427+ apiKey : "victim-key" ,
428+ models : [ "v1" ] ,
429+ defaultModel : "v1" ,
430+ } ,
431+ } ,
432+ } ;
433+ const harness = await wireRemoveHarness ( {
434+ seed,
435+ local : null ,
436+ liveProvider : "keeper" ,
437+ } ) ;
438+ try {
439+ const victim = modelOptionId ( "victim" , "v1" ) ;
440+ harness . wiring . onRemoveProvider ( victim ) ;
441+ harness . wiring . onModelSelect ( victim ) ;
442+ expect ( harness . state . config . providerName ) . toBe ( "keeper" ) ;
443+ } finally {
444+ await harness . cleanup ( ) ;
445+ }
446+ } ) ;
447+
341448 test ( "removing the default repoints it and says where it went" , async ( ) => {
342449 const seed : Settings = {
343450 ...SEED_WATERMARKS ,
@@ -438,6 +545,11 @@ describe("describeRemoveProvider", () => {
438545 } ;
439546 const harness = await wireLines ( seed ) ;
440547 try {
548+ clearSourceCredentials ( ) ;
549+ registerSourceCredentialRecord ( "xai/work" , {
550+ provenance : { kind : "oauth" , provider : "xai" , profile : "work" } ,
551+ material : { secret : "token" } ,
552+ } ) ;
441553 const keyLine = harness . wiring . describeRemoveProvider (
442554 modelOptionId ( "openai/work" , "m1" ) ,
443555 ) ;
0 commit comments