From 65dd19cf87718e6ee1beb03c833bebad2daf96eb Mon Sep 17 00:00:00 2001 From: gnanam1990 Date: Tue, 7 Apr 2026 09:50:10 +0530 Subject: [PATCH] fix: preserve explicit startup provider selection --- .gitignore | 1 + src/screens/REPL.tsx | 33 +++++++----- src/screens/replStartupGates.test.ts | 44 +++++++++++++++ src/screens/replStartupGates.ts | 11 ++++ src/utils/managedEnv.ts | 5 +- src/utils/providerEnvSelection.test.ts | 74 ++++++++++++++++++++++++++ src/utils/providerEnvSelection.ts | 68 +++++++++++++++++++++++ src/utils/providerFlag.test.ts | 35 ++++++++++++ src/utils/providerFlag.ts | 8 +++ 9 files changed, 265 insertions(+), 14 deletions(-) create mode 100644 src/screens/replStartupGates.test.ts create mode 100644 src/screens/replStartupGates.ts create mode 100644 src/utils/providerEnvSelection.test.ts create mode 100644 src/utils/providerEnvSelection.ts diff --git a/.gitignore b/.gitignore index 2d046b19..fa958413 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ GEMINI.md package-lock.json /.claude coverage/ +.worktrees/ diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 1ee59943..444677dd 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -238,6 +238,7 @@ import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInCh import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js'; import type { Theme } from 'src/utils/theme.js'; import { isPromptTypingSuppressionActive } from './replInputSuppression.js'; +import { shouldStartStartupChecks } from './replStartupGates.js'; import { checkAndDisableBypassPermissionsIfNeeded, checkAndDisableAutoModeIfNeeded, useKickOffCheckAndDisableBypassPermissionsIfNeeded, useKickOffCheckAndDisableAutoModeIfNeeded } from 'src/utils/permissions/bypassPermissionsKillswitch.js'; import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'; import { SANDBOX_NETWORK_ACCESS_TOOL_NAME } from 'src/cli/structuredIO.js'; @@ -784,19 +785,6 @@ export function REPL({ }); const tasksV2 = useTasksV2WithCollapseEffect(); - // Start background plugin installations - - // SECURITY: This code is guaranteed to run ONLY after the "trust this folder" dialog - // has been confirmed by the user. The trust dialog is shown in cli.tsx (line ~387) - // before the REPL component is rendered. The dialog blocks execution until the user - // accepts, and only then is the REPL component mounted and this effect runs. - // This ensures that plugin installations from repository and user settings only - // happen after explicit user consent to trust the current working directory. - useEffect(() => { - if (isRemoteSession) return; - void performStartupChecks(setAppState); - }, [setAppState, isRemoteSession]); - // Allow Claude in Chrome MCP to send prompts through MCP notifications // and sync permission mode changes to the Chrome extension usePromptsFromClaudeInChrome(isRemoteSession ? EMPTY_MCP_CLIENTS : mcpClients, toolPermissionContext.mode); @@ -1337,6 +1325,7 @@ export function REPL({ const [inputValue, setInputValueRaw] = useState(() => consumeEarlyInput()); const inputValueRef = useRef(inputValue); inputValueRef.current = inputValue; + const startupChecksStartedRef = useRef(false); const promptTypingSuppressionActive = isPromptTypingSuppressionActive(isPromptInputActive, inputValue); const insertTextRef = useRef<{ insert: (text: string) => void; @@ -1344,6 +1333,24 @@ export function REPL({ cursorOffset: number; } | null>(null); + // Start background plugin installations after the initial input window is idle. + // SECURITY: This still runs only after the "trust this folder" dialog has been + // confirmed because the REPL is not mounted until that dialog completes. + useEffect(() => { + if ( + !shouldStartStartupChecks({ + isRemoteSession, + promptTypingSuppressionActive, + startupChecksStarted: startupChecksStartedRef.current, + }) + ) { + return; + } + + startupChecksStartedRef.current = true; + void performStartupChecks(setAppState); + }, [isRemoteSession, promptTypingSuppressionActive, setAppState]); + // Wrap setInputValue to co-locate suppression state updates. // Both setState calls happen in the same synchronous context so React // batches them into a single render, eliminating the extra render that diff --git a/src/screens/replStartupGates.test.ts b/src/screens/replStartupGates.test.ts new file mode 100644 index 00000000..03fb4647 --- /dev/null +++ b/src/screens/replStartupGates.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'bun:test' +import { shouldStartStartupChecks } from './replStartupGates.js' + +describe('shouldStartStartupChecks', () => { + test('returns false for remote sessions', () => { + expect( + shouldStartStartupChecks({ + isRemoteSession: true, + promptTypingSuppressionActive: false, + startupChecksStarted: false, + }), + ).toBe(false) + }) + + test('returns false while prompt typing suppression is active', () => { + expect( + shouldStartStartupChecks({ + isRemoteSession: false, + promptTypingSuppressionActive: true, + startupChecksStarted: false, + }), + ).toBe(false) + }) + + test('returns true once local startup is idle and checks have not started', () => { + expect( + shouldStartStartupChecks({ + isRemoteSession: false, + promptTypingSuppressionActive: false, + startupChecksStarted: false, + }), + ).toBe(true) + }) + + test('returns false after startup checks have already started', () => { + expect( + shouldStartStartupChecks({ + isRemoteSession: false, + promptTypingSuppressionActive: false, + startupChecksStarted: true, + }), + ).toBe(false) + }) +}) diff --git a/src/screens/replStartupGates.ts b/src/screens/replStartupGates.ts new file mode 100644 index 00000000..90cb8b98 --- /dev/null +++ b/src/screens/replStartupGates.ts @@ -0,0 +1,11 @@ +export function shouldStartStartupChecks(options: { + isRemoteSession: boolean + promptTypingSuppressionActive: boolean + startupChecksStarted: boolean +}): boolean { + return ( + !options.isRemoteSession && + !options.promptTypingSuppressionActive && + !options.startupChecksStarted + ) +} diff --git a/src/utils/managedEnv.ts b/src/utils/managedEnv.ts index 0ed32a3d..03dc6731 100644 --- a/src/utils/managedEnv.ts +++ b/src/utils/managedEnv.ts @@ -8,6 +8,7 @@ import { } from './managedEnvConstants.js' import { clearMTLSCache } from './mtls.js' import { clearProxyCache, configureGlobalAgents } from './proxy.js' +import { filterSettingsEnvForExplicitProvider } from './providerEnvSelection.js' import { applyActiveProviderProfileFromConfig } from './providerProfiles.js' import { isSettingSourceEnabled } from './settings/constants.js' import { @@ -87,7 +88,9 @@ function filterSettingsEnv( env: Record | undefined, ): Record { return withoutCcdSpawnEnvKeys( - withoutHostManagedProviderVars(withoutSSHTunnelVars(env)), + filterSettingsEnvForExplicitProvider( + withoutHostManagedProviderVars(withoutSSHTunnelVars(env)), + ), ) } diff --git a/src/utils/providerEnvSelection.test.ts b/src/utils/providerEnvSelection.test.ts new file mode 100644 index 00000000..46ee7670 --- /dev/null +++ b/src/utils/providerEnvSelection.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { filterSettingsEnvForExplicitProvider } from './providerEnvSelection.js' + +const originalEnv = { ...process.env } + +const RESET_KEYS = [ + 'CLAUDE_CODE_EXPLICIT_PROVIDER', + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_GITHUB', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', +] as const + +beforeEach(() => { + for (const key of RESET_KEYS) { + delete process.env[key] + } +}) + +afterEach(() => { + for (const key of RESET_KEYS) { + if (originalEnv[key] === undefined) delete process.env[key] + else process.env[key] = originalEnv[key] + } +}) + +describe('filterSettingsEnvForExplicitProvider', () => { + test('strips settings-sourced provider flags when CLI provider is explicit', () => { + process.env.CLAUDE_CODE_EXPLICIT_PROVIDER = 'openai' + + expect( + filterSettingsEnvForExplicitProvider({ + CLAUDE_CODE_USE_GITHUB: '1', + CLAUDE_CODE_USE_OPENAI: '1', + OTHER: 'keep-me', + }), + ).toEqual({ OTHER: 'keep-me' }) + }) + + test('strips a stale GitHub model when explicit provider is not github', () => { + process.env.CLAUDE_CODE_EXPLICIT_PROVIDER = 'openai' + + expect( + filterSettingsEnvForExplicitProvider({ + OPENAI_MODEL: 'github:copilot', + OTHER: 'keep-me', + }), + ).toEqual({ OTHER: 'keep-me' }) + }) + + test('keeps a normal OpenAI model when explicit provider is openai', () => { + process.env.CLAUDE_CODE_EXPLICIT_PROVIDER = 'openai' + + expect( + filterSettingsEnvForExplicitProvider({ + OPENAI_MODEL: 'gpt-4o', + OTHER: 'keep-me', + }), + ).toEqual({ OPENAI_MODEL: 'gpt-4o', OTHER: 'keep-me' }) + }) + + test('strips a non-GitHub OpenAI model when explicit provider is github', () => { + process.env.CLAUDE_CODE_EXPLICIT_PROVIDER = 'github' + + expect( + filterSettingsEnvForExplicitProvider({ + OPENAI_MODEL: 'gpt-4o', + OTHER: 'keep-me', + }), + ).toEqual({ OTHER: 'keep-me' }) + }) +}) diff --git a/src/utils/providerEnvSelection.ts b/src/utils/providerEnvSelection.ts new file mode 100644 index 00000000..6bcd79a5 --- /dev/null +++ b/src/utils/providerEnvSelection.ts @@ -0,0 +1,68 @@ +import { isEnvTruthy } from './envUtils.js' + +export const EXPLICIT_PROVIDER_ENV_VAR = 'CLAUDE_CODE_EXPLICIT_PROVIDER' + +const PROVIDER_FLAG_KEYS = [ + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_GITHUB', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', +] as const + +export function clearProviderSelectionFlags( + env: NodeJS.ProcessEnv = process.env, +): void { + for (const key of PROVIDER_FLAG_KEYS) { + delete env[key] + } +} + +function getExplicitProvider(processEnv: NodeJS.ProcessEnv): string | undefined { + const explicitProvider = processEnv[EXPLICIT_PROVIDER_ENV_VAR]?.trim() + if (explicitProvider) return explicitProvider + + if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI)) return 'gemini' + if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) return 'github' + if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI)) return 'openai' + if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_BEDROCK)) return 'bedrock' + if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_VERTEX)) return 'vertex' + if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_FOUNDRY)) return 'foundry' + + return undefined +} + +function isGithubModel(model: string | undefined): boolean { + return (model ?? '').trim().toLowerCase().startsWith('github:') +} + +export function filterSettingsEnvForExplicitProvider( + env: Record | undefined, + processEnv: NodeJS.ProcessEnv = process.env, +): Record { + if (!env) return {} + + const explicitProvider = getExplicitProvider(processEnv) + if (!explicitProvider) { + return env + } + + const filtered = { ...env } + for (const key of PROVIDER_FLAG_KEYS) { + delete filtered[key] + } + + if (explicitProvider === 'github') { + if (!isGithubModel(filtered.OPENAI_MODEL)) { + delete filtered.OPENAI_MODEL + } + return filtered + } + + if (isGithubModel(filtered.OPENAI_MODEL)) { + delete filtered.OPENAI_MODEL + } + + return filtered +} diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index db38fbc2..b45af273 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -9,11 +9,13 @@ import { const originalEnv = { ...process.env } const RESET_KEYS = [ + 'CLAUDE_CODE_EXPLICIT_PROVIDER', 'CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_USE_GEMINI', 'CLAUDE_CODE_USE_GITHUB', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', 'OPENAI_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_MODEL', @@ -83,6 +85,16 @@ describe('applyProviderFlag - openai', () => { applyProviderFlag('openai', ['--model', 'gpt-4o']) expect(process.env.OPENAI_MODEL).toBe('gpt-4o') }) + + test('clears a previously persisted GitHub flag', () => { + process.env.CLAUDE_CODE_USE_GITHUB = '1' + + const result = applyProviderFlag('openai', []) + + expect(result.error).toBeUndefined() + expect(process.env.CLAUDE_CODE_USE_GITHUB).toBeUndefined() + expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') + }) }) describe('applyProviderFlag - gemini', () => { @@ -104,6 +116,16 @@ describe('applyProviderFlag - github', () => { expect(result.error).toBeUndefined() expect(process.env.CLAUDE_CODE_USE_GITHUB).toBe('1') }) + + test('clears a previously set OpenAI flag', () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + + const result = applyProviderFlag('github', []) + + expect(result.error).toBeUndefined() + expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined() + expect(process.env.CLAUDE_CODE_USE_GITHUB).toBe('1') + }) }) describe('applyProviderFlag - bedrock', () => { @@ -151,6 +173,19 @@ describe('applyProviderFlag - invalid provider', () => { }) }) +describe('applyProviderFlag - anthropic', () => { + test('clears third-party provider flags', () => { + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + + const result = applyProviderFlag('anthropic', []) + + expect(result.error).toBeUndefined() + expect(process.env.CLAUDE_CODE_USE_GITHUB).toBeUndefined() + expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined() + }) +}) + describe('applyProviderFlagFromArgs', () => { test('applies ollama provider and model from argv in one step', () => { const result = applyProviderFlagFromArgs([ diff --git a/src/utils/providerFlag.ts b/src/utils/providerFlag.ts index b2cbc06f..2bb206b9 100644 --- a/src/utils/providerFlag.ts +++ b/src/utils/providerFlag.ts @@ -1,3 +1,8 @@ +import { + clearProviderSelectionFlags, + EXPLICIT_PROVIDER_ENV_VAR, +} from './providerEnvSelection.js' + /** * --provider CLI flag support. * @@ -77,6 +82,9 @@ export function applyProviderFlag( } } + clearProviderSelectionFlags() + process.env[EXPLICIT_PROVIDER_ENV_VAR] = provider + const model = parseModelFlag(args) switch (provider as ProviderFlagName) {