fix: avoid sync github credential reads in provider manager (#428)

* fix: avoid sync github credential reads in provider manager

* test: stabilize provider manager async credential test

* fix: avoid first-frame github provider false negative

---------

Co-authored-by: KRATOS <84986124+gnanam1990@users.noreply.github.com>
This commit is contained in:
Agent_J
2026-04-06 18:59:53 +05:30
committed by GitHub
parent 72e6a945fe
commit aff2bd87e4
5 changed files with 408 additions and 26 deletions

View File

@@ -0,0 +1,305 @@
import { PassThrough } from 'node:stream'
import { afterEach, expect, mock, test } from 'bun:test'
import React from 'react'
import stripAnsi from 'strip-ansi'
import { createRoot } from '../ink.js'
import { AppStateProvider } from '../state/AppState.js'
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
const ORIGINAL_ENV = {
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
GH_TOKEN: process.env.GH_TOKEN,
}
function extractLastFrame(output: string): string {
let lastFrame: string | null = null
let cursor = 0
while (cursor < output.length) {
const start = output.indexOf(SYNC_START, cursor)
if (start === -1) {
break
}
const contentStart = start + SYNC_START.length
const end = output.indexOf(SYNC_END, contentStart)
if (end === -1) {
break
}
const frame = output.slice(contentStart, end)
if (frame.trim().length > 0) {
lastFrame = frame
}
cursor = end + SYNC_END.length
}
return lastFrame ?? output
}
function createTestStreams(): {
stdout: PassThrough
stdin: PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
getOutput: () => string
} {
let output = ''
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
stdout.on('data', chunk => {
output += chunk.toString()
})
return {
stdout,
stdin,
getOutput: () => output,
}
}
async function waitForCondition(
predicate: () => boolean,
options?: { timeoutMs?: number; intervalMs?: number },
): Promise<void> {
const timeoutMs = options?.timeoutMs ?? 2000
const intervalMs = options?.intervalMs ?? 10
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) {
return
}
await Bun.sleep(intervalMs)
}
throw new Error('Timed out waiting for ProviderManager test condition')
}
function createDeferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>(r => {
resolve = r
})
return { promise, resolve }
}
function mockProviderProfilesModule(): void {
mock.module('../utils/providerProfiles.js', () => ({
addProviderProfile: () => null,
applyActiveProviderProfileFromConfig: () => {},
deleteProviderProfile: () => ({ removed: false, activeProfileId: null }),
getActiveProviderProfile: () => null,
getProviderPresetDefaults: () => ({
provider: 'openai',
name: 'Mock provider',
baseUrl: 'http://localhost:11434/v1',
model: 'mock-model',
apiKey: '',
}),
getProviderProfiles: () => [],
setActiveProviderProfile: () => null,
updateProviderProfile: () => null,
}))
}
function mockProviderManagerDependencies(
syncRead: () => string | undefined,
asyncRead: () => Promise<string | undefined>,
): void {
mockProviderProfilesModule()
mock.module('../utils/githubModelsCredentials.js', () => ({
clearGithubModelsToken: () => ({ success: true }),
GITHUB_MODELS_HYDRATED_ENV_MARKER: 'CLAUDE_CODE_GITHUB_TOKEN_HYDRATED',
hydrateGithubModelsTokenFromSecureStorage: () => {},
readGithubModelsToken: syncRead,
readGithubModelsTokenAsync: asyncRead,
}))
mock.module('../utils/settings/settings.js', () => ({
updateSettingsForSource: () => ({ error: null }),
}))
}
async function waitForFrameOutput(
getOutput: () => string,
predicate: (output: string) => boolean,
timeoutMs = 2500,
): Promise<string> {
let output = ''
await waitForCondition(() => {
output = stripAnsi(extractLastFrame(getOutput()))
return predicate(output)
}, { timeoutMs })
return output
}
async function mountProviderManager(
ProviderManager: React.ComponentType<{
mode: 'first-run' | 'manage'
onDone: () => void
}>,
): Promise<{
getOutput: () => string
dispose: () => Promise<void>
}> {
const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(
<AppStateProvider>
<ProviderManager
mode="manage"
onDone={() => {}}
/>
</AppStateProvider>,
)
return {
getOutput,
dispose: async () => {
root.unmount()
stdin.end()
stdout.end()
await Bun.sleep(0)
},
}
}
async function renderProviderManagerFrame(
ProviderManager: React.ComponentType<{
mode: 'first-run' | 'manage'
onDone: () => void
}>,
options?: {
waitForOutput?: (output: string) => boolean
timeoutMs?: number
},
): Promise<string> {
const mounted = await mountProviderManager(ProviderManager)
const output = await waitForFrameOutput(
mounted.getOutput,
frame => {
if (!options?.waitForOutput) {
return frame.includes('Provider manager')
}
return options.waitForOutput(frame)
},
options?.timeoutMs ?? 2500,
)
await mounted.dispose()
return output
}
afterEach(() => {
mock.restore()
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value === undefined) {
delete process.env[key as keyof typeof ORIGINAL_ENV]
} else {
process.env[key as keyof typeof ORIGINAL_ENV] = value
}
}
})
test('ProviderManager resolves GitHub virtual provider from async storage without sync reads in render flow', async () => {
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const syncRead = mock(() => {
throw new Error('sync credential read should not run in ProviderManager render flow')
})
const asyncRead = mock(async () => 'stored-token')
mockProviderManagerDependencies(syncRead, asyncRead)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const output = await renderProviderManagerFrame(ProviderManager, {
waitForOutput: frame =>
frame.includes('Provider manager') &&
frame.includes('GitHub Models') &&
frame.includes('token stored'),
})
expect(output).toContain('Provider manager')
expect(output).toContain('GitHub Models')
expect(output).toContain('token stored')
expect(output).not.toContain('No provider profiles configured yet.')
expect(syncRead).not.toHaveBeenCalled()
expect(asyncRead).toHaveBeenCalled()
})
test('ProviderManager avoids first-frame false negative while stored-token lookup is pending', async () => {
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const syncRead = mock(() => {
throw new Error('sync credential read should not run in ProviderManager render flow')
})
const deferredStoredToken = createDeferred<string | undefined>()
const asyncRead = mock(async () => deferredStoredToken.promise)
mockProviderManagerDependencies(syncRead, asyncRead)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager)
const firstFrame = await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Provider manager'),
)
expect(firstFrame).toContain('Checking GitHub Models credentials...')
expect(firstFrame).not.toContain('No provider profiles configured yet.')
deferredStoredToken.resolve('stored-token')
const resolvedFrame = await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('GitHub Models') && frame.includes('token stored'),
)
expect(resolvedFrame).toContain('GitHub Models')
expect(resolvedFrame).toContain('token stored')
await mounted.dispose()
expect(syncRead).not.toHaveBeenCalled()
expect(asyncRead).toHaveBeenCalled()
})

