fix(provider): saved profile ignored when stale CLAUDE_CODE_USE_* in shell (#807)
* fix(provider): saved profile ignored when stale CLAUDE_CODE_USE_* in shell Users reported "my saved /provider profile isn't picked up at startup — the banner shows gpt-4o / api.openai.com even though I saved Moonshot". Root cause: applyActiveProviderProfileFromConfig() bailed out whenever hasProviderSelectionFlags(processEnv) was true — i.e. whenever ANY CLAUDE_CODE_USE_* flag was present. But a bare `CLAUDE_CODE_USE_OPENAI=1` with no paired OPENAI_BASE_URL / OPENAI_MODEL is almost always a stale shell export left over from a prior manual setup, not genuine startup intent. Respecting it skipped the saved profile and let StartupScreen.ts fall through to the hardcoded `gpt-4o` / `https://api.openai.com/v1` defaults — the exact symptom users see. Fix: narrow the guard from "any flag set" to "flag set AND at least one concrete config value (BASE_URL, MODEL, or API_KEY)". A bare stale flag no longer blocks the saved profile. A real shell selection (flag + URL or flag + model) still wins, preserving the "explicit startup intent overrides saved profile" contract. New helper: hasCompleteProviderSelection(env). Per-provider check for a paired concrete value. Bedrock/Vertex/Foundry keep the flag-alone semantic since they rely on ambient AWS/GCP credentials rather than env config. Three new tests cover the bug and the two counter-cases: - bare USE flag → profile applies (fixes the bug) - USE flag + BASE_URL → profile blocked (preserves explicit intent) - USE flag + MODEL → profile blocked (preserves explicit intent) Co-Authored-By: OpenClaude <openclaude@gitlawb.com> * fix(provider): don't overlay stale legacy profile on plural-managed env Second half of the "saved profile not picked up in banner" bug. The prior commit fixed the guard that prevented applyActiveProviderProfileFromConfig() from firing when a stale CLAUDE_CODE_USE_* flag was in the shell. But even when the plural system applies correctly, buildStartupEnvFromProfile() was then loading the legacy .openclaude-profile.json AND overwriting the plural-managed env with whatever that file contained. addProviderProfile() (the call path the /provider preset picker uses) does NOT sync the legacy file, so a user who went: manual setup: CLAUDE_CODE_USE_OPENAI=1 + OPENAI_MODEL=gpt-4o → writes .openclaude-profile.json as { openai, gpt-4o, ... } /provider: add Moonshot preset, mark active → writes plural config; legacy file UNCHANGED would see startup reliably apply Moonshot env first, then get it clobbered by the stale legacy file. Banner shows gpt-4o / api.openai.com while runtime ends up with the correct env via a different code path — exactly the user-reported symptom. Fix: in buildStartupEnvFromProfile, when the plural system has already set env (CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1'), skip the legacy-file overlay entirely and return processEnv unchanged. Legacy is now strictly a first-run / fallback path for users who haven't adopted the plural system. Also removes the stripped-then-rebuilt env construction that was part of the old overlay path — no longer needed. Test updates: - Replaced "lets saved startup profile override profile-managed env" (encoded the old broken behavior) with a regression test that pins the new semantic: plural env survives when legacy is stale. - Added "falls back to legacy when plural hasn't applied" to pin the first-run path still works. Co-Authored-By: OpenClaude <openclaude@gitlawb.com> --------- Co-authored-by: OpenClaude <openclaude@gitlawb.com>
This commit is contained in:
@@ -572,31 +572,64 @@ test('buildStartupEnvFromProfile leaves explicit provider selections untouched',
|
|||||||
assert.equal(env.OPENAI_API_KEY, undefined)
|
assert.equal(env.OPENAI_API_KEY, undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('buildStartupEnvFromProfile lets saved startup profile override profile-managed env', async () => {
|
test('buildStartupEnvFromProfile preserves plural-profile env when the legacy file is stale', async () => {
|
||||||
|
// Regression: a user saves a provider via /provider (plural system).
|
||||||
|
// addProviderProfile does NOT sync the legacy .openclaude-profile.json,
|
||||||
|
// so the legacy file retains whatever it had from an earlier setup (e.g.
|
||||||
|
// OpenAI defaults). At startup, applyActiveProviderProfileFromConfig()
|
||||||
|
// correctly applies the active plural profile (Moonshot) first, marking
|
||||||
|
// env with CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED=1. The legacy-file
|
||||||
|
// load must NOT overwrite that env — it previously did, surfacing as
|
||||||
|
// "banner shows the wrong provider / model".
|
||||||
const processEnv = {
|
const processEnv = {
|
||||||
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: '1',
|
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: '1',
|
||||||
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: 'saved_ollama',
|
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: 'saved_moonshot',
|
||||||
CLAUDE_CODE_USE_OPENAI: '1',
|
CLAUDE_CODE_USE_OPENAI: '1',
|
||||||
OPENAI_BASE_URL: 'http://localhost:11434/v1',
|
OPENAI_BASE_URL: 'https://api.moonshot.ai/v1',
|
||||||
OPENAI_MODEL: 'llama3.1:8b',
|
OPENAI_MODEL: 'kimi-k2.6',
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = await buildStartupEnvFromProfile({
|
const env = await buildStartupEnvFromProfile({
|
||||||
|
// Stale legacy file — points at SambaNova, but user's active plural
|
||||||
|
// profile is Moonshot and was just applied.
|
||||||
persisted: profile('openai', {
|
persisted: profile('openai', {
|
||||||
OPENAI_API_KEY: 'sk-persisted',
|
OPENAI_API_KEY: 'sk-stale',
|
||||||
OPENAI_MODEL: 'Meta-Llama-3.1-70B-Instruct',
|
OPENAI_MODEL: 'Meta-Llama-3.1-70B-Instruct',
|
||||||
OPENAI_BASE_URL: 'https://api.sambanova.ai/v1',
|
OPENAI_BASE_URL: 'https://api.sambanova.ai/v1',
|
||||||
}),
|
}),
|
||||||
processEnv,
|
processEnv,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
assert.equal(env, processEnv)
|
||||||
|
assert.equal(env.OPENAI_BASE_URL, 'https://api.moonshot.ai/v1')
|
||||||
|
assert.equal(env.OPENAI_MODEL, 'kimi-k2.6')
|
||||||
|
// Plural markers are retained — downstream code uses them to verify the
|
||||||
|
// env still belongs to the profile it was applied from.
|
||||||
|
assert.equal(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED, '1')
|
||||||
|
assert.equal(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID, 'saved_moonshot')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildStartupEnvFromProfile falls back to legacy file when plural system has not applied', async () => {
|
||||||
|
// Counter-example: first-run user with only the legacy file (no plural
|
||||||
|
// active profile yet). The legacy file is the correct source, so the
|
||||||
|
// load must proceed as before.
|
||||||
|
const processEnv = {
|
||||||
|
CLAUDE_CODE_USE_OPENAI: '1',
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = await buildStartupEnvFromProfile({
|
||||||
|
persisted: profile('openai', {
|
||||||
|
OPENAI_API_KEY: 'sk-legacy',
|
||||||
|
OPENAI_MODEL: 'gpt-4o',
|
||||||
|
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
}),
|
||||||
|
processEnv,
|
||||||
|
})
|
||||||
|
|
||||||
assert.notEqual(env, processEnv)
|
assert.notEqual(env, processEnv)
|
||||||
assert.equal(env.CLAUDE_CODE_USE_OPENAI, '1')
|
assert.equal(env.OPENAI_API_KEY, 'sk-legacy')
|
||||||
assert.equal(env.OPENAI_API_KEY, 'sk-persisted')
|
assert.equal(env.OPENAI_BASE_URL, 'https://api.openai.com/v1')
|
||||||
assert.equal(env.OPENAI_MODEL, 'Meta-Llama-3.1-70B-Instruct')
|
assert.equal(env.OPENAI_MODEL, 'gpt-4o')
|
||||||
assert.equal(env.OPENAI_BASE_URL, 'https://api.sambanova.ai/v1')
|
|
||||||
assert.equal(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED, undefined)
|
|
||||||
assert.equal(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID, undefined)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('buildStartupEnvFromProfile treats explicit falsey provider flags as user intent', async () => {
|
test('buildStartupEnvFromProfile treats explicit falsey provider flags as user intent', async () => {
|
||||||
|
|||||||
@@ -841,43 +841,35 @@ export async function buildStartupEnvFromProfile(options?: {
|
|||||||
const processEnv = options?.processEnv ?? process.env
|
const processEnv = options?.processEnv ?? process.env
|
||||||
const persisted = options?.persisted ?? loadProfileFile()
|
const persisted = options?.persisted ?? loadProfileFile()
|
||||||
|
|
||||||
// Saved /provider profiles should still win over provider-manager env that was
|
|
||||||
// auto-applied during startup. Only an explicit shell/flag provider selection
|
|
||||||
// should bypass the persisted startup profile.
|
|
||||||
//
|
|
||||||
const profileManagedEnv = processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1'
|
const profileManagedEnv = processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1'
|
||||||
|
|
||||||
// If the user explicitly selected a provider via env, allow it to bypass
|
// The legacy single-profile file (~/.openclaude-profile.json) is a
|
||||||
// the persisted profile only when we can prove it was managed by the
|
// first-run / fallback mechanism. The newer plural provider-profile
|
||||||
// persisted profile env itself.
|
// system (`/provider` presets + activeProviderProfileId in config) is
|
||||||
|
// applied earlier in the bootstrap via applyActiveProviderProfileFromConfig
|
||||||
|
// and signals completion with CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED=1.
|
||||||
//
|
//
|
||||||
// Practically: on initial startup, provider routing env vars can already
|
// If the plural system has already set env, trust it — do NOT overlay the
|
||||||
// be present due to earlier auto-application steps. We should still apply
|
// legacy file. addProviderProfile() does not sync the legacy file, so a
|
||||||
// the persisted profile rather than returning early.
|
// stale legacy file (e.g. OpenAI defaults from an earlier manual setup)
|
||||||
|
// would otherwise overwrite the correct plural env and surface as the
|
||||||
|
// "banner shows gpt-4o / api.openai.com even though my saved profile is
|
||||||
|
// Moonshot" bug.
|
||||||
|
if (profileManagedEnv) {
|
||||||
|
return processEnv
|
||||||
|
}
|
||||||
|
|
||||||
if (!persisted) {
|
if (!persisted) {
|
||||||
return processEnv
|
return processEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
const launchProcessEnv = profileManagedEnv
|
|
||||||
? (() => {
|
|
||||||
const cleanedEnv = { ...processEnv }
|
|
||||||
for (const key of PROFILE_ENV_KEYS) {
|
|
||||||
delete cleanedEnv[key]
|
|
||||||
}
|
|
||||||
delete cleanedEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
|
|
||||||
delete cleanedEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
|
|
||||||
return cleanedEnv
|
|
||||||
})()
|
|
||||||
: processEnv
|
|
||||||
|
|
||||||
return buildLaunchEnv({
|
return buildLaunchEnv({
|
||||||
profile: persisted.profile,
|
profile: persisted.profile,
|
||||||
persisted,
|
persisted,
|
||||||
goal:
|
goal:
|
||||||
options?.goal ??
|
options?.goal ??
|
||||||
normalizeRecommendationGoal(processEnv.OPENCLAUDE_PROFILE_GOAL),
|
normalizeRecommendationGoal(processEnv.OPENCLAUDE_PROFILE_GOAL),
|
||||||
processEnv: launchProcessEnv,
|
processEnv,
|
||||||
getOllamaChatBaseUrl:
|
getOllamaChatBaseUrl:
|
||||||
options?.getOllamaChatBaseUrl ?? getOllamaChatBaseUrl,
|
options?.getOllamaChatBaseUrl ?? getOllamaChatBaseUrl,
|
||||||
resolveOllamaDefaultModel: options?.resolveOllamaDefaultModel,
|
resolveOllamaDefaultModel: options?.resolveOllamaDefaultModel,
|
||||||
|
|||||||
@@ -256,6 +256,83 @@ describe('applyActiveProviderProfileFromConfig', () => {
|
|||||||
expect(process.env.OPENAI_MODEL).toBe('qwen2.5:3b')
|
expect(process.env.OPENAI_MODEL).toBe('qwen2.5:3b')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('applies active profile when a bare CLAUDE_CODE_USE_OPENAI flag is stale (no BASE_URL/MODEL)', async () => {
|
||||||
|
// Regression: a leftover `CLAUDE_CODE_USE_OPENAI=1` in the shell with no
|
||||||
|
// paired OPENAI_BASE_URL / OPENAI_MODEL is not a real explicit selection
|
||||||
|
// — it's a stale export. The previous guard treated it as intent and
|
||||||
|
// skipped the saved profile, causing the startup banner to show hardcoded
|
||||||
|
// defaults (gpt-4o @ api.openai.com) instead of the user's active
|
||||||
|
// profile.
|
||||||
|
const { applyActiveProviderProfileFromConfig } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
delete process.env.OPENAI_BASE_URL
|
||||||
|
delete process.env.OPENAI_API_BASE
|
||||||
|
delete process.env.OPENAI_MODEL
|
||||||
|
|
||||||
|
const applied = applyActiveProviderProfileFromConfig({
|
||||||
|
providerProfiles: [
|
||||||
|
buildProfile({
|
||||||
|
id: 'saved_moonshot',
|
||||||
|
baseUrl: 'https://api.moonshot.ai/v1',
|
||||||
|
model: 'kimi-k2.6',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
activeProviderProfileId: 'saved_moonshot',
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
expect(applied?.id).toBe('saved_moonshot')
|
||||||
|
expect(process.env.OPENAI_BASE_URL).toBe('https://api.moonshot.ai/v1')
|
||||||
|
expect(process.env.OPENAI_MODEL).toBe('kimi-k2.6')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('still respects complete shell selection with USE flag + BASE_URL', async () => {
|
||||||
|
// Counter-example: when the user really did set both the flag AND a
|
||||||
|
// concrete BASE_URL, that IS explicit intent and wins over the saved
|
||||||
|
// profile. This preserves the original "explicit startup wins" semantic.
|
||||||
|
const { applyActiveProviderProfileFromConfig } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
process.env.OPENAI_BASE_URL = 'http://192.168.1.1:8080/v1'
|
||||||
|
delete process.env.OPENAI_MODEL
|
||||||
|
|
||||||
|
const applied = applyActiveProviderProfileFromConfig({
|
||||||
|
providerProfiles: [
|
||||||
|
buildProfile({
|
||||||
|
id: 'saved_moonshot',
|
||||||
|
baseUrl: 'https://api.moonshot.ai/v1',
|
||||||
|
model: 'kimi-k2.6',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
activeProviderProfileId: 'saved_moonshot',
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
expect(applied).toBeUndefined()
|
||||||
|
expect(process.env.OPENAI_BASE_URL).toBe('http://192.168.1.1:8080/v1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('still respects complete shell selection with USE flag + MODEL', async () => {
|
||||||
|
const { applyActiveProviderProfileFromConfig } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
process.env.OPENAI_MODEL = 'gpt-4o-mini'
|
||||||
|
delete process.env.OPENAI_BASE_URL
|
||||||
|
|
||||||
|
const applied = applyActiveProviderProfileFromConfig({
|
||||||
|
providerProfiles: [
|
||||||
|
buildProfile({
|
||||||
|
id: 'saved_moonshot',
|
||||||
|
baseUrl: 'https://api.moonshot.ai/v1',
|
||||||
|
model: 'kimi-k2.6',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
activeProviderProfileId: 'saved_moonshot',
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
expect(applied).toBeUndefined()
|
||||||
|
expect(process.env.OPENAI_MODEL).toBe('gpt-4o-mini')
|
||||||
|
})
|
||||||
|
|
||||||
test('does not override explicit startup selection when profile marker is stale', async () => {
|
test('does not override explicit startup selection when profile marker is stale', async () => {
|
||||||
const { applyActiveProviderProfileFromConfig } =
|
const { applyActiveProviderProfileFromConfig } =
|
||||||
await importFreshProviderProfileModules()
|
await importFreshProviderProfileModules()
|
||||||
|
|||||||
@@ -322,6 +322,58 @@ function hasProviderSelectionFlags(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A "complete" explicit provider selection = a USE flag AND at least one
|
||||||
|
* concrete config value that tells us WHERE to route (a base URL) or WHAT
|
||||||
|
* to run (a model id). A bare `CLAUDE_CODE_USE_OPENAI=1` with nothing else
|
||||||
|
* is almost always a stale shell export from a previous session, not real
|
||||||
|
* intent — and if we respect it, we skip the user's saved active profile
|
||||||
|
* and fall back to hardcoded defaults (gpt-4o / api.openai.com), which is
|
||||||
|
* the exact bug users report as "my saved provider isn't picked up".
|
||||||
|
*
|
||||||
|
* Used to gate whether saved-profile env should override shell state at
|
||||||
|
* startup. The weaker `hasProviderSelectionFlags` is still used for the
|
||||||
|
* anthropic-profile conflict check (any flag is a conflict for
|
||||||
|
* first-party anthropic) and for alignment fingerprinting.
|
||||||
|
*/
|
||||||
|
function hasCompleteProviderSelection(
|
||||||
|
processEnv: NodeJS.ProcessEnv = process.env,
|
||||||
|
): boolean {
|
||||||
|
if (!hasProviderSelectionFlags(processEnv)) return false
|
||||||
|
if (processEnv.CLAUDE_CODE_USE_OPENAI !== undefined) {
|
||||||
|
return (
|
||||||
|
trimOrUndefined(processEnv.OPENAI_BASE_URL) !== undefined ||
|
||||||
|
trimOrUndefined(processEnv.OPENAI_API_BASE) !== undefined ||
|
||||||
|
trimOrUndefined(processEnv.OPENAI_MODEL) !== undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (processEnv.CLAUDE_CODE_USE_GEMINI !== undefined) {
|
||||||
|
return (
|
||||||
|
trimOrUndefined(processEnv.GEMINI_BASE_URL) !== undefined ||
|
||||||
|
trimOrUndefined(processEnv.GEMINI_MODEL) !== undefined ||
|
||||||
|
trimOrUndefined(processEnv.GEMINI_API_KEY) !== undefined ||
|
||||||
|
trimOrUndefined(processEnv.GOOGLE_API_KEY) !== undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (processEnv.CLAUDE_CODE_USE_MISTRAL !== undefined) {
|
||||||
|
return (
|
||||||
|
trimOrUndefined(processEnv.MISTRAL_BASE_URL) !== undefined ||
|
||||||
|
trimOrUndefined(processEnv.MISTRAL_MODEL) !== undefined ||
|
||||||
|
trimOrUndefined(processEnv.MISTRAL_API_KEY) !== undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (processEnv.CLAUDE_CODE_USE_GITHUB !== undefined) {
|
||||||
|
return (
|
||||||
|
trimOrUndefined(processEnv.GITHUB_TOKEN) !== undefined ||
|
||||||
|
trimOrUndefined(processEnv.GH_TOKEN) !== undefined ||
|
||||||
|
trimOrUndefined(processEnv.OPENAI_MODEL) !== undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Bedrock / Vertex / Foundry signal cloud-provider routing in env; treat
|
||||||
|
// the flag alone as complete (these paths rely on ambient AWS/GCP creds).
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function hasConflictingProviderFlagsForProfile(
|
function hasConflictingProviderFlagsForProfile(
|
||||||
processEnv: NodeJS.ProcessEnv,
|
processEnv: NodeJS.ProcessEnv,
|
||||||
profile: ProviderProfile,
|
profile: ProviderProfile,
|
||||||
@@ -564,9 +616,15 @@ export function applyActiveProviderProfileFromConfig(
|
|||||||
processEnv[PROFILE_ENV_APPLIED_FLAG] === '1' &&
|
processEnv[PROFILE_ENV_APPLIED_FLAG] === '1' &&
|
||||||
trimOrUndefined(processEnv[PROFILE_ENV_APPLIED_ID]) === activeProfile.id
|
trimOrUndefined(processEnv[PROFILE_ENV_APPLIED_ID]) === activeProfile.id
|
||||||
|
|
||||||
if (!options?.force && (hasProviderSelectionFlags(processEnv) || processEnv[PROFILE_ENV_APPLIED_FLAG] === '1')) {
|
if (!options?.force && (hasCompleteProviderSelection(processEnv) || processEnv[PROFILE_ENV_APPLIED_FLAG] === '1')) {
|
||||||
// Respect explicit startup provider intent. Auto-heal only when this
|
// Respect explicit startup provider intent. Auto-heal only when this
|
||||||
// exact active profile previously applied the current env.
|
// exact active profile previously applied the current env.
|
||||||
|
// NOTE: we gate on hasCompleteProviderSelection (flag + concrete config)
|
||||||
|
// rather than hasProviderSelectionFlags alone. A bare CLAUDE_CODE_USE_*=1
|
||||||
|
// with no BASE_URL/MODEL is almost always a stale shell export, not
|
||||||
|
// intent — respecting it would skip the saved profile and fall through
|
||||||
|
// to hardcoded provider defaults, which surfaces as "my saved provider
|
||||||
|
// isn't being picked up at startup".
|
||||||
if (!isCurrentEnvProfileManaged) {
|
if (!isCurrentEnvProfileManaged) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user