View File

@@ -20,6 +20,7 @@ import {
GITHUB_MODELS_HYDRATED_ENV_MARKER, GITHUB_MODELS_HYDRATED_ENV_MARKER,
hydrateGithubModelsTokenFromSecureStorage, hydrateGithubModelsTokenFromSecureStorage,
readGithubModelsToken, readGithubModelsToken,
readGithubModelsTokenAsync,
} from '../utils/githubModelsCredentials.js' } from '../utils/githubModelsCredentials.js'
import { isEnvTruthy } from '../utils/envUtils.js' import { isEnvTruthy } from '../utils/envUtils.js'
import { updateSettingsForSource } from '../utils/settings/settings.js' import { updateSettingsForSource } from '../utils/settings/settings.js'
@@ -118,25 +119,38 @@ function profileSummary(profile: ProviderProfile, isActive: boolean): string {
return `${providerKind} · ${profile.baseUrl} · ${profile.model} · ${keyInfo}${activeSuffix}` return `${providerKind} · ${profile.baseUrl} · ${profile.model} · ${keyInfo}${activeSuffix}`
} }
function getGithubCredentialSource( function getGithubCredentialSourceFromEnv(
processEnv: NodeJS.ProcessEnv = process.env, processEnv: NodeJS.ProcessEnv = process.env,
): GithubCredentialSource { ): GithubCredentialSource {
if (readGithubModelsToken()?.trim()) {
return 'stored'
}
if (processEnv.GITHUB_TOKEN?.trim() || processEnv.GH_TOKEN?.trim()) { if (processEnv.GITHUB_TOKEN?.trim() || processEnv.GH_TOKEN?.trim()) {
return 'env' return 'env'
} }
return 'none' return 'none'
} }
async function resolveGithubCredentialSource(
processEnv: NodeJS.ProcessEnv = process.env,
): Promise<GithubCredentialSource> {
const envSource = getGithubCredentialSourceFromEnv(processEnv)
if (envSource !== 'none') {
return envSource
}
if (await readGithubModelsTokenAsync()) {
return 'stored'
}
return 'none'
}
function isGithubProviderAvailable( function isGithubProviderAvailable(
credentialSource: GithubCredentialSource,
processEnv: NodeJS.ProcessEnv = process.env, processEnv: NodeJS.ProcessEnv = process.env,
): boolean { ): boolean {
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) { if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) {
return true return true
} }
return getGithubCredentialSource(processEnv) !== 'none' return credentialSource !== 'none'
} }
function getGithubProviderModel( function getGithubProviderModel(
@@ -164,19 +178,24 @@ function getGithubProviderSummary(
} }
export function ProviderManager({ mode, onDone }: Props): React.ReactNode { export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const initialGithubCredentialSource = getGithubCredentialSourceFromEnv()
const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const initialHasGithubCredential = initialGithubCredentialSource !== 'none'
const [profiles, setProfiles] = React.useState(() => getProviderProfiles()) const [profiles, setProfiles] = React.useState(() => getProviderProfiles())
const [activeProfileId, setActiveProfileId] = React.useState( const [activeProfileId, setActiveProfileId] = React.useState(
() => getActiveProviderProfile()?.id, () => getActiveProviderProfile()?.id,
) )
const [githubProviderAvailable, setGithubProviderAvailable] = React.useState(() => const [githubProviderAvailable, setGithubProviderAvailable] = React.useState(
isGithubProviderAvailable(), () => isGithubProviderAvailable(initialGithubCredentialSource),
) )
const [githubCredentialSource, setGithubCredentialSource] = React.useState<GithubCredentialSource>( const [githubCredentialSource, setGithubCredentialSource] = React.useState<GithubCredentialSource>(
() => getGithubCredentialSource(), () => initialGithubCredentialSource,
)
const [isGithubActive, setIsGithubActive] = React.useState(() =>
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB),
) )
const [isGithubActive, setIsGithubActive] = React.useState(() => initialIsGithubActive)
const [isGithubCredentialSourceResolved, setIsGithubCredentialSourceResolved] =
React.useState(() => initialHasGithubCredential || initialIsGithubActive)
const githubRefreshEpochRef = React.useRef(0)
const [screen, setScreen] = React.useState<Screen>( const [screen, setScreen] = React.useState<Screen>(
mode === 'first-run' ? 'select-preset' : 'menu', mode === 'first-run' ? 'select-preset' : 'menu',
) )
@@ -196,13 +215,48 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const currentStepKey = currentStep.key const currentStepKey = currentStep.key
const currentValue = draft[currentStepKey] const currentValue = draft[currentStepKey]
const refreshGithubProviderState = React.useCallback((): void => {
const envCredentialSource = getGithubCredentialSourceFromEnv()
const githubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const canResolveFromEnv = githubActive || envCredentialSource !== 'none'
if (canResolveFromEnv) {
githubRefreshEpochRef.current += 1
setGithubCredentialSource(envCredentialSource)
setGithubProviderAvailable(isGithubProviderAvailable(envCredentialSource))
setIsGithubActive(githubActive)
setIsGithubCredentialSourceResolved(true)
return
}
setIsGithubCredentialSourceResolved(false)
const refreshEpoch = ++githubRefreshEpochRef.current
void (async () => {
const credentialSource = await resolveGithubCredentialSource()
if (refreshEpoch !== githubRefreshEpochRef.current) {
return
}
setGithubCredentialSource(credentialSource)
setGithubProviderAvailable(isGithubProviderAvailable(credentialSource))
setIsGithubActive(isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB))
setIsGithubCredentialSourceResolved(true)
})()
}, [])
React.useEffect(() => {
refreshGithubProviderState()
return () => {
githubRefreshEpochRef.current += 1
}
}, [refreshGithubProviderState])
function refreshProfiles(): void { function refreshProfiles(): void {
const nextProfiles = getProviderProfiles() const nextProfiles = getProviderProfiles()
setProfiles(nextProfiles) setProfiles(nextProfiles)
setActiveProfileId(getActiveProviderProfile()?.id) setActiveProfileId(getActiveProviderProfile()?.id)
setGithubProviderAvailable(isGithubProviderAvailable()) refreshGithubProviderState()
setGithubCredentialSource(getGithubCredentialSource())
setIsGithubActive(isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB))
} }
function clearStartupProviderOverrideFromUserSettings(): string | null { function clearStartupProviderOverrideFromUserSettings(): string | null {
@@ -640,7 +694,11 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
{statusMessage && <Text>{statusMessage}</Text>} {statusMessage && <Text>{statusMessage}</Text>}
<Box flexDirection="column"> <Box flexDirection="column">
{profiles.length === 0 && !githubProviderAvailable ? ( {profiles.length === 0 && !githubProviderAvailable ? (
isGithubCredentialSourceResolved ? (
<Text dimColor>No provider profiles configured yet.</Text> <Text dimColor>No provider profiles configured yet.</Text>
) : (
<Text dimColor>Checking GitHub Models credentials...</Text>
)
) : ( ) : (
<> <>
{profiles.map(profile => ( {profiles.map(profile => (

View File

@@ -41,7 +41,7 @@ describe('hydrateGithubModelsTokenFromSecureStorage', () => {
})) }))
const { hydrateGithubModelsTokenFromSecureStorage } = await import( const { hydrateGithubModelsTokenFromSecureStorage } = await import(
'./githubModelsCredentials.js' './githubModelsCredentials.js?hydrate=sets-token'
) )
hydrateGithubModelsTokenFromSecureStorage() hydrateGithubModelsTokenFromSecureStorage()
expect(process.env.GITHUB_TOKEN).toBe('stored-secret') expect(process.env.GITHUB_TOKEN).toBe('stored-secret')
@@ -62,7 +62,7 @@ describe('hydrateGithubModelsTokenFromSecureStorage', () => {
})) }))
const { hydrateGithubModelsTokenFromSecureStorage } = await import( const { hydrateGithubModelsTokenFromSecureStorage } = await import(
'./githubModelsCredentials.js' './githubModelsCredentials.js?hydrate=preserve-existing'
) )
hydrateGithubModelsTokenFromSecureStorage() hydrateGithubModelsTokenFromSecureStorage()
expect(process.env.GITHUB_TOKEN).toBe('already') expect(process.env.GITHUB_TOKEN).toBe('already')

View File

@@ -1,13 +1,11 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import {
clearGithubModelsToken,
readGithubModelsToken,
saveGithubModelsToken,
} from './githubModelsCredentials.js'
describe('readGithubModelsToken', () => { describe('readGithubModelsToken', () => {
test('returns undefined in bare mode', () => { test('returns undefined in bare mode', async () => {
const { readGithubModelsToken } = await import(
'./githubModelsCredentials.js?read-bare-mode'
)
const prev = process.env.CLAUDE_CODE_SIMPLE const prev = process.env.CLAUDE_CODE_SIMPLE
process.env.CLAUDE_CODE_SIMPLE = '1' process.env.CLAUDE_CODE_SIMPLE = '1'
expect(readGithubModelsToken()).toBeUndefined() expect(readGithubModelsToken()).toBeUndefined()
@@ -20,7 +18,11 @@ describe('readGithubModelsToken', () => {
}) })
describe('saveGithubModelsToken / clearGithubModelsToken', () => { describe('saveGithubModelsToken / clearGithubModelsToken', () => {
test('save returns failure in bare mode', () => { test('save returns failure in bare mode', async () => {
const { saveGithubModelsToken } = await import(
'./githubModelsCredentials.js?save-bare-mode'
)
const prev = process.env.CLAUDE_CODE_SIMPLE const prev = process.env.CLAUDE_CODE_SIMPLE
process.env.CLAUDE_CODE_SIMPLE = '1' process.env.CLAUDE_CODE_SIMPLE = '1'
const r = saveGithubModelsToken('abc') const r = saveGithubModelsToken('abc')
@@ -33,7 +35,11 @@ describe('saveGithubModelsToken / clearGithubModelsToken', () => {
} }
}) })
test('clear succeeds in bare mode', () => { test('clear succeeds in bare mode', async () => {
const { clearGithubModelsToken } = await import(
'./githubModelsCredentials.js?clear-bare-mode'
)
const prev = process.env.CLAUDE_CODE_SIMPLE const prev = process.env.CLAUDE_CODE_SIMPLE
process.env.CLAUDE_CODE_SIMPLE = '1' process.env.CLAUDE_CODE_SIMPLE = '1'
expect(clearGithubModelsToken().success).toBe(true) expect(clearGithubModelsToken().success).toBe(true)

View File

@@ -23,6 +23,19 @@ export function readGithubModelsToken(): string | undefined {
} }
} }
export async function readGithubModelsTokenAsync(): Promise<string | undefined> {
if (isBareMode()) return undefined
try {
const data = (await getSecureStorage().readAsync()) as
| ({ githubModels?: GithubModelsCredentialBlob } & Record<string, unknown>)
| null
const t = data?.githubModels?.accessToken?.trim()
return t || undefined
} catch {
return undefined
}
}
/** /**
* If GitHub Models mode is on and no token is in the environment, copy the * If GitHub Models mode is on and no token is in the environment, copy the
* stored token into process.env so the OpenAI shim and validation see it. * stored token into process.env so the OpenAI shim and validation see it.